This page mirrors the package changelog shipped inside `@gentleduck/iam` at
`packages/duck-iam/CHANGELOG.md`. That file is the source of truth; this page is
kept in sync with it by hand. Entries run newest first, from 5.10.0 down to the
first published release. Names in each entry are the names that were current in
that release: the `Iam*` / `IAM_*` export prefixes only exist from 5.0.0 onward,
so a 2.x entry naming `FileAdapter` refers to what is called `IamFileAdapter` today.

## 5.10.0

### Minor Changes

* bde674e: Authorization-correctness audit: behaviour fixes and the documentation defects
  that were hiding them.

  ### The permission map allowed what `can()` denied

  `permissions()` evaluated every check against a resource with no attributes,
  while keying the answer by `resourceId`. A caller reads that key as an answer
  about that instance, but a rule conditioned on `resource.attributes.*` could not
  fire, so a deny written that way was invisible: `read:post:42` came back `true`
  for the very row `can()` refuses.

  `IamClient.IPermissionCheck` now takes an optional `attributes`, and each check
  is evaluated against the attributes it carries. A check that omits them is
  unchanged, and still answers about an instance that has none — now stated in the
  reference instead of being inferred from the key.

  The route guards had the same hole, and it was not structural. NestJS's
  `iamNestAccessGuard` already accepted a `getResourceAttributes`; the express and
  Hono `iamAccessMiddleware`s already took attributes synchronously through
  `getResource`. The rest could not be told at all. Express and Hono's `iamGuard`,
  `withIamAccess` and `createIamNextMiddleware` now take the same
  `getResourceAttributes` option Nest has; `checkIamAccess` and the checker
  `createIamSubjectCan` returns take the attributes as an argument. Supplying them
  costs a load the guard exists to skip, so it stays opt-in: omit the callback and
  the guard remains the coarse gate on type, id and scope, with the
  attribute-dependent rule re-checked by `can()` once the row is in hand. All
  eight surfaces are now pinned against one policy so they cannot drift apart.

  ### A dot in the resource type retired a `:*` deny

  `resources: ['posts:*']` is documented to cover everything under `posts:`, and
  it did — until the resource type contained a dot anywhere. The three evaluators
  each switched to a dot-only matcher whenever either side contained a `.`, and
  that matcher ignores `':*'` entirely. So a deny rule covering `posts:comment`
  silently stopped covering `posts:comment.reply`: adding a dot to a resource
  type removed it from every colon-wildcard rule in the policy set.

  The policy-target level never switched, so the policy was still selected while
  its own rule missed — the inconsistency that made it invisible.

  All three evaluators (interpreter, compiled table, `explain()`) now use the
  documented matcher, which reads the separator from the *pattern* and treats one
  in the request as data. `.*` subtree matching, bare-literal behaviour and the
  "a `:` pattern never matches a `.` type" rule are unchanged and pinned.
  `matchesResourceHierarchical` stays exported as the strict dot-only variant and
  is documented as no longer used by the engine.

  ### A route guard named the wrong row, or none

  The guards that read the resource id read one path param, `id`, and could not be
  told otherwise. On a nested route that names a different row: `/orgs/:id/posts/:postId`
  guarded as a `post` sends the **org's** id as `resource.id`, so a rule denying a
  particular post, or comparing `resource.id` against what the subject owns, was
  answered about the wrong instance. Express and Hono's `iamGuard` and
  `withIamAccess` now take the `getResourceId` option Nest already had; the default
  is unchanged.

  `createIamNextMiddleware` had no id at all. It matches a path prefix and knows
  the rule's resource type, so a rule reading `resource.id` could not fire and the
  request passed — the same fail-open shape as an attribute rule with no
  attributes. It now takes `getResourceId` too, with no default, because only the
  rule's own path shape says which segment is the id.

  The resolved id is also handed to `getResourceAttributes` on every guard, so a
  loader can fetch the row the check is actually about rather than re-deriving it.

  ### A Server Component check ignored the environment

  `checkIamAccess` and `getIamPermissions` passed `undefined` where every other
  integration passes a request-derived environment, and neither accepted one. A
  policy keyed on `environment.ip`, `environment.userAgent` or a custom key read
  as a non-match, so a deny that fired in Next middleware was inert in a Server
  Component asking the same question. Both now take an `environment` argument and
  forward it.

  ### A wedged database hung every admin call forever

  `adapterTimeoutMs` is documented as a per-adapter-call timeout and was wired
  only into the decision path. Against an adapter that never answers, `can()`
  returned `false` as designed while every `engine.admin` call — including
  `admin.listPolicies()`, the *same adapter method* `can()` bounds — never
  settled. An admin route or dashboard hit a wedged database and hung, with no
  error, no audit event, and nothing to say whether the write had landed.

  All seventeen admin operations now run under the same timeout and reject with
  the call named. Reads pass the abort signal through, so an adapter that honours
  it cancels the query. Writes take no signal, so the timeout frees the caller
  while the write runs on: a rejection means "not confirmed" rather than "not
  applied", and every admin write is idempotent by id. The transaction-bound
  admin stays unbounded on purpose — aborting mid-transaction leaves the
  transaction for the caller to roll back — and that exclusion is now pinned by a
  test rather than left to be rediscovered.

  ### An admin write over HTTP was recorded as nobody's

  The admin routers authenticate a caller and hand it to their own audit hook.
  They never handed it to `engine.admin`, so the engine's `onMutation` fired with
  no actor and the SQL adapters wrote `created_by` / `updated_by` as null for
  every request-driven write — the columns the drizzle and prisma schemas ship for
  exactly this. An operator who wired `onMutation` instead of `onAdminMutation`
  had no attribution at all, and nothing said so.

  All four routers now forward a string `authorize` answer to `engine.admin`. An
  object answer — a claims object, the documented common case — still names no one
  to the engine, because its actor is a string and picking a field would be a
  guess; the new `getMutationActor` option picks it. A value that names no one,
  blank or non-string, is discarded rather than written.

  `admin.import` had the same defect one layer down: it stamped its mutation
  events with the actor it was given and passed nothing to `adapter.savePolicy` /
  `saveRole`. It was the only admin write in the package that did. Fixed, and
  every engine-side write is now pinned against a recording adapter so a new one
  cannot be added without provenance.

  ### `preload({ validator: true })` never ran the validator

  The flag loaded the lazy validate chunk and then dropped it on the floor: the
  module was imported, `validatePolicy` and `validateRole` were never called, and
  `preload()` resolved. Since `engine.admin` is the only thing that validates —
  all six adapters call the same write gate, the read path validates nothing — a
  row that entered storage another way (a migration, a seed script, a restore, a
  direct SQL insert, another service writing the same table) was loaded and
  evaluated exactly as stored, and nothing in the package could tell an operator.

  Which matters because the two failure modes differ. A condition the evaluator
  refuses is Indeterminate and the policy denies: loud, fail-closed, visible on
  the first request. A row that merely never matches — an action carrying a
  trailing newline from a CSV import, an unreachable resource pattern — just
  misses, so a **deny** in that shape silently never fires and the request is
  allowed.

  `preload({ validator: true })` now reads the roles too and validates every
  stored policy and role, throwing once with the exact number of offending rows
  and up to ten named with their reason. Only `type: 'error'` issues fail the
  boot; a `BROAD_ALLOW` warning does not. Without the flag `preload()` reads no
  roles and runs no validator, so the cost stays opt-in.

  ### A route guard could not name the tenant it was deciding in

  The access request has four dimensions — action, resource type, row, scope —
  and the scope was the one no guard could derive from the request. `iamGuard`
  (express and hono), `withIamAccess` and `createIamNextMiddleware` took only a
  fixed `scope` fixed at mount time, so on the canonical multi-tenant route,
  `/orgs/:orgId/posts/:postId`, every check ran with `scope: undefined` even
  though the tenant was in the path. Only the nest guard could already read one
  per request.

  Running unscoped is not neutral in either direction. A scoped assignment —
  `assignRole('u1', 'editor', { scope: 'org-1' })` — only enriches the subject
  when the request carries a matching scope, so the grant did not apply and the
  guard denied a caller who genuinely held the role. A rule conditioned on
  `scope` is the opposite: the field resolves to `null`, the condition does not
  match, and a **deny** in that shape silently never fired.

  All four now take `getScope`, matching the extractor the two
  `iamAccessMiddleware` surfaces already had and the shape nest already used. A
  fixed `scope` still wins where both are given, so existing mounts keep their
  meaning, and the resolved scope is handed to `getResourceAttributes` so an
  attribute loader can read the row from the right tenant.

  ### A fail-closed deny told the observers nothing

  `afterEvaluate`, `onDeny` and `onMetrics` fired for every verdict the evaluator
  produced — and for none of the verdicts the engine produced without reaching
  it. A malformed `subjectId` was refused silently, with no hook at all. An
  adapter that would not answer fired `onError` and then denied, unobserved.
  `permissions()` returned an all-`false` map on a batch load failure after one
  `onError`, and a `false` per check that threw with only a metric.

  These are exactly the denials worth watching. During an adapter outage the
  engine refuses every request while `iamCreateMetricsAggregator().snapshot()`
  held `total` and `deny` flat — an authorization failure that reads on a
  dashboard as a traffic drop rather than a deny spike, and a security log fed by
  `onDeny` that records nothing for the whole window.

  Every verdict now reaches the observers. `decision.failure` names the path:
  `'input'` for a malformed subject id, `'resolution'` for a subject or policy
  load that threw, `'evaluation'` for a throw mid-check. `permissions()` emits one
  per map entry, because there is one verdict per map entry, and
  `{ telemetry: false }` still reports the deny while skipping the metric, as it
  already did on the evaluated path. A malformed id does not also fire `onError`:
  it is input, not a fault.

  ### A mistyped `scopeCombine` widened the role set

  `policyCombine` has a boot-time guard because both evaluators branch on one
  literal and fall through to the most permissive option for anything else. The
  two scope settings have the identical shape and had no guard at all.

  `scopeCombine` is the sharper of the two, because its fall-through is the
  *wider* branch. A subject holding `admin` at `org-1` and `viewer` at
  `org-1.team-a`, checked at `org-1.team-a` under `scopeMode: 'hierarchical'`,
  resolves to `['viewer']` with `'override'` and to `['admin', 'viewer']` with
  anything that is not that exact string — so `'overide'` in a config file
  silently handed the caller every ancestor scope's roles, and `can('delete')`
  flipped from `false` to `true`. `scopeMode` falls through to `'flat'`, which is
  narrower, but it still means a hierarchical deployment quietly stops honouring
  descendant scopes.

  Both are now refused at construction with the same message shape as
  `policyCombine`, so a bad value is a failed start rather than a quiet change of
  meaning.

  ### A mistyped `mode` turned every deny into an allow

  `IConfig.mode` was assigned with a bare `?? 'production'` and never checked,
  while every site that reads it compares `this._mode === 'production'`. So
  `'prodution'` did not fail, and did not run production either — it selected
  development behaviour at all nine sites at once.

  The worst of those is `check()`. In development it answers an `IDecision`
  object instead of a bare boolean, and that object is truthy even when it
  denies, so `if (await engine.check(...))` admitted every denial. TypeScript
  could not catch it: `TMode` is a type argument that `mode` does not have to
  agree with, so the call site still read as `boolean`. `explain()` also became
  callable, exposing policy ids, rule ids and condition values in what the
  operator believed was production, and the existing
  `mode: 'production'` + `policyCombine: 'first-applicable'` guard was bypassed,
  because the typo is not the string that check compares against.

  `mode` is now refused at construction with the same message shape as
  `policyCombine`, which also closes the `first-applicable` bypass.

  ### `constructor` and `toString` were usable as condition operators

  `evalCondition` dispatched with a bare `ops[cond.operator]` and then checked
  `typeof op !== 'function'`. `ops` is an object literal, so every
  `Object.prototype` member is a function on it and passed that check.
  `constructor` returned a boxed object and `toString` returned the string
  `"[object Undefined]"` — both truthy — so the condition reported as satisfied
  without ever comparing anything.

  A rule carrying one fired unconditionally: an allow rule granted, and a deny
  rule guarded by `none` was retired. It happened in both modes, because the fast
  path's `conditionMayThrow` had already classified these names correctly — it
  uses `Object.hasOwn` — and handed the policy to the interpreter, which is where
  the truthy answer came from.

  The write-path validator refuses these names, so only a row that reached
  storage another way — a seed, a migration, a direct write — could carry one.
  That is the same gap `preload({ validator: true })` exists to close.

  Dispatch is now own-property only in `evalCondition`, in the interpreter's
  combining-algorithm lookup, and in the public `iamEvaluateOperator`, which
  previously let a policy linter report such a condition as satisfied.

  ### A mistyped `deny` was outvoted by its own policy's allow

  `IRule.effect` is typed `'allow' | 'deny'`, and every branch that reads it tests
  both names positively. An earlier fix made an unrecognised effect vote for
  neither side, which is fail-closed for a policy whose *only* rule carries it —
  nothing matches, so `defaultEffect` answers — and that is the shape that was
  tested.

  Give that deny a sibling allow in the same policy and it inverts. Measured, one
  `deny-overrides` policy with an unconditional allow and a second rule spelled
  `'DENY'`: `can()` answered **true**, against `false` for `'deny'`. The compiled
  table agreed, by a different route: its flat model reads a non-allow effect as a
  deny, so the cell became DYNAMIC, and the DYNAMIC cell asks the combiners, which
  read it as neither.

  An unrecognised effect is now Indeterminate rather than an abstention, which is
  the same rule already applied to an unanswerable condition and an unknown
  combining algorithm. `policyHasDenyRule` counts anything that is not `'allow'`,
  so the Indeterminate denies instead of casting `defaultEffect`, and the policy
  is reported through `onPolicyError` naming the offending rule.

  The write path refuses these rows, so only a seed, a migration or a direct write
  can carry one.

  ### A deny with a broken priority was outranked by every allow

  `first-match` and `highest-priority` rank matched rules by `rule.priority`.
  `rulePriority` read a priority that was not a finite number — `NaN`, `null`,
  `'urgent'`, or absent — as `0`. That is a real rank, the lowest meaningful one,
  so the rule stayed in the ranking and lost to any allow above it.

  Measured, a `highest-priority` policy holding an unconditional allow at priority
  5 and a deny carrying `'urgent'`, `-Infinity` or no priority at all: `can()`
  answered **true** in both evaluators, against `false` for the same deny at
  priority 9. Reading it as `-Infinity` loses the same way.

  A priority the engine cannot rank is now Indeterminate, matching the rule
  already applied to an unanswerable condition, an unknown combining algorithm and
  an unrecognised effect. Both evaluators refuse it at policy level, after the
  NotApplicable tests they already share, so they refuse exactly the same
  requests; the compiled table's flat model never reads `priority`, so such a
  policy is forced out of it. `deny-overrides` and `allow-overrides` do not rank,
  and are unaffected.

  The write path refuses these rows, so only a seed, a migration or a direct write
  can carry one.

  ### Production allowed what development denied for a rule with no conditions object

  `IRule.conditions` is required, and `evalConditionGroup` refuses anything that
  is not a condition group. The fast path's `conditionMayThrow` classifier
  disagreed: it read a node that is not an object — `undefined`, `null`, a string,
  a number — as a leaf that cannot throw. So `idx.mayThrow` stayed false, the fast
  path scanned the policy itself instead of handing it to the interpreter, and
  `allow-overrides` returned on its first unconditional allow without ever
  reaching the broken rule.

  Measured, an `allow-overrides` policy with an unconditional allow and a deny
  whose `conditions` was absent, `null` or a string, made residual by a `*`
  resource: `can()` answered **false** in development and **true** in production.
  The same shape nested one level down (`{ all: [null] }`) behaved the same way.

  A non-object node is now a throw site at every depth, so the fast path delegates
  and both modes refuse identically.

  `evalConditionGroup` also reports it properly. `'all' in group` raises a bare
  `TypeError` on a non-object, which is Indeterminate to a caller but arrives
  without a duck-iam message — and it fired before the branch written to describe
  a non-object could run, so that branch was unreachable. The non-object test now
  runs first and throws `IamConditionGroupError('unknown-keys')` naming what it
  saw.

  The write path refuses these rows, so only a seed, a migration or a direct write
  can carry one.

  ### `explain()` disagreed with the decision it was explaining

  `explain()` reaches its verdict through its own combiner switch and its own
  target matching, which makes it a third evaluator — and nothing compared it to
  a verdict. Adding that comparison to the 6000-catalog differential found five
  disagreements.

  Reported **allow** where `can()` denied: an unrecognised `rule.effect` (the
  combiner matches `'deny'` and `'allow'` positively, so a mistyped one voted for
  neither) and a non-finite `rule.priority` (never checked at all). An unknown
  combining algorithm fell off the end of the switch and raised
  `Cannot destructure property 'effect'` out of `explain()` itself, so the
  diagnostic crashed on exactly the input it exists to explain.

  Reported **deny** where `can()` allowed: a throwing condition on a rule that
  does not target the request poisoned the whole policy, though `ruleApplies`
  never evaluates such a rule's conditions; and a throwing permission inside the
  allow-only RBAC union was not allowed to abstain, though the decision path lets
  it.

  The policy-level refusals now come from one place and run behind the same
  rule-target test the two evaluators share. `explain()` answers `defaultEffect`
  directly when no rule matched, so the combining algorithm is never consulted
  where it has no arm, and the hand-copied policy target matcher is gone in
  favour of `policyApplies`. A non-object condition group is reported by name in
  the trace rather than as a bare `TypeError`.

  ### A role grant retired an explicit deny under `first-applicable`

  `policyCombine: 'first-applicable'` stops at the first policy that is not
  NotApplicable, so whichever policy comes first is the decision. `loadAllPolicies`
  handed over the synthetic `__rbac__` policy first — a policy the operator never
  wrote, generated from the role definitions.

  It is applicable to any action/resource pair *any* role grants, so it decided
  both ways it should not have. A subject holding a role that grants
  `delete` on `post` got that allow and never reached the ABAC policy denying a
  locked post: granting a role widened access past an explicit deny. A subject
  holding no such role got the RBAC policy's `defaultEffect` deny and never
  reached an ABAC policy that would have allowed.

  The generated policy now goes last, in `evaluate` and in `explain`'s
  `decideFinal` through one shared helper, so the trace and the decision cannot
  name different policies. `'and'` and `'allow-overrides'` fold every applicable
  vote and are unchanged. The order of the stored policies themselves is still the
  adapter's row order, as it is for rule order, and no adapter orders
  `listPolicies`.

  ### A mistyped action in a policy rule was reported for roles and not for policies

  `createIam` constrains `engine.check` to the declared action and resource
  unions, so anything a rule names outside them can never be requested.
  `createIam(...).validateRoles` has always reported that for a grant —
  `UNREACHABLE_TARGET`, an error. `createIam(...).validatePolicy` did not pass the
  vocabulary through at all, so the same typo in a policy rule returned
  `valid: true`.

  The direction that matters is the deny. A policy that allows `'*'` on `post`
  and means to deny `delete`, written `actions: ['delet']`, answers `can('delete',
  'post')` with `true`; spelled correctly it answers `false`. Nothing reported it.

  `validatePolicy` now takes the same optional declared surface and checks
  `rule.actions`, `rule.resources`, `targets.actions`, `targets.resources` and
  `targets.roles`. Patterns are cleared through the engine's own `matchesAction`
  and `matchesResource`, so a prefix is accepted on exactly the values it would
  match at runtime and the action axis's lack of a dot form is honoured. The bare
  `validatePolicy` export is unchanged, as the bare `validateRoles` is.

  ### A role grant the engine honours was reported as unreachable

  The vocabulary pass `createIam(...).validateRoles` runs matched a grant against
  the declared list with `includes`, so only a literal or `'*'` could clear it.
  `validateRoles` takes the **unconstrained** `IRole` on purpose — it exists for
  rows read from a store, a config file or an admin form — and the engine honours
  a prefix pattern in a role permission: `{ action: 'post:*', resource: 'org.*' }`
  grants `post:create` on `org.team` and nothing else.

  That grant came back `valid: false` with two `UNREACHABLE_TARGET` errors, which
  `PolicyBuilder`-style callers and admin UIs read as a refusal. Each axis is now
  cleared by the matcher the engine actually uses — `matchesAction`,
  `matchesResource`, `matchesScope` — so `'post:*'` and `'org.*'` pass, while
  `'admin.*'` on the action axis is still reported (there is no dot form there)
  and `'org-1.*'` as a scope is still reported (scopes match exactly).

  ### A non-string entry in `policy.targets` retired the policy, or the whole engine

  `validatePolicy` type-checked every entry of `rule.actions` and `rule.resources`
  — string, no control characters — and checked `targets.actions`,
  `targets.resources` and `targets.roles` only for `Array.isArray`. Not one entry
  inside them was looked at, and the vocabulary pass skipped a list that was not
  already all strings, so a malformed target was the one corner nothing read.

  Both outcomes are reachable from a policy the validator called `valid: true`:

  * **A target the roles axis cannot match retires the policy's denies.**
    `targets.roles` is compared with `includes`, so a number — or a role id
    carrying a stray control character — matches no subject. The policy becomes
    NotApplicable for everyone and the deny inside it never runs. Measured with a
    policy that allows `'*'` and denies a locked resource: with
    `targets: { roles: ['editor'] }` the locked request answers `false`; with
    `roles: [42]` it answers `true`.
  * **A target on the action or resource axis takes the process down.**
    `matchesAction` calls `.endsWith` on the pattern, so a number throws while the
    compiled table is being built. The engine fails closed and warns, but every
    request in that process is denied until the row is removed.

  Entries are now checked the way rule entries always were, per axis and per
  index, so the write path refuses the row: `savePolicy` reports
  `targets.roles[0] must be a string`. Rows already in a store are not
  re-validated, so the runtime behaviour above is pinned by tests rather than
  changed.

  ### The published policy schema refused four shapes the validator accepted

  `POLICY_JSON_SCHEMA` is published so operators can gate policies in an admin UI
  or in CI, and its contract is one-directional: anything it rejects,
  `validatePolicy` rejects too, so a policy the runtime accepts always validates
  there. Four shapes broke that, all of them fields `IPolicy` and `IRule` already
  type:

  | Shape                                   | `validatePolicy` | schema   |
  | --------------------------------------- | ---------------- | -------- |
  | `description` on a policy, not a string | accepted         | rejected |
  | `description` on a rule, not a string   | accepted         | rejected |
  | `metadata` on a rule, not an object     | accepted         | rejected |
  | `targets: null`                         | accepted         | rejected |

  `description` and `metadata` were listed in the known-key sets and type-checked
  nowhere; `targets: null` was explicitly skipped. The validator now checks all
  four, which is the side that was wrong — no adapter produces any of them (both
  drizzle and prisma drop a null column rather than passing it on), and `explain()`
  renders a rule `description` into the trace it hands an operator.

  **Behaviour change:** `savePolicy`, `admin.import()` and `PolicyBuilder.build()`
  now refuse a policy carrying `targets: null` or a non-string `description`.
  TypeScript callers cannot write any of these shapes; plain-JS and JSON callers
  that did were writing a policy the published schema already rejected.

  ### The fuzzer pinning that contract tested it on 147 policies out of 4000

  The randomised half of `schema-validator-agreement.test.ts` generated each slot
  independently from its own pool, so almost every policy was invalid for some
  unrelated reason and never reached the implication under test — `validatePolicy`
  accepted 147 of 4000. `targets` was not in the generator at all (one constant,
  `{ actions: ['read'] }`), and `description`, `metadata` and `priority` were
  constants too, which is why the four shapes above survived 4000 passes a round.

  Its non-vacuity guard asked a *different*, simpler generator whether it produced
  accepted policies, so the number it reported said nothing about the generator
  being pinned.

  The generator now builds a valid policy and perturbs one slot (sometimes two)
  from a pool covering every optional field, and the guard runs on that same
  generator: 1417 of 4000 accepted, against a floor of 1000. Reverting any of the
  four fixes above now fails it; before the rewrite, reverting the `rule.metadata`
  fix did not.

  ### Vue's access state shared the permission map the caller still held

  `createAccessState(map)` put the caller's object straight into its `ref`, and
  `update(next)` assigned `next` itself. React's `AccessProvider` and the vanilla
  `IamAccessClient` both copy the map in and both say why in a comment;
  `createIamPermissionChecker` shares it deliberately and documents that. Vue was
  the one surface that shared it silently — and it shared in both directions,
  since the `Ref` also hands the stored object back through `permissions.value`.

  So on Vue, and only on Vue, `map['delete:post'] = true` after construction made
  `can('delete', 'post')` start answering `true`, with no reactivity triggered:
  `ref()` sees an assignment to `.value`, not an edit inside the object it holds.
  The same write through `state.permissions.value` did the same thing. The map is
  client-side and the server re-checks, so this is a UI-correctness and
  cross-binding-consistency bug rather than a bypass — a gate rendered from a map
  somebody else could edit.

  `createAccessState` now stores `Object.freeze({ ...map })`, in the constructor
  and in `update`, which closes both directions: the copy defends the map you
  handed in, the freeze defends the one you read back.

  The reference doc's ownership matrix was wrong in two more cells while it was
  there: it said a write into the map React's `AccessProvider` hands back changes
  `can()`, but that snapshot has been frozen all along. All four surfaces are now
  pinned by one table-shaped test rather than by prose.

  ### A Next route handler's own error was reported as an authorization failure

  `withIamAccess` wrapped the route handler in the same `try` as the permission
  check, so anything the route threw came back through the adapter's `onError` —
  documented as *"handles thrown errors during evaluation"* — and the client got
  the IAM layer's 500 `{error:'Internal server error'}` instead of whatever the
  route meant to answer. Next's own error handling never saw it.

  Only half the time, which is the part worth knowing. The call was spelled
  `return handler(req, ctx)`, with no `await`, and an unawaited `return` inside a
  `try` still routes a *synchronous* throw to the `catch` while letting a
  rejection past. So the same route error behaved differently depending on
  whether the route was declared `async`.

  Hono had this fixed a round earlier, and it was written up then as hono being
  "the only one of the five". That was wrong: the check behind it looked for an
  *awaited* downstream call inside the try, and `withIamAccess` was not awaiting.

  `handler(req, ctx)` now sits outside the try, as `await next()` does in hono.
  `cross-adapter.test.ts` runs the assertion against both spellings, and a new
  express control in the HTTP e2e boots a real express app to show why the one
  adapter still calling its downstream inside the try is unaffected: express
  dispatches each layer inside a try of its own and turns the throw into
  `next(err)` before it could unwind.

  ### A malformed role grant in the file store turned a deny into an allow

  The file adapter's assignment parser dropped a malformed `{role, scope?}`
  entry, reported it through `onPolicyError`, and answered the read with the
  grants that did parse. Losing a grant reads like *less* authority, so this
  looked safe. It is not: a policy's `targets.roles` names the subject's
  **grants**, and `policyApplies` drops a policy whose targets no longer
  intersect the subject's roles. A deny policy targeting `banned` stops applying
  the moment the `banned` grant cannot be read, and the allow that the same file
  grants through another role carries the decision.

  Measured through the engine, with an `allow-all-read` policy and a
  `deny-banned` policy targeting `targets.roles: ['banned']`: a subject holding
  `reader` + `banned` is denied; the same subject whose `banned` entry is
  `{role: 'banned', scope: 5}` — a number where a string belongs — is **allowed**.
  Same for `{role: 42}`, for a bare string entry, and for a row that is not an
  array at all.

  The same file already fails closed for a corrupt *attributes* row, for the same
  stated reason, so the fix is that design applied to assignments: the row moves
  into `corruptAssignments`, `getSubjectRoles` and `getSubjectScopedRoles` throw,
  and `assignRole` / `revokeRole` / `updateAssignmentScope` refuse — an
  incremental edit would write back a row with the unreadable entry silently
  gone.

  The second half was worse than the read. `_serializableState` wrote the
  *parsed* assignments back, so the next flush of any kind — an unrelated grant
  for another subject — erased the malformed entry from disk permanently, and the
  store then looked clean. The raw row is now written back verbatim, as the
  corrupt attributes row already was, so the file still holds what an operator
  has to repair. `deleteRole` cannot sweep a row it refuses to read, and now
  reports each one it skipped rather than implying the role is gone.

  ### The HTTP adapter answered a subject read with the half of the list that parsed

  The same defect as the file store above, at the adapter where the payload is
  remote and least trusted. `parseHttpSubjectRoles` skipped any element that was
  not a non-empty string and `parseHttpSubjectScopedRoles` skipped any element
  whose `role` or `scope` was not one, both silently, and both returned the rest.
  A test pinned that as correct on the premise that "roles are allow-only" — the
  premise the file-store finding had already disproved.

  Measured through the engine over a fake server, with a `deny delete:post`
  policy at `targets.roles: ['contractor']` and a subject holding `editor` +
  `contractor`: the well-formed list denies, and the same list with the
  `contractor` entry arriving as `{role: 'contractor', scope: 'org-1'}` — the one
  server mistake the endpoint contract explicitly warns about — **allows**, with
  nothing reported. Same for `null`, `42` and `''` in that position. A payload
  where *every* element is malformed already failed closed, because a subject with
  no roles has no allow either; only the partial list flipped the verdict, which
  is why it survived.

  `adapters-runtime.md` had documented the throwing behaviour as the contract for
  both methods for as long as the drop existed. The parsers now match it: the
  element that will not parse fails the read, naming its index and its type but
  never its value. A row carrying no `scope` on `/scoped-roles` throws too — the
  two endpoints are disjoint by contract, so that row is the server mixing them
  up rather than a global grant. A malformed *catalog* row from `GET /roles` is
  still dropped and reported, because a policy left targeting it is now reported
  in its own right; a dropped grant has no such witness.

  ### A subject's grant of a role nothing defines lost every role it inherited

  The grant-side half of the target check above, and the case that one could not
  see. `resolveEffectiveRoles` deliberately keeps a directly assigned role id that
  no stored role defines, so the id still matches an ABAC rule naming it and the
  grant looks intact. But there is no row to read `inherits` from, so every role
  that id conferred is silently absent — and a deny targeting one of *those*
  retires.

  Measured through the engine with a `deny delete:post` policy at
  `targets.roles: ['contractor']` and a subject holding `staff` + `editor`, where
  `staff inherits contractor`: with the `staff` row present the subject is denied;
  with the `staff` row missing from the catalogue the subject is **allowed**, and
  nothing is reported — the policy's own target, `contractor`, is stored and
  healthy, so the target check stays quiet. A subject holding `contractor`
  directly is still denied, and one holding only `editor` is still allowed, which
  is what makes the middle row a finding rather than an engine that denies.

  Four adapters drop a malformed role row rather than refusing it, on the reasoning
  that role permissions are allow-only and a role nobody can resolve grants
  nothing. The first half is true and the second does not follow: the drop is
  tolerable only because something reports the grants left pointing at nothing.
  Now something does, de-duplicated per role id — the absent definition is what
  needs repairing and every holder shares it. Both grant paths report, global and
  scoped. It changes no verdict.

  The premise had been written into five comments and a test-file header across
  the adapters; those now say what is actually true.

  ### A misspelled action or resource target retired a policy in silence

  The third carrier of the same defect. `targets.actions` and `targets.resources`
  are matched against the request, so an entry naming an operation the policy's
  own rules never mention admits nothing and the policy is skipped for every
  request — the same retired deny as a `targets.roles` entry naming no stored
  role, which is already reported.

  Measured with a `deny delete:post` rule: `targets: {actions: ['delte']}` allows,
  `{resources: ['psot']}` allows, and both are silent, while the identical typo in
  `targets.roles` is reported. Spelled correctly, or with no targets at all, the
  deny fires.

  No catalogue is needed to see it. The engine is never handed the declared
  vocabulary — that lives on `createIam` and reaches only its `validatePolicy`,
  which callers invoke or do not — but a policy's own rules are the vocabulary
  that matters: if no pattern in any rule could match a request the targets admit,
  the policy cannot fire whatever the catalogue says. Pattern intersection follows
  the matchers, so `*` intersects anything and a prefix form keeps its separator,
  leaving `post:*` and `post.*` disjoint exactly as `matchesResource` treats them.
  One live rule among dead ones keeps the policy reachable and quiet.

  ### A condition reading a misspelled path silently stopped its deny from firing

  One level below the targets: a rule's condition `field`. `resolve` answers
  `null` for a path whose root is not `subject`, `resource` or `environment`, and
  for one carrying a prototype key at any segment — on every request, for every
  input. The rule then never matches, and the deny never fires.

  Measured with a deny given a companion allow in the same policy, so the verdict
  distinguishes a deny that fired from one that did not:
  `subject.attributes.banned` denies, `resource.attributes.sensitive` denies, the
  same subject unbanned is allowed — and `user.banned`,
  `Subject.attributes.banned`, `subject.__proto__.banned` are all **allowed**,
  with nothing reported.

  The oracle already existed. `isResolvablePath` is exported for the validator and
  kept in parity with `resolve` by its own test; it had no caller on the load
  path, because `validatePolicy` is something users invoke and the engine does
  not. The engine now runs it over every rule's conditions when policies load,
  reporting once per policy, rule and path.

  Deliberately not reported: a path that is merely *absent*, such as
  `subject.attributes.bannd`. Its root is legal and its shape resolvable — whether
  the key exists is a fact about the request, and attributes are open-ended. Also
  not reported: a `$`-prefixed value operand, which `evalCondition` already
  reports and answers Indeterminate rather than false.

  ### A rule its own policy's targets excluded could never fire, and the policy looked fine

  The mirror of the dead-target check above. Reporting only when *no* rule matches
  the targets left the harder case: the policy is reachable, one rule inside it is
  not. The policy fires, so nothing looks wrong — and the deny in the excluded
  rule is gone, better hidden than one in a policy that never applies.

  Measured with an allow and a deny in the same policy under
  `targets: {actions: ['delete']}`: the deny spelled `delete` denies, the deny
  spelled `delte` **allows**, and nothing was reported either way, because the
  allow rule kept the policy reachable. Same by resource.

  Each excluded rule is now reported on its own, once per policy, rule and
  dimension. A policy whose rules are all excluded is still reported once at the
  policy level rather than once per rule. A wildcard target admits everything, and
  a policy with no targets has nothing to judge its rules against, so both stay
  quiet.

  **The test that had to change.** The dead-target work had pinned "one live rule
  among dead ones keeps the policy reachable" with `reported: []`. That was the
  conservative choice at the time and this round measured it wrong: the policy is
  reachable, and the dead rule still needs naming. It now asserts the policy-level
  silence *and* the new per-rule report.

  ### A `0` that switches off a timeout or a cap now says so

  `adapterTimeoutMs`, `hookTimeoutMs` and `maxConcurrentSubjectLoads` all read `0`
  as "off", and each one turned off is a fail-closed mechanism. With a timeout, an
  adapter that stops answering makes `can()` deny; with `0` the check hangs
  instead, and never returns. A hook whose promise never settles does the same to
  the evaluation. An unbounded subject loader never sheds a cache-miss burst.

  The non-finite guard beside them cannot catch this - `0` is legal and
  documented. What makes it worth reporting is that `Number('')` is `0`, so an
  environment variable that is set but empty lands on the off switch, while an
  unset one gives `NaN` and is refused outright.

  Each now warns once per process at construction, naming what stops happening.
  Nothing changes for the defaults or for any real value.

  ### The Express middleware no longer answers for errors that are not its own

  Hono and Next both call the downstream handler outside their `try`, each with a
  comment saying why: a route's own error belongs to the framework, not to the
  authorization guard. Express called `next()` inside it.

  Express 4 invokes the next middleware synchronously, so a route that threw
  synchronously came back out of `next()`, was caught by the authorization
  middleware and was answered by its `onError` - a fixed 500 that pre-empts the
  app's own error-handling middleware and reports a route failure as an
  authorization failure. Both `iamAccessMiddleware` and `iamGuard` were affected.

  Both now let a route error past. Evaluation errors, a throwing `getUserId` and
  every other failure the guard is responsible for still reach `onError`
  unchanged, and a denial still answers 403 without calling `next()`.

  ### `mode: 'development'` now announces itself, like the other fail-open setting

  `defaultEffect: 'allow'` has always warned unconditionally at construction, so
  that a log search for fail-open configurations finds it. Development mode is the
  other one, and the sharper of the two: under fail-open a deny still answers
  `false`, but in development `check()` and `authorize()` answer an `IDecision`,
  and an object is truthy even when `allowed` is `false`. `if (await
  engine.check(...))` therefore allows every request, denies included. It warned
  nothing.

  The constructor now warns once per process, naming that consequence rather than
  just the mode, and pointing at `.allowed` and at `can()`, which stays a boolean
  in both modes. Worth knowing because `policyCombine: 'first-applicable'` is
  refused in production, so choosing that combine chooses this mode with it.

  ### `setInvalidator` no longer leaves an engine attached in one direction only

  The invalidator was stored before `subscribe` returned, so a `subscribe` that
  threw left an engine that publishes its own revocations and applies nobody
  else's: stale for the life of the process, fleet-wide, with `healthCheck()`
  reporting nothing about it. It is now stored only after `subscribe` returns, the
  engine is left fully detached, and the error reaches the caller.

  `dispose()` detaches the invalidator as well as releasing the subscription, for
  the same reason - an engine that has stopped receiving but still broadcasts is
  the worse half of the pair to keep.

  Two things `subscribe` can hand back are now reported instead of absorbed. A
  return value that is not a function means the subscription can never be
  released, so nothing callable is stored and a warning says so. A teardown that
  throws is still caught - it must not mask the reason for shutting down - but is
  warned about, because the subscription may still be delivering into an engine
  that was already released.

  ### An invalidation the engine cannot apply now drops the caches instead of being ignored

  Events arrive from a transport the operator writes, so the engine now
  shape-checks what it receives rather than trusting the handler parameter's type:
  `kind` must be one of the four, a `subject` event must carry a non-empty string
  `subjectId`, and a `roles` event may carry a non-empty string `roleId` or none.

  Anything that fails that check is applied as `{kind:'all'}` - every local cache
  and the compiled table dropped - and warned about once per distinct reason.
  Before, an unrecognised `kind` was silently ignored, so a newer peer's event
  during a rolling deploy left older instances serving the answer that peer had
  just said was stale; and an event that was not an object threw out of the
  handler, which on an EventEmitter transport is an uncaught exception. A value
  that cannot even be inspected, such as a throwing getter or a revoked proxy, is
  now a reason rather than a throw.

  Note the trade: a transport that delivers malformed messages repeatedly will now
  drop the caches repeatedly where before it did nothing. The warning names the
  reason. There is no fleet amplification - every applied branch is local only.

  The shipped redis invalidator already validated each decoded event and never
  reached this path.

  ### An invalidator whose `publish` fails can no longer take the process down

  `IInvalidator.publish` is operator-supplied code, is allowed to return a
  promise, and runs on the revocation path. The engine called it as a bare
  `void publish(...)`, so a rejection was unhandled - fatal under Node's default
  `--unhandled-rejections=throw` - and a synchronous throw came back out of
  `admin.revokeRole` as a failure, for a revoke that had already landed and
  already cleared the local caches.

  Both are now caught, as every other advisory callback in the engine already was,
  and reported as one `console.warn` naming the event kind and stating that this
  instance is up to date while other instances keep their caches until their own
  TTL expires. No verdict changes, and the local caches are cleared before the
  publish either way, so a transport failure degrades to per-instance TTL
  staleness rather than to a local one.

  The shipped redis invalidator already absorbed and reported its own publish
  failures, so it never reached this path.

  ### A write that timed out no longer leaves the old answer cached

  `adapterTimeoutMs` bounds how long the caller waits, not how long the store
  takes, and an admin write takes `IActorOptions`, which carries no abort signal.
  So a write that times out may well land a moment later. Until now the
  invalidation that follows the write was skipped in that case, and the engine
  kept serving the old answer for a full cache TTL: `revokeRole` could time out,
  the row could be deleted in the store, and `can()` would still return `true`.

  Every single-row admin write — `savePolicy`, `deletePolicy`, `saveRole`,
  `deleteRole`, `assignRole`, `revokeRole` and both paths of
  `updateAssignmentScope` — now invalidates whether the call resolves or throws.
  Dropping a cache entry for a write that did not land costs one re-read; keeping
  one for a write that did land is a revocation that never took effect. The batch
  methods already worked this way.

  `hooks.onMutation` is deliberately unchanged: it still fires only after a write
  resolves, because an audit seam that records writes which may not have happened
  is worse than one that misses a write whose outcome was never learned.

  ### A rule that no request can reach is now reported

  A rule can be unreachable for reasons wholly inside itself, and until now
  nothing said so. An empty `actions` or `resources` list matches nothing. An
  empty `any` group is false for every request - while an empty `all`, an empty
  `none` and `{}` are all true, so the one spelling that reads like "no
  conditions" is the one that retires the rule. `in` against an empty list can
  hold for no value. And a `none` group holding an item true for every request is
  false for every request.

  In each case a deny written that way never fires and the policy around it looks
  healthy. The engine now reports each unreachable rule once through
  `hooks.onPolicyError`, or one `console.warn` when no hook is installed. No
  verdict changes.

  Only falsity that reaches the rule is reported: the walk descends `all` chains
  and stops under `any`, where a false item is a dead disjunct, and under `none`,
  where a false item helps the rule apply. `subset_of` and `superset_of` against
  an empty list are left alone because an empty array field satisfies them - a
  fact about the request, not the policy.

  `validatePolicy` already rejected the empty lists, but nothing on the load path
  calls it, so a seeded row or a migration carried them straight past; the two
  condition shapes were unguarded even at write time. Relatedly, a rule with an
  empty list inside a policy that has `targets` was previously reported as one
  the policy's targets exclude. That was a misdiagnosis - the rule is dead
  whatever the targets say - and it now reports the rule's own list instead.

  ### A deny written against `scope` does not follow the hierarchy its grant does

  Reported and documented, not changed — the behaviour is each operator doing
  exactly what it says.

  In `'hierarchical'` mode a grant at `org-1` reaches `org-1.team-a`, because
  `rolesToPolicy` emits `any: [eq 'org-1', starts_with 'org-1.']` for it. A rule
  an operator writes gets no such help: `scope` is an ordinary field and there is
  no `scope_under` operator, so each obvious spelling of "this scope and below" is
  wrong in a different direction, and two fail *open*.

  Measured with an allow and a deny in one policy, subject granted at `org-1`:
  `eq 'org-1'` denies the parent and **allows** `org-1.team-a`; `starts_with
  'org-1.'` denies the child and **allows** the parent itself; bare `starts_with
  'org-1'` denies both but also reaches `org-10`, a different organisation. Only
  the two-arm form is right, and it is the one the package already generates
  internally.

  `core-rbac.md` now states this where the generated condition is shown, with the
  table of all four spellings, and `scope-deny-follows-no-hierarchy.test.ts` pins
  them in both modes so it cannot drift. Whether to add a `scope_under` operator
  is a public-surface decision and is on the open-findings ledger.

  ### A recreated role id handed its permissions to everyone who inherited the old one

  `deleteRole` removes the role and, on every adapter, the grants that named it —
  with the reason written into the compliance suite: *"an orphan grant would be
  held again if a role were recreated under the same id."* An `inherits` edge is
  the other place a role id is written down, and nothing swept it. `iam_roles`
  stores `inherits` as a JSON array, so the SQL foreign key that cascades the
  grants cannot reach it either.

  Measured on the memory and file stores: `u1` holds `reader` and `staff`, and
  `staff` has `inherits: ['ghost']` where `ghost` grants `write:post`.
  `deleteRole('ghost')` correctly takes the write away, but `staff.inherits` still
  reads `['ghost']`. An operator then recreates `ghost` for something unrelated,
  granting `delete:post` — and `u1` can delete, a permission nobody granted them.
  `resolveEffectiveRoles` walks the surviving edge into the new role.

  The interim protection was `resolveEffectiveRoles` dropping an inherited id no
  role defines, which stops the *phantom-allow* half. It reads in one direction
  only: a role id in `subject.roles` is also a selector — `policy.targets.roles`
  matches it — so cutting the id retires the deny that was targeting it. Measured
  on the same store: a subject reaching `banned` through `staff inherits banned`
  is denied while `banned` is defined and **allowed** once its row is not. Both
  directions escalate, so the state must not arise in the first place.

  `deleteRole` now strips the edges as well as the grants, on all six adapters
  (the HTTP adapter's reference server included), and the compliance suite pins
  both the edge sweep and the end-to-end escalation, so a new adapter cannot miss
  it.

  ### The one write path the scope guard did not cover

  `iamAssertAssignableScope` is the adapter-boundary guard that refuses two scope
  values: `''`, because the redis adapter spells "no scope" as `''` and would
  store it as a global grant, and `'*'` on a grant, because a scoped assignment is
  matched *literally* — a `'*'` row answers only a request whose own scope is the
  string `"*"`.

  `assignRole` and `revokeRole` called it. So did drizzle's `assignRoleMany` and
  `revokeRoleMany`. `updateAssignmentScope` — the third method that takes a scope
  to write, and the only one that takes two — called it on neither end, on all
  four adapters that implement it. Its own doc comment listed the two methods it
  covered, which is why it read as complete.

  Measured on memory, file, prisma and drizzle:
  `updateAssignmentScope('u1', 'editor', 'org-1', '')` and the same with `'*'`
  both returned `true` and left the subject holding `editor` under a scope no
  request can name. The operator is told the grant moved; it is dead.

  Both ends are now guarded — the `from` as a lookup, so a legacy `'*'` row can
  still be moved off, the `to` as a grant — and two compliance clauses hold every
  adapter to it.

  ### The subject entry outlived the role snapshot it was resolved against

  `IamLRUCache.set` takes a `notAfter` so a derived entry expires with its input,
  and three call sites use it: the RBAC policy expires with the role snapshot, the
  merged policies with the older of their two inputs, and the compiled table
  stamps itself with the read time of its oldest input rather than `Date.now()`.

  The subject entry is the fourth derived value and was not capped that way. It
  holds `resolveEffectiveRoles(assigned, snapshot)` and, for scoped grants, roles
  resolved through `inherits` against the same snapshot, so both halves are only
  as fresh as the role graph they were resolved against. The one cap it did take,
  the optional `getSubjectGrantBoundary`, describes the subject's *assignment*
  rows and says nothing about the role graph.

  Because each subject is written at its own moment, one resolved late in the
  snapshot's life bought a full fresh TTL past it. Measured at the default
  `cacheTTL: 60`, with the `staff -> admin` inherits edge removed at the store by
  another writer: a subject resolved at t=50s still held `delete:post` at t=61s,
  after the engine had re-read the role graph without that edge and was already
  answering deny for every subject resolved since; it converged only at t=110s.
  The exposure is up to one whole `cacheTTL` beyond the point the engine itself
  knows the role changed, and it needs no adapter feature to reach - any engine
  whose roles are written by something other than its own `admin` facet is in it.

  The entry is now capped at `min(grantBoundary, roleCache.expiresAt('all'))`,
  which costs nothing when the snapshot is fresh because the two are then the same
  instant.

  ### A policy targeting a role nothing defines was skipped in silence

  `policyApplies` matches `targets.roles` by equality against the request's
  effective roles and against nothing else; there is no catalogue lookup. An entry
  naming a role no stored row defines therefore matches no subject, and the whole
  policy is skipped on every request. A typo is enough.

  For a deny policy that is a deny that never fires, which is the one outcome this
  engine otherwise refuses to be quiet about - a policy that cannot be read throws
  rather than being dropped, a condition that cannot be answered is Indeterminate
  rather than `false`, a policy that throws still votes deny. Measured with every
  diagnostic hook attached: a `deny delete:post` policy targeted at `contarctor`
  instead of `contractor` let the subject delete, in both modes, with
  `onPolicyError` and `onError` silent.

  The second way in is `deleteRole`. It sweeps the grants and the `inherits` edges
  that named the role, but it cannot sweep this carrier, and the asymmetry is not
  an oversight: `targets.roles: []` means *unconstrained*, so stripping the last
  entry would widen the policy from one role to every subject rather than narrow
  it to none. There is nothing safe to rewrite and nothing to decide at evaluation
  time, since the state is indistinguishable from a policy written before its
  role.

  So the engine reports it instead: one `onPolicyError` per `(policyId, roleId)`
  for the engine's lifetime, or one `console.warn` when no hook is installed. Both
  evaluator paths carry the check - the compiled-table build and the interpreter's
  `loadAllPolicies` - because either can be the only one that runs, and the
  de-duplication is what keeps development mode, where both run, from reporting
  twice. It costs no extra adapter read and changes no verdict.

## 5.9.0

### Minor Changes

* 8a2f514: Close three fail-open gaps and finish the invalidator log redaction.

  **`RuleBuilder.build()` refuses a builder that was never configured.** The
  defaults are the broadest possible grant — `allow` on `['*'] x ['*']` with
  `{all:[]}` conditions, which evaluates true — so `.rule('x', (r) => { ...forgot... })`
  silently pushed an unconditional allow-everything rule into the policy.
  `PolicyBuilder.rule()` had claimed for some time that `build()` already refused
  this; it did not. The validator does detect the shape (`BROAD_ALLOW`) but emits
  it as `type:'warning'`, and `build()` keeps only `type:'error'`, so the verdict
  was computed and discarded with no console output and no return channel.

  The refusal is deliberately narrower than promoting `BROAD_ALLOW` to an error:
  it targets *silence*, not breadth. A deliberate broad grant still builds — say
  `.allow()` — and `validatePolicy` still accepts stored policies carrying one, so
  existing policy data is unaffected. `desc()`, `priority()` and `meta()` annotate
  a rule rather than shaping it and do not count as configuring it; `forScope('*')`
  does count, because it is an explicit statement about scope even though it
  narrows nothing.

  **BREAKING:** `defineRule(id).build()` and `new RuleBuilder(id).build()` now
  throw unless at least one of `allow`, `deny`, `on`, `of`, `forScope`, `when` or
  `whenAny` was called.

  **The `allowFailOpen` gate now covers every public evaluator entry point.**
  `evaluate.public.ts` gated `iamEvaluate` / `iamEvaluateFast` and asserted its
  wrappers were "the only route to the evaluator from outside the package". They
  were not: the barrel re-exported the raw single-policy `evaluatePolicy` /
  `evaluatePolicyFast` straight from `./evaluate`, and both reached the package
  root, so `iamEvaluatePolicy(policy, req, 'allow')` answered `allowed: true` with
  no opt-in and no warning. All four are now gated and pinned together by one test,
  because a half-gated boundary is what made the original claim false.

  **BREAKING:** `iamEvaluatePolicy` / `iamEvaluatePolicyFast` throw on
  `defaultEffect: 'allow'` unless the new trailing `allowFailOpen` argument is
  `true`, matching `iamEvaluate` and `IamEngine`.

  **`explain()` and `can()` agree on condition groups again.** `evalConditionGroup`
  distinguishes `{}` ("no conditions", unconditionally true) from a group carrying
  unrecognised keys (false, so a typo cannot turn a conditional allow into an
  unconditional one). `traceGroup`'s fallback collapsed both to `false`, so
  `explain()` reported a denial for a rule `can()` allowed. The fallback now
  delegates to `evalConditionGroup` rather than reimplementing it — this is the
  third time a hand-copy of the decision logic has drifted from it.

  **The Redis invalidator no longer logs raw tenant-bearing channels.** The earlier
  redaction covered the two drop-warning sites only; `reportSubscribeFailure` and
  the unsubscribe rejection handler still logged `JSON.stringify(channel)`, so a
  NOAUTH, a wrong ACL or a reconnect window wrote the tenant id to stderr. Reaching
  it needs no PUBLISH rights at all — a broker hiccup is enough.

  **The untouched-builder refusal now gates on what the rule became, not on which
  methods were called.** Three shapes still built the exact allow-everything rule
  it was added to refuse. The one that matters is `.forScope(...tenantIds)` with a
  runtime-empty array: `forScope` set the flag before discovering it had no scope
  to apply, so a rule meant to be tenant-restricted built global and passed the
  guard. `forScope` now throws when called with no scopes at all - a restriction
  that names nothing can only be a call-site bug, and `'*'` is how "every scope"
  is said out loud. `.when(w => w)` was the same shape: an empty `all` group is
  `.every` over nothing, which is `true`, so it matches every request; an empty
  callback no longer counts as configuring the grant. `.whenAny(w => w)` is left
  alone deliberately - `.some` over nothing is `false`, so an empty `any` group
  matches nothing and fails closed.

  **BREAKING:** `.forScope()` with no arguments throws.

  **A `$`-sourced `matches` operand is refused at validate time.** `evalCondition`
  returns `false` for one without looking at the request - correctly, since a
  caller-supplied pattern is a ReDoS vector - which makes the condition false for
  every request that will ever arrive. `validate.libs.ts` skipped these with the
  comment "Non-string / $-resolved values are caught elsewhere"; nothing caught
  them. A `deny`-when-`matches` rule written against a request attribute validated
  clean, stored clean, and never fired. New code `ERR_REGEX_USER_SOURCED`, an
  error, matching the two sibling pattern checks beside it. The three literal
  cases (uncompilable, over-long, catastrophic) were already errors and are
  unaffected.

  **`explain()` no longer throws where `can()` denies.** `evaluate` absorbs a rule
  that throws as Indeterminate - the policy votes deny if it carries any deny
  rule, else casts `defaultEffect` - while `explainEvaluation` had no `try` at
  all. On an unknown operator, `conditions: {all: null}` or a non-array group it
  raised out of the caller: a diagnostic failing on precisely the input it exists
  to explain, which is also the shape a hand-edited store row produces. The
  failure is now recorded on the rule trace as `conditionError` and the policy
  casts the same Indeterminate vote, reusing decision's own `policyHasDenyRule`
  rather than reading the rule list a second time. This is the fourth drift
  between these two paths and the third caused by a hand-copy.

  `Explain.IRuleTrace` gains an optional `conditionError`.

  **A dangling inherited role id no longer becomes a phantom role.**
  `resolveEffectiveRoles` added a role id to the effective set *before* looking it
  up, so an id reached through `inherits` that no role defines landed in
  `subject.roles` carrying no permissions - and a hand-written ABAC rule testing
  `subject.roles contains 'ghost'` fired on it. The route in is ordinary: delete a
  role while another still names it in `inherits`. `deleteRole` cascades the
  *assignments* on every adapter, so the direct grant goes; the inherited id did
  not. `validateRoles` already calls this state `DANGLING_INHERIT` with
  `type: 'error'`, so dropping it is not a new opinion about the data. Directly
  assigned ids are kept whether or not the catalog defines them - that is a row an
  operator wrote, not one derived from one.

  **BREAKING:** `getEffectiveRoles` and `subject.roles` no longer contain
  inherited role IDs that no stored role defines.

  **Prisma translates `P2003` into the shared unknown-role refusal.** An unknown
  role produced ``Foreign key constraint failed on the field: `roleId` `` off the
  driver - naming a column of a schema the operator may not have written, in a
  string nothing can branch on - while four adapters raised one shared sentence.
  The driver error is preserved as `cause`. The compliance suite gained the clause
  that would have caught it, plus one requiring the refusal not to echo the
  caller-supplied role id back into an operator log.

  **BREAKING:** `runAdapterCompliance` takes a third `IComplianceOptions`
  parameter. `delegatesRoleExistence: true` waives the *wording* of the
  unknown-role refusal (not the refusal) for an adapter that delegates role
  existence to a remote server - the HTTP adapter.

  **Nest denies a handler whose `@IamAuthorize` metadata is unreadable.** The
  guard read the metadata and treated anything falsy as "no decorator present",
  so a handler decorated with metadata that did not survive serialisation - or
  that arrived as `null`, `0` or `''` - was allowed through with no authorization
  call at all. Presence and readability are now separate questions: no decorator
  still allows, unreadable metadata routes to `onError` and denies.

  **`iamOptionalStringField` refuses an explicit `null`.** It treated `null` as
  "absent", so `{"scope": null}` on an admin route meant *unscoped* - a global
  grant - where the caller had written something that reads like "no value". Omit
  the field to mean unset.

  **BREAKING:** an admin request body carrying an explicit `null` for an optional
  string field is now a 400 instead of being silently read as absent.
  `IamValidationError` gained `statusCode = 400`, which is the property Nest's
  base exception filter duck-types.

  **The admin audit trail records a refusal as a refusal.** `iamWithAdminAudit`
  set `success: true` whenever the handler returned normally, and a handler that
  returns a 400/403 response object returns normally - so a rejected mutation was
  written to the audit log as a successful one. `savePolicy` / `saveRole` also
  record the target id now, via the new `iamAuditIdOf`.

  **The Redis invalidator reports dropped inbound messages.** A `secret` present
  on some nodes and not others makes every peer invalidation drop in both
  directions for the whole rollout; caches never converge and the failure is stale
  *allow*. The only report was a `console.warn` coalesced to one line per minute.
  New `onMessageDropped(reason, channel, suppressed)` hook. Separately, the
  coalescing latch was keyed on the channel alone, so attacker-driven inbound
  drops and publish failures shared one 60s budget - one junk message a minute
  suppressed the publish-failure warning for a concurrent broker outage. The two
  kinds now have separate keys.

  **Derived caches inherit their inputs' expiry.** `IamLRUCache` gained
  `expiresAt(key)`, and the RBAC-policy, merged-policy and compiled-table caches
  now cap their own entries at the oldest input's expiry rather than restarting
  the TTL clock. A policy-only invalidation used to re-age the role snapshot,
  which was measured serving a stale allow for 118s under a 60s TTL.

  **`IamSubjectsPanel` carries the devtools guard itself.** `package.json` exports
  every panel individually under `./dt`, and the subjects panel - the only one
  that writes, calling `assignRole` / `revokeRole` / `setAttributes` - had no
  `isDevtoolsAllowed` anywhere in its path when imported directly.

  **The admin read routes run the same authorization phase as the writes.**
  Express, hono and next gated `GET /policies` and `GET /roles` on `authorize`
  alone while nest ran the CSRF check too, so an operator whose `csrfCheck`
  carried any part of an authorization decision had it enforced on reads on one
  of four adapters, undocumented. All four now run `iamRunAdminAuthz`. This is
  not a new refusal for API clients: `iamDefaultCsrfCheck` returns `true` when
  there is no `Sec-Fetch-Site` header at all, which is every non-browser caller.

  **Nest answers a failed admin mutation the way the other three do.** It threw
  the handler's own error untouched, so an internal message reached the client -
  in one of this package's own tests, a fake SQL string naming a password column.
  Express, hono and next all answer a fixed `Internal server error`. Nest now
  throws that, with the original attached as `cause` so a logger loses nothing,
  and `includeErrorMessage` governs the audit string only, as it does elsewhere.
  `IamNest.IAdminOptions` gained `onUnauthorized`, `onForbidden` and `onError`,
  which the other three adapters already had.

  **BREAKING:** a Nest admin handler that throws now surfaces
  `Internal server error` (500) rather than the original error. Read `cause` for
  the original. A validation failure surfaces as 400, as on the other adapters.

  **One malformed admin request now gets one answer on all four adapters.**
  `POST /subjects/:id/roles` with `{"roleId": ""}` was a 400 on hono, which
  hand-rolled its checks inline, and a 500 on express, next and nest, whose shared
  validators threw a bare `Error` that every generic `catch` routes to `onError` -
  paging an operator for a client's typo and telling the client to retry something
  that will never succeed. `{"roleId": "   "}` was a *write* on all four: the
  checks read `length === 0`, so a blank id became a real grant on a role that
  renders as nothing at all in an admin UI, while `iamIsNameableActor` next to
  them had required `.trim().length > 0` all along. And hono capped ids at 128
  characters where the other three applied no cap and let the engine's 1024
  decide.

  `iamRequireStringField`, `iamOptionalStringField` and `iamRequirePathParam` now
  refuse a blank value and cap at `IAM_MAX_ADMIN_FIELD_LENGTH` (1024, the engine's
  own cap, so the edge refuses exactly what the engine would), and they throw
  `IamValidationError` so all four adapters answer 400 through the branch they
  already had. Hono uses them instead of its inline copy.

  **BREAKING:** `IamValidationError.kind` gained `'request'`, alongside `'policy'`
  and `'role'`. A whitespace-only `roleId`, `scope` or `:id` is now a 400 instead
  of a grant, and a bad admin field is a 400 instead of a 500 on express, next and
  nest.

  **A body that is not JSON is a 400 on hono and next.** Both call `req.json()`
  inside the audited handler, so a truncated upload - or a form post carrying a
  JSON content-type - raised a `SyntaxError` out of the handler into the generic
  `catch`, which answered 500 and handed the parse error to `onError` as though
  the package had broken. Express and nest never saw it, because their hosts parse
  the body first. New `iamReadJsonBody` wraps the framework's own parser; the
  refusal deliberately does not repeat the parser's message, which quotes the
  offending bytes into an operator's log.

  **Test-suite gaps closed alongside the fixes above** (no runtime behaviour
  change): the e2e tier now runs in CI - nineteen files and roughly seven hundred
  tests previously executed in no workflow at all, because `bun run test` excludes
  `**/*.e2e.test.ts` and nothing invoked `test:e2e`. The HTTP e2e sweep gained a
  sixth listener that opts into proxy trust, because every `environment.ip`
  assertion in that file compared five integrations that all report `undefined`,
  and deleting the IP derivation outright left the section green. The two verdict
  suites now name the verdict they expect rather than only requiring the two
  engines to match. And the fast/slow evaluator delegation for throwable policies
  is pinned directly: deleting it leaves the 6000-iteration property oracle green,
  because on any iteration with a throwable policy the "fast path" *is* the
  interpreter.

  **And the tier that CI now runs was quietly smaller than it looked.** Five e2e
  files decided whether to run by shelling out to `docker info` with a
  five-second budget of their own, rather than using the shared thirty-second
  probe. On a machine already running the e2e stack the daemon takes longer than
  that, so the copies answered "docker is down": the redis adapter suite dropped
  twenty-four cases and the invalidation failure-modes suite dropped ten, in a
  run that reported `609 passed | 26 skipped` and exit 0. Their own reachability
  guards read the same wrong answer and agreed the skip was expected. All five
  now call the shared probe, which is memoised so the gate and the guard cannot
  reach different verdicts - and the guards no longer consult it at all under
  `CI`, where the workflow provisions the backends before vitest starts and
  "absent" is a broken runner rather than a reason to be quiet. The two
  invalidation suites had no guard at all; the comment above one of them said
  "fail loudly, not silently" next to a gate that could only do the opposite.
  With the probe fixed the same command collects **706** tests and skips none.

  Three harness bugs surfaced by that: `assertE2eReachable` awaited a
  thirty-second probe inside a case with vitest's five-second default, so it
  failed as a timeout on exactly the busy daemon it was written for
  (`scope-prod-parity` red on that line with its eleven cases passing beside it);
  `whilePaused` returned as soon as `docker unpause` did, without waiting for
  Postgres to serve again the way `whileStopped` already did, so the recovery
  case read a fail-closed deny as a failure to recover; and the shared compliance
  matrix registers ~100 cases with no explicit timeout, which is right against
  the in-memory fake and wrong against a container sharing the machine with every
  other suite - one round trip took 5.88s and was reported as a failure.

  **A denylist with an operand typo admitted everyone, and validation was not in
  front of it.** `nin` returns `true` when its operand is not an array, so
  `allow if tier nin 'banned'` - the author meant `['banned']` - satisfies the
  guard for every subject it was written to exclude. The operand-type matrix in
  the validator refuses that, and the package's own test said the verdict was
  "unreachable only because the validator refuses the operand first". It is not:
  `validatePolicy` runs on `admin.savePolicy` and `admin.import`, while
  `loadPolicies` does not validate at all. Measured end to end, with no database
  and nothing hand-edited - a policy seeded through the documented
  `IamMemoryAdapter` constructor let a banned subject through `engine.can`. The
  same door is an operator's `INSERT`, a seed script, a migration, or a row
  written by a version that predates the rule.

  The evaluator now applies the matrix itself and throws `IamOperandTypeError`
  rather than answering, which `evaluate` absorbs the way it already absorbs an
  unknown operator - Indeterminate, reported through `onPolicyError`, never
  `false`, because answering `false` retires a deny rule just as quietly. The
  table moved to `conditions/conditions.libs` beside the operators it describes
  and the validator imports it, so the write-time check and the read-time check
  cannot drift. Because `condVal` is the *resolved* operand, this also covers
  what the validator cannot see: a `$`-prefixed reference is skipped there, since
  its type is unknowable at authoring time, and lands here as whatever the
  request actually carried. **BREAKING** for anyone whose store holds a policy
  the validator would have refused: that policy now denies, loudly, instead of
  granting.

  **The devtools production guard stood in front of one panel out of five.**
  `isDevtoolsAllowed` is default-block and well argued - it blocks unless both
  `NODE_ENV` and the engine's own mode give a positive development signal - and
  its docblock says it exists so "the policy/role/subject **readers** cannot leak
  into raw-browser bundles (CWE-200 / CWE-489)". Two of those three readers never
  called it. `package.json` publishes `./dt` and `src/dt/index.ts` exports every
  panel individually, so importing one directly is a supported thing to
  do, and through that route `IamPoliciesPanel` renders the entire policy corpus
  from `engine.admin.listPolicies()`, `IamRolesPanel` the whole role catalog, and
  `IamDecisionInspector` answers `engine.explain()` for any subject, action and
  resource typed into it - each blocked through `IamDevtools` and wide open
  through its own export. `IamSubjectsPanel` had already been given the guard for
  exactly this reason; the reasoning was never carried to its siblings.

  All four panels that are handed an engine now call the guard themselves, as
  `IamSubjectsPanel` does. `IamFlowPanel` and `IamTraceTree` are untouched: they
  take a recorder and a result and never reach an engine. The "only panel that
  writes" framing is also retired - `IamMetricsPanel`'s Reset button calls
  `engine.stats.reset()`. Guarding is now enforced by a source sweep as well as
  by render tests, so a panel added later that reaches for the engine fails until
  it carries the guard.

  **The devtools could not render outside this monorepo at all.** `./dt` is a
  published export, and `@gentleduck/registry-ui` and `@gentleduck/libs` were
  *optional* peer dependencies - but six devtools modules imported them
  unconditionally, so `import '@gentleduck/iam/dt'` threw `ERR_MODULE_NOT_FOUND`
  for any consumer who had installed the package and not those two. The modules
  that did resolve still rendered wrong: their Tailwind utility classes only name
  real CSS if the consumer's Tailwind is configured to scan this package's
  `dist`, and the `iam-dt-*` rules read `var(--card)`, `var(--border)` and
  `var(--foreground)` out of the host theme with no fallback. None of this is
  visible from inside the monorepo, where both peers are installed and Tailwind
  does scan the source, which is why it survived.

  The devtools now own their appearance outright. `lib/styles.ts` carries the
  whole visual layer - every colour an `--iam-dt-*` token it defines itself,
  every component a class it ships - injected as a single `<style>` tag, with a
  scoped reset so a host's `button {}` cannot reach inside. There are two
  themes: `data-iam-dt-theme` pins one and the new `theme` prop sets it,
  defaulting to `'auto'`, which follows `prefers-color-scheme`. Tokens are
  declared only on the outermost `.iam-dt`, because each panel carries the class
  (each is exported individually and may be its own root) and a nested block
  would re-derive the theme and overrule an explicit one. The action, resource,
  allow and deny colours were hardcoded hex tuned against a dark ground and sat
  at roughly 2:1 on a white one; both palettes now come from a set with worked-out
  contrast in each direction. `@gentleduck/registry-ui` and `@gentleduck/libs`
  are gone from `peerDependencies`, `peerDependenciesMeta` and `devDependencies`,
  along with a `@gentleduck/variants` entry that was listed as an optional peer
  without ever being declared or imported.

  Two things that were broken rather than merely coupled: a panel imported
  directly injected no stylesheet at all, since only the two shells called
  `ensureStylesInjected` - every panel now does, through `useIamDevtoolsStyles`.
  And the accessibility of the overlay: the tab strip is a real `tablist` with
  arrow-key navigation (previously six buttons, five of them unreachable from the
  keyboard once the strip claimed the role), Escape closes the panel and returns
  focus to the launcher, opening moves focus into it, the resize edge is a
  focusable `separator` that responds to arrow keys, Home and End, `Section` and
  the JSON tree report `aria-expanded`/`aria-controls`, `Field` associates its
  label with its control, filter pills report `aria-pressed`, alerts announce,
  icons are hidden from assistive technology, and every animation stops under
  `prefers-reduced-motion`.

  Regression tests in `dt/__tests__/dt-selfcontained.test.tsx`: a source sweep
  that fails on any import of the two optional peers, a sweep of the stylesheet
  for any `var()` it does not define itself, a check that the token blocks stay
  root-scoped, a cross-check that every `iam-dt-*` class the components emit has
  a rule in the sheet that ships with them, and a render of each panel on its own
  asserting it is its own themed root. Each has a positive control, because an
  empty sweep satisfies every one of them.

* 5f38486: Add a second devtools build, `@gentleduck/iam/dt/v2`, written on duck-ui.

  `./dt` and `./dt/v2` are two implementations of the same six panels with
  deliberately opposite dependency contracts, published side by side so a
  consumer picks the trade rather than inheriting it:

  |            | `./dt`                                                  | `./dt/v2`                                                                    |
  | ---------- | ------------------------------------------------------- | ---------------------------------------------------------------------------- |
  | Styling    | own stylesheet, `iam-dt-*` classes, `--iam-dt-*` tokens | duck-ui components on the host's Tailwind theme                              |
  | Peers      | none                                                    | `@gentleduck/registry-ui`, `@gentleduck/libs`, `lucide-react` (all optional) |
  | Looks like | itself, everywhere                                      | whatever the host app looks like                                             |

  `./dt` is unchanged and stays self-contained — that is the point of keeping it.
  Reach for `./dt/v2` when the host already runs duck-ui and Tailwind v4 and you
  want the devtool to inherit its theme; it needs one line in the host stylesheet
  so Tailwind scans the shipped panels:

  ```css
  @source "../node_modules/@gentleduck/iam/dist/dt/v2";
  ```

  v2 is a rebuild, not a re-skin. It is assembled from duck-ui's own components
  rather than lookalikes — `Alert`, `Avatar`, `Badge`, `Button`, `ButtonGroup`,
  `Card`, `Empty`, `Field`, `Input`, `InputGroup`, `Item`, `Kbd`, `Label`,
  `Progress`, `ScrollArea`, `Separator`, `Skeleton`, `Switch`, `Table`, `Tabs`,
  `Textarea` and `Tooltip` — because a `div` with a border is a card only the
  devtool knows about, while a `Card` is one the host's theme can reach. The Flow
  log is a real data table, cache and allow rates are real `Progress` bars, the
  verdict filters are real `Switch`es, and every toolbar button carries a
  `Tooltip`. A contract test names that component set and fails if v2 stops using
  one of them or hand-rolls a `<table>` or progress bar instead.

  What is deliberately not duck-ui: a new tone system (`lib/tone.ts`) keeps the
  five decision colours on a fixed palette while the chrome floats with the host;
  tabs get arrow-key roving focus, because `TabsTrigger` does roving `tabIndex`
  and no key handling; sections are hand-rolled disclosures inside a `Card`,
  because `Collapsible` drives its open state through a DOM attribute and renders
  closed on the server; and the dockable shell's resize edge is a real
  window-splitter separator — pointer drag plus arrow/Home/End,
  `aria-valuemin`/`max`/`now`, Escape to close and focus returned to the
  launcher — which `Separator` (an `<hr>`) cannot be.

  Every v2 panel calls `isDevtoolsAllowed(engine)` itself, because each is
  exported individually; a source sweep in the v2 tests fails any future panel
  that reads `engine.` without the guard. A contract test asserts the split from
  both sides: v2 really imports duck-ui, is really outside `./dt`'s
  self-containment sweep, and carries none of v1's styling layer.

* 555fa09: Refuse `'*'` as the scope of a role *assignment*.

  `'*'` is this package's spelling of "every scope" on the scope a role or a
  permission **declares** — `IPermission.scope` is typed `TScope | '*'`, and
  `matchesScope`, `scopeCovers` and `effectiveScopeOf` all read it as global. It
  means nothing of the kind on a scoped **assignment**: `enrichSubjectWithScopedRoles`
  compares the stored scope literally, so `assignRole(u, r, '*')` stored a row that
  matched no request, while the write resolved and `admin.assignRoles` reported
  `applied: 1`. `getEffectiveRoles` returned `[]`.

  That is the same silent success `iamAssertNoAssignOptions` refuses for a dropped
  `expiresAt` and `iamAssertRoleExists` refuses for an unknown role id, and
  `assignRole`'s own contract already says a grant `resolveSubject` will drop must
  throw rather than read back as success. `undefined` is how a global assignment is
  spelled.

  Lookups stay open: `revokeRole`, `revokeRoles`, the `fromScope` end of
  `updateAssignmentScope` and redis's member encoder all accept `'*'`, so rows
  written before this change can still be revoked or moved off. They were dead
  before it and are dead after it; nothing that worked stops working.

  The check also runs in `admin`'s pre-pass, not only in the adapters. The adapter
  guard fires inside `assignRoles`' write loop, which would leave the batch
  half-applied — the failure the pre-pass is documented to prevent.

* 70a90b4: Make `IamMemoryAdapter`'s constructor agree with its own write methods.

  A seeded assignment naming a role the init does not declare is now refused, with
  the same message `assignRole` throws. It was accepted, `getSubjectRoles`
  returned the id, and a hand-written ABAC rule keyed on `subject.roles` fired on
  it — an allow from a role that does not exist. `resolveEffectiveRoles` drops a
  dangling `inherits` id, but a directly assigned role is not that case.

  `getSubjectAttributes` returns a copy rather than the live internal bag, and the
  attributes seed copies in. Editing what a read handed back used to rewrite the
  store, with no validation and nothing to invalidate a cache from. The other five
  adapters already rebuild the bag through `iamNarrowAttributes`.

  This adapter is documented "tests + prototypes only", so neither is a production
  grant path. Seeds that declare the roles they assign are unaffected.

  Report the adapter-compliance suite's optional-method skips honestly. Nineteen
  tests in `runAdapterCompliance` bowed out with a bare `return` when the adapter
  under test does not implement an optional method, reporting as passed; across
  the tier that was 59 green ticks asserting nothing. They now report as skipped,
  and a new support matrix asserts which adapters implement which optional
  methods, so dropping one turns a test red instead of turning five silent.

* ebc38e8: Close the two process-global latches, and give React's `usePermissions` the
  `refetch` Vue's has always had.

  **React's `usePermissions` could not reach its own stale-map reset.** The effect
  deliberately empties `permissions` and clears `error` before each load, for the
  sign-out and account-switch cases where the previous subject's grants must not
  stay on screen. The only trigger was a change to `deps` — which defaults to `[]`
  and was undocumented — so `usePermissions(() => fetchFor(user.id))`, the obvious
  call, loaded once and then answered `can()` from the first subject's map
  indefinitely. Vue's docblock claimed the two hooks were "the same shape"; they
  were not. React's now returns `refetch`, and carries the monotonic run id Vue
  uses, which is required once `refetch` exists: two loads can be in flight with no
  deps change and therefore no effect teardown between them, and a slow earlier one
  would otherwise land on top of a fast later one. A rejection is normalised to an
  `Error` rather than declared to be one.

  **`IamAccessClient` guarded its map on the way out and not on the way in.** The
  `permissions` getter has always returned a copy, because `Readonly<…>` erases at
  runtime and an in-place edit would otherwise grant a permission without going
  through `update()`/`merge()` — and so without notifying a subscriber. The
  constructor and `update()` stored the caller's reference, which is the identical
  hazard unguarded. Both copy now; listeners still receive the caller's own object.

  **The unsigned-invalidator warning is latched per channel, not per process.**
  `tenantId` exists so one process builds one invalidator per tenant, and a single
  process-wide boolean meant the second and every later unsigned invalidator
  constructed in silence — while the one warning that did fire named no channel, so
  an operator who fixed "the" unsigned invalidator had no way to learn the other
  forty were still unsigned. The channel is now named in the message, with its
  tenant segment redacted the way every other line on that path is.

  **The broken-error-hook report is latched per hook, not per process.** One
  engine's transiently broken `onPolicyError` permanently silenced a different
  engine's — a different tenant's — hook failure for the life of the process, and a
  hook failure is how an operator learns that policy evaluation is throwing at all.
  `safeErrorReport` now takes the hook and its arguments instead of a
  `() => hook?.(…)` thunk, which gives the latch something stable to key on, keeps
  the `Error` normalisation in one place instead of six, and stops allocating an
  `Error` per throwing policy per request when no hook is wired at all.

* e15213c: Bind the `matches` operator to the operand-type guard it was exempt from.

  `evalCondition` throws `IamOperandTypeError` for an operand whose type the
  operator cannot compare against, so the condition reads as Indeterminate rather
  than as "not met" — `false` is the permissive answer for a deny rule, and
  `loadPolicies` does not validate, so a seeded or migrated row reaches the
  evaluator exactly as authored. `OPERAND_TYPES` is shared with `validate.libs`
  precisely so write-time and read-time cannot drift.

  For `matches` they had. The operator was dispatched before the guard ran, so a
  non-string or absent `value` fell into `evalMatchesOp`, failed that function's
  own `typeof v !== 'string'` test, and answered `false`. A seeded
  `deny`-when-`matches` rule therefore never denied, and under
  `defaultEffect: 'allow'` the request was allowed outright. The dispatch now sits
  below the guard. The refusals `matches` already had — a `$`-sourced pattern, an
  uncompilable one — are unchanged.

  Also: `IamLRUCache.entries()` used `>` where `get()` uses `>=`, so at an entry's
  own expiry millisecond the iterator yielded a value `get()` refuses. The one
  caller evicts rather than serves, so nothing was wrong yet; the signature
  promises non-expired entries and now keeps that promise.

  And `explain()` reported ALLOW on a request naming the reserved refusal token.
  `authorize()` and `permissions()` each refuse that token before consulting any
  policy - a `'*'` rule matches a sentinel string, so without the refusal the
  ordinary wildcard admin grant allows exactly the requests the adapters mint the
  token for. `explain()` ran the combine instead and reported what the policies
  said, telling an operator investigating a refusal that it was allowed.
  `explainEvaluation` now applies the same predicate, keeps the policy traces, and
  carries the same `failure: 'input'` tag.

## 5.8.1

### Minor Changes

* ff6f112: Close five ways the policy builder could emit something other than what the author wrote.

  **A condition callback that returns a group is no longer discarded.**
  `RuleBuilder.when()`, `RuleBuilder.whenAny()`, `RoleBuilder.grantWhen()` and
  `PolicyBuilder.rule()` used the builder they passed in and ignored the callback's
  return value. The reusable-group idiom returns one, so
  `.when(() => sharedOwnerOrAdmin())` authored to `{ all: [] }` - and `all` of
  nothing is true, making the rule fire unconditionally: an allow rule granted to
  everybody, a deny rule denied everybody. The returned builder is now honoured. A
  callback that both chains onto its argument *and* returns a different builder
  throws, because there is no answer to which group was meant.

  **Built condition groups no longer alias the builder's array.** `buildAll()`,
  `buildAny()` and `buildNone()` snapshot. Reusing a `When` after building used to
  reach back into rules that were already finished.

  **`When.roles()`, `When.scopes()` and `When.resourceType()` refuse zero
  arguments.** They emitted a membership test against an empty list, which can
  never match - on a deny rule that removes the guard. Pass at least one value, or
  `.in(field, list)` when the list is computed and may legitimately be empty.

  **The builders emit absent optional keys, not keys holding `undefined`.**
  `description`, `targets` and `version` on policies, `description`/`metadata` on
  rules, `description`/`inherits`/`scope`/`metadata` on roles. A key holding
  `undefined` survived in the memory, file and http stores and disappeared through
  every JSON- or `jsonb`-backed one, so the same authored policy read back unequal
  depending on where it had been. `version` in particular is now left out when
  unset: the `version: 1` default belongs to the store, and a builder that
  pre-empted it made "never set" indistinguishable from "set to 1".

  **Breaking:** `when()`'s type parameters were reordered to
  `when<TAction, TResource, TRole, TScope, TContext, TActiveResource>` so they name
  the slots they fill. They previously ran `TAction, TResource, TScope, TRole`,
  which let a scope be passed to `.role()` and a role to `.scope()`. Only callers
  who pass all four explicitly are affected; inference is unchanged.

* 3de4298: Make the HTTP boundary's refusals actually refuse.

  **A wildcard rule turned every unmappable request back into an allow.** The
  framework adapters map an HTTP request to an `(action, resource)` pair, and some
  requests cannot be mapped: an unmapped method, or a path this layer and the
  router downstream would read differently. Both were expressed by handing the
  engine a sentinel *string* - `IAM_UNKNOWN_ACTION`, `IAM_UNKNOWN_RESOURCE` -
  documented as matching no policy and therefore denying. `'*'` matches every
  string, sentinels included, so any deployment with a wildcard rule
  (`.on('*').of('*')`, the ordinary shape of an admin role) allowed them. The
  traversal guard that refuses to resolve `/posts/../admin/secret` handed the
  engine `type: 'unknown'`, an admin was allowed, and express, hono, next and nest
  then routed the raw target elsewhere - authorized as one resource, served as
  another.

  A string cannot carry a denial, so the denial moved into the engine: the token
  is reserved, and both `authorize()` and `permissions()` refuse it before
  consulting any policy. `permissions()` needed it separately because it does not
  route through `authorize()`. The refusal reports `failure: 'input'` and fires
  `onDeny` like any other denial. Reserving the token means a resource type or
  action genuinely named `'unknown'` can no longer be granted; both constants have
  always been documented as sentinels that deny.

  **A literal backslash was not treated as ambiguous, though `%5C` was.** The
  WHATWG URL parser rewrites `\` to `/` in a special-scheme URL before resolving
  dot segments, so `new URL('http://x/posts\..\admin').pathname` is `/admin`:
  `iamPathIsAmbiguous` read the type as `posts\..\admin` while anything parsing
  the target through `URL` read `/admin`. The encoded form was already refused;
  the plain character - the easier one to send - was not.

  **The default CSRF check allowed cross-site requests on a capital letter.**
  `iamDefaultCsrfCheck` read exactly one spelling of `Sec-Fetch-Site` out of a
  header Record. Node lowercases what it parses, but the predicate is exported and
  documented as taking any request-like object, and not finding the header is
  indistinguishable from "no header was sent" - which it treats as a non-browser
  caller and allows. The lookup is now case-insensitive across all three supported
  header shapes, and reads own properties only.

  **A throwing `csrfCheck` escaped the admin gate.** `iamRunAdminAuthz` caught a
  throwing `authorize` and reported `phase: 'error'`, but let a throwing
  `csrfCheck` propagate, so whether the request was refused depended on the
  framework adapter's outer catch. A predicate that cannot answer has not said
  yes: it is now `phase: 'forbidden'`, and `authorize` is not called.

  **The admin audit could not name who made a mutation.** The gate passed any
  truthy `actor` straight into the audit event, and the documented shape -
  `authorize: (req) => req.user?.role === 'admin'` - returns a boolean. So the
  mutation was authorized and the audit trail recorded `true` as the person who
  made it, which attributes it to nobody. A truthy answer still authorizes, as it
  always did; a value that names no one is now recorded as no one (`actor:
  undefined`), with a one-time notice explaining how to make mutations
  attributable. New `iamIsNameableActor` export.

  `iamDefaultCsrfCheck`'s five `as` casts were replaced with runtime type
  predicates while fixing it.

* 8c131a4: Grant writes now record who made them, `engine.admin` emits a typed mutation event for every write, and `mode` defaults to `'production'`.

  **Actor provenance.** `IAssignOptions` gains `actor?: string`, and `revokeRole` / `revokeRoles` gain a matching `IRevokeOptions`. `savePolicy`, `saveRole` and `setSubjectAttributes` take a new `IActorOptions`. Every table in the drizzle and Prisma schemas has declared `created_by` / `updated_by` for as long as it has existed and nothing ever filled them - eight columns per dialect promising a provenance the API could not produce. They are written now: `created_by` on the row a call inserts, `updated_by` on the row it overwrites, so an edit records its editor without rewriting the original author. Both are written by spread rather than as an explicit null, so a table that predates the columns is untouched unless the caller names an actor.

  `actor` is deliberately outside the `ASSIGN_OPTION_FIELDS` allow-list that `iamAssertNoAssignOptions` guards. Dropping `expiresAt` changes what the store answers; dropping `actor` does not, because the event carries it regardless.

  **Mutation events.** Wire `hooks.onMutation` to receive a closed discriminated union - `role.assigned`, `role.revoked`, `role.scope-changed`, `role.saved`, `role.deleted`, `policy.saved`, `policy.deleted`, `attributes.set` - each carrying `at`, the optional `actor`, and the subject/role/scope the write touched.

  The library owns the event and the actor and ships no history table: retention, redaction and GDPR erasure are compliance decisions the consuming application must own. There is no way to register your own events on this bus; the union stays closed so a `switch` on `type` stays exhaustive across versions.

  Two details worth knowing: `attributes.set` carries key names and never values, because attribute bags routinely hold personal data and the event is likely headed for a durable log. And under `withTransaction`, events buffer alongside invalidations and are emitted only on `flush()` - a rolled-back grant leaves no history.

  **Breaking:**

  * **`mode` now defaults to `'production'`.** A consumer who never set it was running a production authorization engine that allocated a rich `Decision` on every call. Three consequences: `can()` / `check()` return `boolean` rather than `IDecision` unless you opt in; `policyCombine: 'first-applicable'` now throws at construction, since the production fast path cannot represent it; and devtools, which refuses a production engine, is now off unless you set `mode: 'development'`.
  * **Explicit type arguments need a fifth.** TypeScript uses a type parameter's default rather than inferring it when the argument list is partial, so `new IamEngine<Action, Resource, Role, Scope>({ ..., mode: 'development' })` no longer typechecks. Write `new IamEngine<Action, Resource, Role, Scope, 'development'>(...)`, or drop the explicit arguments and let all five infer.
  * **`afterEvaluate` and `onDeny` now fire in production too.** They were documented and implemented as development-only, which put the audit and alerting hooks in the mode nobody runs in production. The production decision is verdict-only - `allowed`, `effect`, `duration`, `timestamp`, and a `reason` that says so - because the compiled table erases policy identity at compile time and that erasure is the optimisation. `policy` and `rule` are absent rather than fabricated. Nothing is allocated unless one of the two hooks is wired.
  * **`maxConcurrentSubjectLoads` defaults to `512`** instead of `0` (unbounded). Only distinct, never-before-cached subjects count toward it, so reaching 512 means a cold-start herd rather than normal traffic. Shedding denies legitimate requests, so the bound is deliberately generous; set `0` to restore unbounded explicitly.

  **Also:**

  * `IamEngine.setInvalidator(invalidator | null)` attaches, replaces or detaches an invalidator after construction, for callers whose Redis client is built after their engine. It unsubscribes the previous one first and validates the shape, so a malformed object fails loudly instead of surfacing later as invalidations that never arrive. `IConfig.invalidator` still works and now routes through it.
  * `iamScopeAncestors` and `iamScopeCovers` are exported. Callers doing scope-aware rank or reach calculations had to reimplement the walk, and any reimplementation drifts from the relation the engine matches with.
  * The drizzle adapter warns once per process, at construction, when `ops.isNull` or `ops.or` is missing. Both omissions are correct but silently slow - without `or`, `revokeRoleMany` degrades to one `DELETE` per row; without `isNull`, `updateAssignmentScope` falls back to revoke + assign, two writes with a window where the grant does not exist. Neither can be derived: `eq(col, null)` is not `IS NULL`, and there is no way to synthesise an `OR` builder from `eq` and `and`.

  **Production and development now agree on two cases where they did not.** Both were found by running the same catalog through a production engine and a development engine and comparing verdicts; both had production granting access development refused, on identical data, with no malformed catalog anywhere.

  * **A throwing role permission no longer voids unrelated grants.** `rolesToPolicy` folds every role permission into one generated `__rbac__` policy, and the interpreter caught a throwing condition at whole-policy scope - so one rotten permission denied a subject an unconditional grant held through a different role, while the compiled table answered allow from the grant mask before it ever reached the bad condition. A rule that throws inside that generated union now abstains and is reported through `onPolicyError`, matching the table. This is safe only there: `rolesToPolicy` emits `effect: 'allow'` and nothing else, so a skipped rule in an allow-only union can cost a subject a grant but can never suppress a denial. An authored policy still fails closed at whole-policy scope, because its vote may have been a deny.
  * **Role permission conditions get the same nesting budget in both modes.** `rolesToPolicy` nests a permission's condition group one level inside the generated rule, so the interpreter reached it at depth 1 while the compiled table, which stores the group raw, started it at 0. Authors got ten usable levels in production and nine in development: at exactly `MAX_CONDITION_DEPTH` the table allowed and the interpreter denied, while depths on either side agreed. `validateRole` already validated at depth 1, so the table was the outlier; it now starts there too, via the new exported `IAM_RBAC_CONDITION_DEPTH`.

  **Schema fixes.** The three drizzle schemas are three hand-written files describing one logical schema and nothing compared them, so they had drifted:

  * MySQL's `iam_roles.inherits` was `NOT NULL` with no default, while Postgres and SQLite default it to `[]`. The same insert succeeded on two dialects and failed on the third.
  * `ch_iam_roles_scope_not_blank` and `ch_iam_assignments_scope_not_blank` existed only on Postgres, so a whitespace-only scope was storable on MySQL and SQLite and rejected on Postgres.
  * MySQL lacked `idx_iam_assignments_subject_scope`, leaving the scoped-subject lookup - the hot read - to a subject scan plus a filter. It is added unfiltered there, MySQL having no partial indexes.

  A new `schema-parity.test.ts` compares columns, insert-required columns, indexes, CHECKs and foreign keys across all three dialects, with an explicit allow-list for the handful of entries a dialect genuinely owns alone (Postgres GIN indexes, SQLite's algorithm CHECK standing in for the others' enum type). It also pins `src/test/pg-e2e-schema.sql` - self-described as a hand-kept mirror, with nothing keeping it honest - against the Postgres schema module.

  The Prisma reference schema gains the provenance columns, `updated_at` on assignments, `created_at` on subject attributes, and the `role` / `(subject, scope)` indexes the drizzle schemas already had. Its header now spells out what Prisma cannot express - CHECK constraints, partial indexes - and, importantly, that `@@unique([subjectId, roleId, scope])` does not prevent a duplicate *unscoped* grant, because SQL unique indexes do not collapse NULLs. The adapter closes that hole with a read-then-write; the comment exists so nobody "simplifies" it back to a bare `create`.

  **Breaking: a role must exist before it can be granted.** `assignRole` (and `assignRoles`) now throws when no role is stored under the given id, on every adapter. This was already true on drizzle and Prisma, whose schemas carry the assignments-to-roles foreign key, and silently accepted on memory, file, redis and HTTP: the row landed, `getSubjectRoles` returned the id, and `resolveSubject` dropped it again because no definition resolves — so a typo'd role id reported success and granted nothing. Provisioning code that assigns before saving the role must now save first. The drizzle adapter also translates the driver's foreign-key error into that same refusal, keeping the original as `cause`; before, an operator saw `Failed query: <sql>` with the constraint reachable only on `.cause`.

  **Breaking: the unique indexes on policy and role `name` are gone.** `uq_iam_policies_name` and `uq_iam_roles_name_scope` existed only on the drizzle Postgres schema. Nothing in this package resolves a policy or role by name — `id` is the key everywhere — so they protected a field no code reads while making a write the adapter contract mandates impossible on Postgres alone. Existing databases keep them until you drop them; nothing in the library depends on either behaviour.

  **Breaking: `iamExtractEnvironment` no longer guesses the client IP.** It reported one from `req.ip`, `x-forwarded-for` or `x-real-ip`, which meant five HTTP integrations gave five different answers for the same request, and — with nothing in front of the app — let a client set `X-Forwarded-For` and satisfy an IP-conditioned policy. `environment.ip` is now `undefined` unless the app opts in: pass `{ trustProxy: true }` as the second argument, or supply the environment yourself through the integration's `getEnvironment`. Hono's `trustCloudflareHeaders` is that opt-in there. The normalisation is unchanged behind the flag.

  **Breaking: deleting a role now revokes the grants that named it.** `deleteRole` is a cascade on every adapter, matching what `fk_iam_assignments_role ON DELETE CASCADE` already did on drizzle and Prisma: the same call left `getSubjectRoles` returning the deleted role on memory, file, redis and HTTP and `[]` on the SQL adapters. The orphan was not inert — `resolveSubject` ignored it, but recreating a role under the reused id handed it back to everyone who once held it, with no operator granting anything, and `assignRole` now refuses to create the very row `deleteRole` was leaving behind. The redis adapter reaches the assignment sets with a `KEYS` sweep on that one admin-rate call; a client that does not expose `keys` gets the role deleted and a report through `onPolicyError` saying the grants were not. An HTTP server is expected to cascade on `DELETE /roles/:id` — see the reference server in `http-compliance.test.ts`.

* 70a9e4d: A malformed admin request body now answers 400 instead of 500.

  `engine.admin.savePolicy` and `saveRole` validate before they write, and signalled
  a rejection with a bare `Error`. Every HTTP integration catches whatever a handler
  throws and routes it to `onError`, which answers 500 — so a body the validator
  refused, which is the client's mistake, was reported as the server's. The write
  was correctly refused either way, so this was never a way past validation; but a
  500 tells a caller to retry a request that can never succeed, and hides a client
  bug behind an apparent outage.

  Rejections are now `IamValidationError`, carrying `kind` (`'policy' | 'role'`),
  the validator's `issues`, and `status: 400`. It extends `Error`, so existing
  `instanceof Error` checks and the exact message text still hold.

  * **express, hono and next** answer `400 { error: 'Invalid policy', issues: [...] }`
    and no longer route the failure through `onError`.
  * **Nest** hands errors to its own exception filter, which only maps
    `HttpException`, and this package does not depend on `@nestjs/common`. Read
    `status` in a filter of your own:

  ```ts
  if (iamIsValidationError(err))
    throw new BadRequestException({
      error: `Invalid ${err.kind}`,
      issues: err.issues,
    });
  ```

  A genuine server fault is still a 500, and the `onAdminMutation` audit event
  still fires with `success: false` either way.

* 3de4298: The Prisma adapter no longer invents keys a stored role does not have.

  `toRole` mapped an absent or null `inherits` column to `inherits: []`, so a role
  read back through Prisma was not the role the other adapters returned for the
  same row — a caller distinguishing "inherits nothing" from "does not declare
  inheritance" saw the two collapse, and only on this adapter. Absent columns now
  produce absent keys, matching the memory, Drizzle and Postgres adapters.

  If you relied on `role.inherits` always being an array, read it as
  `role.inherits ?? []`.

* Both engine modes now evaluate through the compiled table.

  `mode: 'production'` used the compiled table and `mode: 'development'` used the interpreter, so a disagreement between the two was invisible until it reached production - and it reached production as an *allow* against a development run that denied. That single shape accounted for five separate defects in the round-3 audit.

  The table now produces the verdict in both modes. Development additionally runs the interpreter, because the table cannot explain itself: `CONST_ALLOW`/`CONST_DENY` cells are one `kind` byte and `allow` is a raw bitmask, so policy identity is erased at compile time. The interpreter supplies `reason`/`policy`/`rule`, the table supplies the verdict, and a disagreement is printed and thrown - a development-time failure instead of a production-only allow. `check()` returns the same `IDecision` it always did.

  Development is roughly 2.4x slower than production as a result. That is the trade: the second evaluator is what makes a divergence visible, and it does not run in production.

* More roles than the 32-bit grant mask can address now falls back to the interpreter instead of denying every request.

  `compileTable()` throws past 32 roles because role N and role N+32 would otherwise silently share a mask bit. That throw used to reach `authorize()`'s catch, so the whole deployment answered deny with no message unless an `onError` hook happened to be wired - a total authorization outage caused by a capacity limit of one representation.

  The engine now catches that error specifically, warns once, and runs the interpreter, which has no such limit. `preload()` resolves instead of throwing and `healthCheck()` keeps `ok: true` while reporting `compiledTable: { available: false, reason: 'role-limit-exceeded', roleCount, limit }` - correct answers, no fast path, and an operator who can see which.

  Every other compile failure still throws and still denies; a malformed policy is a bug, and answering it with a slower correct path would hide it.

### Patch Changes

* A policy the compiler cannot lower is now named.

  The compiler walks `policy.rules` and each rule's `actions`/`resources` directly, so a policy missing one of them threw from wherever the walk touched it first - `policy.rules is not iterable`, with no indication which of a tenant's policies was broken. The interpreter had always isolated a rotten policy and reported its id through `onPolicyError`, so moving both modes onto the table would have traded a precise diagnostic for an anonymous one. The shape check now runs before the walk, names the policy and the rule, and is forwarded to `onPolicyError` before the error is rethrown. The deny is unchanged.

* e47efb9: Time-boxed grants stop granting when they expire, not up to a `cacheTTL` later.

  **The subject cache now respects the grant's own window.** `IamLRUCache` gave every entry the engine's full `cacheTTL` (60s by default) with nothing tying it to the bounds the drizzle adapter filters on, so a grant with `expiresAt` kept answering *allow* for up to a minute after it ended, and a grant with a future `startsAt` kept answering *deny* for up to a minute after it opened. A 30-second break-glass grant was live for sixty. `IamLRUCache.set` takes an optional `notAfter`, caps the entry at the earlier of the two, stores nothing when the bound has already passed, and expires on `>=` so it agrees with the adapter's exclusive upper bound.

  **New optional adapter method: `getSubjectGrantBoundary(subjectId, opts?)`.** Returns the earliest *future* `startsAt` or `expiresAt` among the subject's grants, or `null` when none has a bound. The engine asks for it alongside the reads it describes — no extra round trip — and caps the cache entry there. It is implemented by the drizzle adapter, the only one that stores the bounds; the other five omit it and continue to refuse the options outright. Custom adapters need no change, and gain the shortened cache by implementing it. A failure in the method costs caching only: the subject is not cached, the answer still comes from the store, and a warning names the method and subject.

  **`assignRole` refuses a window that can never be active.** `startsAt >= expiresAt` is an empty interval — the grant is stored and never live, while the call resolves and `engine.admin.assignRoles` reports `ok: true, applied: 1`. An `Invalid Date` in either field was passed to the driver rather than refused. Both are now rejected at the adapter boundary, on `assignRole` and on every row of `assignRoleMany`; the error names the fields and never the instants. The shipped pg/mysql/sqlite schemas already carried `ch_iam_assignments_starts_before_expires`, but the adapter can be pointed at a caller's own table, where the code is the only guard.

* Three fixes from the audit re-verification sweep.

  An error-reporting hook that throws no longer unwinds the evaluation it was
  reporting on. `onPolicyError` and `onRuleError` were called raw from inside the
  catch that implements the Indeterminate contract, so a hook whose metrics
  backend was down took the deny vote with it.

  The Redis invalidator's drop warning no longer prints the tenant id. The channel
  carries it, the warning is written on a path any holder of PUBLISH rights can
  drive, and stderr is the stream that gets shipped to shared aggregators; the
  tenant segment is now a stable digest instead.

  `explain()` and `can()` agree at the leaf. The trace evaluated conditions
  through the raw operator table, which skips the refusal of `matches` against a
  `$`-sourced operand, so a trace could report a condition as satisfied that the
  engine had refused - showing an operator the opposite of the decision they were
  debugging. Leaf verdicts now come from the decision path itself.

## 5.8.0

### Minor Changes

* 7202aa1: **Breaking:** scoped permission keys are now prefixed with `@`.

  `iamBuildPermissionKey` previously emitted `scope:action:resource[:resourceId]`, which is ambiguous with the unscoped `action:resource:resourceId` form - a three-segment key could not be parsed back without knowing which shape produced it, so a scope could be read as a resource id and vice versa. Scoped keys now carry an explicit `@` marker:

  * `org-1:manage:billing` becomes `@org-1:manage:billing`
  * `org-1:update:post:post-42` becomes `@org-1:update:post:post-42`

  Unscoped keys are unchanged. A literal leading `@` in a segment is escaped as `\@`, and `iamSplitPermissionKey` understands the escape. The new `iamParsePermissionKey` returns `{ scope, action, resource, resourceId }` or `null`, so consumers no longer have to split keys by hand.

  Anything that hardcodes a scoped key string - client-side permission maps, cached `can()` results keyed by string, fixtures - needs the `@` added.

### Patch Changes

* 2e5f5dc: Fix `resolveSubject` mistagging a role reached through cross-scope inheritance with the source assignment's scope instead of the role's own declared scope. A role assigned at one scope that `.inherits()` a role defined at another scope (e.g. a company-level role inheriting a marketplace-level role) had the inherited role silently invisible at the scope it actually belongs to - `enrichSubjectWithScopedRoles` filtered it out because it carried the wrong scope tag, so a policy check at the inherited-into scope only ever saw whatever lower-privilege role was directly assigned there, if any.

  Each role visited during the inheritance walk is now tagged with its own `IRole.scope`, falling back to the assignment row's scope only when the role declares none of its own - a no-op for roles with no cross-scope inheritance.

* 5c96bd5: Fix two places where a rule that was correct in development silently stopped applying in production.

  **`first-match` / `highest-priority` disagreed between the two evaluation paths on a priority tie.** Both algorithms resolve equal priorities by source order. The interpreter walks `policy.rules` directly and honoured that, but `evaluatePolicyFast` walks the rule index, which buckets literal-resource rules separately from wildcard-resource ones and visits the literal bucket first. A `deny read '*'` declared before an `allow read 'post'` at the same priority therefore denied under `mode: 'development'` and allowed under `mode: 'production'` - the deny disappeared exactly where it mattered most. `Evaluate.IIndexedRule` now carries the rule's index in `policy.rules`, and both tie-break sites in the fast path compare it, so bucket order no longer leaks into the decision. `deny-overrides` and `allow-overrides` were never affected; they are order independent.

  The `evaluate == evaluateFast` property oracle covered this shape in principle but drew priorities from 20 values, making ties too rare to hit it. It now draws from 4, so collisions are the common case.

  **The condition-nesting limit was off by one between the validator and the evaluator.** `evalConditionGroup` refuses a group at `depth >= MAX_CONDITION_DEPTH` and fails closed, while `validateConditionGroup` only errored at `depth > MAX_CONDITION_DEPTH`. A group nested exactly at the boundary therefore validated cleanly and then never matched. On an allow rule that merely failed closed, but a deny rule at that depth passed validation and silently stopped denying. Both comparisons are now `>=`, so anything the evaluator will refuse is reported as `LIMIT_EXCEEDED` up front.

* ec678af: Evaluation errors are now Indeterminate and fail closed instead of being silently skipped.

  A condition that threw - an unknown operator, a malformed `all`/`any`/`none` group, a regex rejected for being unsafe - was caught and treated as "policy does not apply", which quietly retired the deny rule that condition was guarding. An error inside a policy or compiled cell that carries a deny rule now vetoes the request. Allow-only policies and RBAC role permissions still skip, since an error there cannot grant anything.

  Also in this release:

  * `matches` uses a real catastrophic-backtracking detector instead of a naive nested-quantifier regex, and checks the compiled-pattern cache before running it.
  * Unknown condition operators and non-array condition groups throw a prefixed error rather than a raw `TypeError`.
  * HTTP method-to-action and pathname normalisation reject `//admin` and `/%61dmin` style bypasses; unknown methods and unresolvable resources map to explicit `IAM_UNKNOWN_ACTION` / `IAM_UNKNOWN_RESOURCE` instead of a permissive default.
  * User-Agent is capped at 2048 characters before it reaches the environment attributes.
  * The file adapter writes atomically via tmp-file + rename and serialises concurrent flushes; the redis adapter rejects an empty scope; the drizzle adapter rejects non-finite assignment bounds.
  * `preload()` builds the compiled table in production so a compile error surfaces at startup, and `healthCheck()` awaits it.
  * `pathCache` is no longer exported from `src/core/resolve`.

* 7a1ce88: `IamEngine.withTransaction(client)` binds reads and writes to a transaction you own, and `admin` gained batch forms that report per-row outcomes.

  The client is opaque to the library and handed straight back to your adapter. Writes go through `.admin`, the same interface as `engine.admin`, so there is one write surface rather than two. Reads on the bound view run against caches created for that transaction alone: an empty cache always misses through to the transaction-bound adapter, which is what makes a read-after-write inside the transaction correct, and the shared engine keeps answering from its own warm caches, which the transaction never pollutes.

  Cache invalidations - including the `config.invalidator` fleet broadcast - buffer in `pending`, de-duplicated, and fire on `flush()`. A rolled-back grant therefore never evicts another node's cache for a write that did not happen. The bound view drops the invalidator rather than reusing the parent's, so a facade built per transaction cannot leak a subscription; buffered entries broadcast through the parent engine on flush.

  Also in this release:

  * `admin` gained `assignRoles`, `revokeRoles`, `moveRoleScopes` and `invalidateSubjects`. Every row is validated before any is written, so a malformed row aborts the batch instead of half-applying it, and each affected subject is invalidated once however many rows named it.
  * Each outcome carries the `row` it answers rather than an id derived from it. A role assignment is identified by a `(subject, role, scope)` triple of free-form strings, and every encoding of three of those into one key is either ambiguous or unreadable - joining them with a space collided whenever an id contained one, and nothing rejects a space in a subject id. Outcomes stay in input order, so matching by index is exact too.
  * Both role writes are idempotent, so every row is `ok` - granting a role a subject already holds is success, matching the single-row method. Outcomes carry `changed` for the finer answer: `true` when the row accounts for a write the statement made, `false` when it was already in the requested state, and absent where the adapter could not say. It is read off a `RETURNING` clause on the write itself, so it costs no extra round trip; MySQL, which has no `RETURNING`, and adapters that loop the `void`-returning single-row methods leave it off rather than guess.
  * Every write is credited to exactly one row, the first that accounts for it. Listing the same triple twice reports `true` then `false` rather than both rows claiming a write the database made once, and revoking a role unscoped alongside a scoped row it already covers credits the wildcard. Neither batch is rejected.
  * The drizzle adapter collapses the writes into one `INSERT` and one `DELETE`. The `DELETE` needs `or` in the adapter's `ops` and revokes row by row without it.
  * The adapter contract gained an optional `withClient`. An adapter that cannot join a transaction makes `withTransaction` throw rather than silently leaving those writes outside it.

## 5.7.0

### Minor Changes

* 62e3d0b: Add an optional `IConfig.maxConcurrentSubjectLoads` cap (default `0` = unbounded,
  matching `adapterTimeoutMs`'s 0-disables convention) to bound the cold-flat herd
  described in `SCALING.md` section 8. `resolveSubject` rejects a *new* subject load once
  `inFlight.subjects.size` hits the cap, before touching the adapter - fail-closed
  load-shed, not a bounded queue, consistent with the engine's existing fail-closed
  posture. The rejection is a plain `Error` whose message contains `"subject load shed"`,
  so it surfaces through `can` / `check` / `authorize`'s existing fail-closed
  `catch -> onError` path with no new wiring.

  A call that hits the subject cache or joins an already-in-flight load for the same
  subject never counts against the cap.

## 5.6.0

### Minor Changes

* `iam_assignments` gains `starts_at`, `expires_at`, and `attributes` columns across
  the pg, mysql, and sqlite drizzle schemas. `getSubjectRoles` / `getSubjectScopedRoles`
  now filter out assignments outside their `[startsAt, expiresAt)` window; a row with
  both NULL behaves exactly as before these columns existed.

  `IScopedRole` gains an optional `attributes` field, populated from the new column so
  a policy condition can read per-grant data (department, region, whatever the caller
  stores) as `subject.scopedRoles[].attributes`, distinct from the subject's own global
  attributes. A corrupted `attributes` value drops just that field and reports through
  `onPolicyError`; it does not fail the whole role.

  `ISubjectStore.assignRole` gains an optional fourth `opts: IamAdapter.IAssignOptions`
  parameter (`startsAt` / `expiresAt` / `attributes`), implemented by the drizzle adapter.
  Purely additive - every other adapter (memory, file, redis, prisma, http) still
  satisfies the interface unchanged.

## 5.5.1

### Patch Changes

* 86b6775: Drop `deletedAt` from `iamPolicies`, `iamRoles`, `iamAssignments`, and
  `iamSubjectAttrs`, added in 5.5.0, along with `IamDrizzleAdapter`'s opt-in
  `deletedAt IS NULL` read filtering. `deletePolicy` / `deleteRole` / `revokeRole` are
  hard-delete by explicit design (a soft-deleted policy/role name couldn't be reused,
  and a revoked assignment has no reason to be retained), and subject attributes have
  no delete operation at all - none of these columns would ever have been set by
  anything in this codebase.

  `ops.isNull` stays on `IamDrizzleAdapter`'s config: `updateAssignmentScope` still
  needs it to match a global (unscoped) assignment correctly, independent of the
  removed soft-delete filtering.

## 5.5.0

### Minor Changes

* 2853214: Add `engine.admin.updateAssignmentScope(subjectId, roleId, fromScope, toScope, actor?)`
  to move a role assignment to a different scope in one write instead of
  revoke + assign.

  `IamAdapter.ISubjectStore` gains an optional `updateAssignmentScope`. When an adapter
  implements it, the engine uses it directly; when it doesn't (or it returns `false`
  because nothing matched `fromScope`), the engine transparently falls back to
  revoke + assign, so the call always succeeds either way.

  Implemented for `memory`, `file`, `prisma`, and `drizzle`. `drizzle` additionally needs
  `ops.isNull` configured (matching `deletedAt` filtering) to match the global/unscoped
  case correctly; without it, `updateAssignmentScope` returns `false` and the engine falls
  back automatically. Not implemented for `redis` (scope is encoded into the Set member
  itself, so there's no cheaper path than remove + add) or `http` (would need a new
  endpoint on the operator's server) - both already work correctly via the fallback.

  `iamAssignments` gains `updatedAt` / `updatedBy` in the drizzle schema (pg/mysql/sqlite)
  to support this - the only table getting them in this release, since it's now the only
  one with a real update path that didn't already have them.

### Patch Changes

* 2853214: Round out audit columns on the drizzle schema (pg/mysql/sqlite): `iamPolicies` and
  `iamRoles` gain `deletedAt`; `iamSubjectAttrs` gains the `createdBy` it was missing
  (it already had `updatedBy`) plus `deletedAt`. `iamAssignments`' own audit columns
  are covered separately, alongside the new `updateAssignmentScope` feature that needs
  them.

  `IamDrizzleAdapter`'s `ops` config gains an optional `isNull` operator. When
  provided, `listPolicies` / `getPolicy` / `listRoles` / `getRole` / `getSubjectRoles` /
  `getSubjectScopedRoles` / `getSubjectAttributes` exclude rows with `deletedAt` set;
  omitted (the default, matching every version before this column existed), reads are
  unchanged. `deletePolicy` / `deleteRole` / `revokeRole` still hard-delete on purpose -
  turning them into soft-deletes would break the unique-name constraint on policies and
  roles (a "deleted" name couldn't be reused) and orphan the FK cascade from
  `iamAssignments`. The column is a hook for something outside the adapter to set
  (an admin tool, a trigger), not something this adapter writes itself.

* 2853214: Fix scoped role assignments not resolving inherited roles. `resolveSubject` closed
  `subject.roles` over `inherits` but passed `subject.scopedRoles` through unresolved,
  so a condition reading `subject.scopedRoles` saw only the directly assigned role and
  not what it inherits, while the exact same role assigned without a scope resolved
  correctly. Scoped roles now go through the same inheritance closure.

  `IamClient` also gains `PartialPermissionMap`, the type `engine.permissions()`
  actually returns (only the checked keys, not every possible combination). The React
  client's `usePermissions` / `createIamPermissionChecker` / `IContextValue.permissions`
  now use it instead of the full `PermissionMap`, matching what callers really have.
  `iamBuildPermissionKey` is also re-exported from the React entry so a consumer
  building a key by hand doesn't need a second import from core.

## 5.4.2

### Patch Changes

* 4d956c8: No functional change. Version bump to resync with the registry after 5.4.1 was
  published without its git history being committed.

## 5.4.1

### Patch Changes

* 959a8a4: Restructure the drizzle adapter's schema exports into per-dialect folders, matching
  `@gentleduck/auth`'s layout.

  `@gentleduck/iam/adapters/drizzle/schema/{pg,mysql,sqlite}` is now
  `@gentleduck/iam/adapters/drizzle/{pg,mysql,sqlite}`. Each folder also exports a
  `{Pg,Mysql,Sqlite}` types namespace (`PolicyRow`, `RoleRow`, `AssignmentRow`, `AttrRow`)
  inferred from that dialect's schema, so a consumer pinned to one dialect no longer needs
  to import the adapter's cross-dialect union types to get a concrete row shape.

  Update imports from `@gentleduck/iam/adapters/drizzle/schema/pg` (etc.) to
  `@gentleduck/iam/adapters/drizzle/pg` (etc.).

## 5.4.0

### Minor Changes

* 39aaa82: Export the factories, and finish the barrels.

  The previous release gave every publicly constructed class a factory function, but
  several were never exported, so `new` remained the only reachable spelling for
  `AnomalyFacet`, `HijackFacet`, `WebhookDeliverer`, `MemoryPasskeyChallengeStore`,
  `AuthMemoryDeviceFingerprintStore`, `DPoPVerifier`, the data-at-rest providers, the
  password hashers, and the api-key / magic-link / passkey / saml / passwords impls.

  The channels barrel exported one type and nothing else, so every channel had to be
  imported by deep path. All six ship from `@gentleduck/auth/channels` now. The anomaly
  barrel likewise omitted both detectors and the fingerprint store, which meant the
  detectors could not be registered without reaching past it.

  On the IAM side, `iamEngine` and `iamLRUCache` are exported alongside their classes.

  BREAKING: three aliases in the `@gentleduck/auth` root now name the factory rather than
  the class, so `new` on them stops compiling.

  * `AuthBackupCodesFacet` is now `backupCodesFacet`; the class is `BackupCodesFacet`.
  * `AuthInMemoryEvents` is now `inMemoryEvents`; the class is `InMemoryEvents`.

  Both classes are exported under their own names, so `new BackupCodesFacet(...)` and
  `new InMemoryEvents()` are the mechanical fix.

* 196f52d: Reject a policy whose target names a pair no allow rule covers, instead of warning.

  `UNREACHABLE_TARGET` was a warning, so `PolicyBuilder.build()` accepted the policy and
  the only symptom was a denial at request time. A denial reads as the permission system
  working, which is why this cost five separate incidents to recognise: widening a target
  is one line and widening the rules is another, nothing couples them, and the drift is
  silent.

  It is now an error, so `build()` throws where the policy is written.

  Two supporting fixes:

  * The check treated a dimension the target omits as a literal `*`, which demanded that
    every rule be a wildcard. A target naming only `impersonate` was reported unreachable
    because its allow rule named `.of('users')`. An omitted dimension is one the target
    does not constrain, so only the dimensions it names are checked.
  * `PolicyBuilder.build()` dropped the validator's message and reported only the code and
    path, so every build failure was cryptic. It now includes the message and the policy id.

  Also re-enables the two drizzle adapter suites, commented out wholesale in f3f57cb8
  "pending rename follow-up" that never landed. `IamDrizzle.IConfig` had gained
  `<TDb, TType>` in that rename and the suites still referenced it bare. 62 tests back,
  and they are not decorative: removing the JSONB shape guard, silencing `onPolicyError`,
  and dropping the WHERE from the single-row lookup are each caught.

  BREAKING: a policy with an unreachable target now throws at build time rather than
  loading with a silent denial.

### Patch Changes

* Make the not-blank check constraints reject whitespace.

  `length(trim(x)) > 0` only strips ordinary spaces, so a name, subject id, scope or
  credential secret consisting of a tab, a newline or a form feed passed the check that
  exists to refuse exactly that. Seven constraints were affected:
  `auth_credentials.secret`, `iam_assignments.subject_id` and `.scope`, `iam_policies.name`,
  `iam_roles.name` and `.scope`, `iam_subject_attrs.subject_id`.

  Each dialect gets the strongest form it has. Postgres and MySQL match a non-whitespace
  character (`~ '[^[:space:]]'` and `REGEXP '[^[:space:]]'`); SQLite has no regexp operator
  built in, so it trims the whitespace set explicitly and compares against the empty string.
  All three were verified against a real server for a tab, a newline, a carriage return, a
  vertical tab, a form feed and a plain space.

  Existing databases need a migration: drop each constraint and add it back in the new form.
  Any row already holding a whitespace-only value has to be repaired first, or the
  `ALTER TABLE` is refused.

* Close scoped role assignments over `inherits`, the way direct assignments already were.

  `resolveSubject` ran `resolveEffectiveRoles` over the roles returned by `getSubjectRoles`
  and passed `getSubjectScopedRoles` through untouched. A deployment that scopes every
  assignment therefore had an empty `subject.roles` and a flat scoped set, so a condition
  reading `subject.roles` saw the assigned role and none of the roles it inherits.

  The effect was silent and direction-dependent: RBAC permission resolution walks `inherits`
  separately, so a superadmin still had every permission its parents grant, while
  `w.role(...)`, `w.roles(...)` and `w.contains('subject.roles', ...)` behaved as if the
  hierarchy did not exist. The same policy then decided differently depending on whether the
  assignment carried a scope, which is not something the API hints at.

  Scoped roles now expand through the same closure, each inherited role keeping the scope of
  the assignment it came from.

## 5.3.0

### Minor Changes

* Warn when a policy target names an action/resource pair no rule can allow.

  `evaluatePolicy` folds `defaultEffect` when a policy's target matches and none of its
  rules do, and that default is `deny`. A target therefore widens what a policy refuses,
  not only what it inspects: adding a resource to `.target({ resources: [...] })` without
  an allow rule covering it denies every caller for that resource, and the refusal
  surfaces far from the policy that caused it.

  `validatePolicy` now emits an `UNREACHABLE_TARGET` warning per uncovered pair. It fires
  only once the policy contains at least one allow rule, so a purely restrictive policy -
  where denying everything the target names is the whole point - is untouched.

  Warning rather than error: the behaviour is correct deny-by-default and existing
  policies that rely on it keep validating.

## 5.2.0

### Minor Changes

* bc8a9ea: Prefix the drizzle tables and constraints with `iam_`, and let the Nest access
  guard contribute resource attributes.

  **Renamed tables and constraints.** The physical tables move from the
  `access_*` prefix to `iam_*` (`access_policies` becomes `iam_policies`,
  `access_roles` becomes `iam_roles`), along with every derived `pk_`, `uq_`, `idx_`
  and `ch_` identifier, in the `mysql`, `pg` and `sqlite` schema builders. This
  makes the schema attributable to this package once merged into a host
  application's database.

  Existing databases need a migration renaming those tables and their
  constraints. New installations are unaffected.

  **`getResourceAttributes` on `iamNestAccessGuard`.** An optional hook that
  computes the attributes attached to `resource.attributes` before
  `engine.can()` runs. It receives the resolved `{ action, resource }` alongside
  the request, because the correct attributes are resource-specific: a `users`
  row is its own subject, whereas an `iamAssignments` row carries its subject in
  a column. Passing the resolved pair means callers do not have to re-derive
  which case they are in from the raw request.

  **Known gap:** the drizzle adapter and native-attr-shape test suites (47 cases)
  are temporarily disabled while their mock table references are reworked for the
  rename.

## 5.1.0

### Minor Changes

* Restructure core into `engine/` and `config/` subfolders matching duck-iam patterns.
  Rename `defineAuth` to `createAuth` as primary entry point. Extract `AuthEngineTypes`
  and `AuthDefine` into dedicated types files. Add `Auth` prefix to all public classes.

## 5.0.1

### Patch Changes

* fix: strip redundant iam/auth prefixes from public exports

## 5.0.0

### Major Changes

* Prefix all public exports with package namespace (`Auth*` / `Iam*` / `IAM_*` / `AUTH_*`)
  so the origin is clear at the type level when both packages are imported together.
  This is a breaking change - all consumers must update import references to the new names.

## 4.0.0

### Major Changes

* a5fb285: Rename the policy builder factory to `definePolicy`, matching `defineRule` and
  `defineRole`.

  BREAKING: the `policy()` factory and `access.policy()` method are removed. Use
  `definePolicy()` and `access.definePolicy()` instead - the builder API is
  otherwise unchanged.

## 3.2.0

### Minor Changes

* f77fb5a: Harden and type the Drizzle adapter schemas (pg, mysql, sqlite).

  * Add `json: 'native' | 'string'` adapter option. `'native'` (default) writes plain
    objects to `jsonb` / `json` columns so payloads stay queryable; `'string'`
    JSON-stringifies for SQLite/text columns. The read path accepts both, so switching
    is migration-safe.
  * Type every JSON column with `$type<>()` against the `AccessControl` types; constrain
    `algorithm` with a Postgres enum, a MySQL enum, and a SQLite CHECK.
  * Add CHECK constraints (non-blank name/subject, `version >= 1`), `created_by` /
    `updated_by` audit columns, GIN indexes (pg), partial indexes for scoped rows
    (pg/sqlite), and a `roleId` index.
  * Collapse NULL scopes in unique constraints (`NULLS NOT DISTINCT` on pg,
    `COALESCE(scope, '')` on mysql/sqlite) so duplicate global rows are rejected.
  * Name every constraint (`pk_`, `fk_`, `uq_`, `idx_`, `ch_`).

  Fixes: pg `inherits` was `text[]` but the shared adapter writes JSON, so it is now
  `jsonb`; the MySQL timestamp default was a static import-time snapshot and is now
  per-row `CURRENT_TIMESTAMP(3)`.

  Migration note: regenerate migrations with `drizzle-kit generate`. SQLite users must
  pass `json: 'string'`.

## 3.1.0

### Minor Changes

* e5fc356: Engine structure cleanup + adapter validation hardening.

#### New exports

* **`parsePolicyRow` / `parseRoleRow`** from `@gentleduck/iam/core/validate`. Helpers for
  custom-adapter authors: take an `unknown` row, return the typed
  `AccessControl.IPolicy<...>` / `AccessControl.IRole<...>` when structurally valid, or
  `null` to drop the row. Replaces the pattern of calling `validatePolicy(row)` then
  casting `row as IPolicy<...>`.

#### Internal refactors (no public API change)

* `engine.ts` split into five single-purpose modules under `core/engine/`:
  * `engine.invalidation.ts` - cross-instance + in-flight cache invalidation
  * `engine.loaders.ts` - cache-fronted loaders with single-flight coalescing + adapter
    timeout + max-row guards
  * `engine.hooks.ts` - safe hook calls + metrics emission with throw-swallowing
  * `engine.lifecycle.ts` - preload / health-check / dispose
  * `engine.stats.ts` - snapshot / reset / hit-rate aggregation
* File, Redis, Drizzle, and Prisma adapters now route every row-decode path through
  `parsePolicyRow` / `parseRoleRow` instead of bare `as` casts. Prisma's `listPolicies` /
  `getPolicy` / `listRoles` / `getRole` now actually validate before returning - this was
  a latent gap.
* `core/explain` is now lazy-loaded by `engine.explain()` via dynamic `import()`.
  Production-mode bundles drop the explain chunk entirely.

#### Tests

* 50 new direct unit tests for the extracted engine helpers (invalidation, hooks, stats,
  lifecycle, loaders). The class-method shims are proved to delegate to the extracted free
  functions, not just rename.

#### Documentation

* `AUDIT-RESULTS.md` checked in. 0 runtime advisories.
* The two reported workspace-level vulnerabilities affecting `@gentleduck/iam` are both in
  `role-acl` (a benchmark competitor in `devDependencies` only); never installed by
  consumers.

#### Migration

None required. All changes are additive or internal.

## 3.0.1

### Patch Changes

* 1f5ac74: **@gentleduck/auth**: end-to-end input + tenant + config-time hardening sweep.

  * Provider entry-point caps + typeof guards (api-key, magic-link, oauth, passkey,
    password, saml). Magic-link `callbackPath` validated at construction (refuses
    protocol-relative + CR/LF). OAuth `redirectUri` + endpoint URLs validated. SAML
    `relayState` + `host` CR/LF guard.
  * Facet input caps (flows, sessions, mfa, apikeys, identities, idempotency).
    `isProviderIdSafe` guard in `signIn` / `beginProvider`. CAS-claim on recovery +
    signup. Email canonicalization (`trim().toLowerCase()`) shared between rate-limit +
    lookup + stored metadata.
  * Transport hardening: 4 KB bearer cap, 8 KB DPoP cap, 16 KB cookie-header cap, cookie
    name RFC 6265 validation. JWT `signKey.kid` + `signKey.key` validation.
    `Number.isFinite` on iat / nonce / counter rollback. `timingSafeEqual` on `ath` +
    `nonce`.
  * Adapter parity: memory adapter `findByHashedSecret` respects `ctx.tenantId` (was
    searching globally) + uses `isRevoked` predicate. `upsert` inherits `tenantId` from
    ctx. Redis adapter caps key length + clamps NaN/huge ttl. SQL adapters parameterize
    JSONB queries.
  * `AuthRoot.strict`: refuse `http://` baseUrl in production.
  * Webhooks: `redirect: 'error'` SSRF, 1 MiB payload cap, 20-attempt backoff cap,
    NaN-timestamp rejection.
  * New `@gentleduck/auth/server/{fastify,koa,nestjs,elysia,grpc}` adapters.
  * New providers: SAML 2.0 SP, Microsoft, Discord, LinkedIn, Sign in with Apple,
    api-key sign-in.
  * New channels: Resend, Twilio, Web Push, AWS SES.
  * DPoP (RFC 9449) + OAuth refresh-reuse detection.
  * READMEs: parallel structure across both packages, local logo + LICENSE for npm
    rendering.

  **@gentleduck/iam**: defense-in-depth + adapter hardening + vitest compat shim.

  * `engine.libs.assertNonEmptyStringParam`: enforce 1024-char cap.
    `assertAttributesParam`: 256-key + depth-16 caps. `engine.permissions()`: refuse
    batches over 1024. `engine.can()` / `check()` / `explain()`: subjectId typeof +
    length-cap; fail-closed in production.
  * File adapter dicts now `Object.create(null)` (prototype-pollution defense).
    `setSubjectAttributes('__proto__', ...)` no longer pollutes `Object.prototype`.
  * HTTP adapter: streaming `readBodyCapped` + `readJsonCapped<T>` so multi-GB remote
    bodies cannot OOM before slice. ID-length caps. Backoff overflow cap. SSRF
    `redirect: 'error'`.
  * Redis invalidator: pre-auth UTF-8 byte-length cap + depth/key-count cap on parsed
    envelopes.
  * Hono adapter: body `Reflect.get`-parsed with typeof + length guards.
  * Vitest compat shim for bun runtime (`stubGlobal` / `unstubAllGlobals` /
    `describe.runIf`); 8 previously-failing devtools tests now pass.

  **Tests**: +42 across both packages, all green. No functional behavior changes beyond
  defensive guards on hostile input.

## 3.0.0

### Breaking - Engine facet split (cache + stats)

`Engine` had 16 public methods on one class. Four clusters were visible: evaluation,
cache invalidation, lifecycle, and observability. The cache-invalidation and
observability clusters are folded into two facets so the root surface stays focused.
Evaluation (`authorize`, `can`, `check`, `explain`, `permissions`) and lifecycle
(`constructor`, `dispose`, `preload`, `healthCheck`) stay flat - hot path, single noun.

#### Migration

| Before (`<= 2.x`)                       | After (3.0)                                |
| --------------------------------------- | ------------------------------------------ |
| `engine.invalidate(opts)`               | `engine.cache.invalidate(opts)`            |
| `engine.invalidateSubject(id, opts)`    | `engine.cache.invalidateSubject(id, opts)` |
| `engine.invalidatePolicies(opts)`       | `engine.cache.invalidatePolicies(opts)`    |
| `engine.invalidateRoles(id?, opts)`     | `engine.cache.invalidateRoles(id?, opts)`  |
| `engine.stats()`                        | `engine.stats.get()`                       |
| `engine.resetStats()`                   | `engine.stats.reset()`                     |
| `engine.flushSharedCaches()` (removed)  | `import { flushSharedCaches } from ...`    |

Mechanical `sed` per call site - no behavior change. Codemod:

```sh
sed -i -E \
  -e 's/(engine[A-Za-z]*)\.invalidate\(/\1.cache.invalidate(/g' \
  -e 's/(engine[A-Za-z]*)\.invalidateSubject\(/\1.cache.invalidateSubject(/g' \
  -e 's/(engine[A-Za-z]*)\.invalidatePolicies\(/\1.cache.invalidatePolicies(/g' \
  -e 's/(engine[A-Za-z]*)\.invalidateRoles\(/\1.cache.invalidateRoles(/g' \
  -e 's/(engine[A-Za-z]*)\.stats\(\)/\1.stats.get()/g' \
  -e 's/(engine[A-Za-z]*)\.resetStats\(\)/\1.stats.reset()/g' \
  src/**/*.ts
```

#### Why 3.0 now

* The `flushSharedCaches` instance method was already scheduled for 3.0 removal - it was
  misleading, because it wiped process-globals, so calling it on one engine affected every
  other engine in the process. The module-level export is the honest surface and has been
  the documented one since 2.1. Bundling the deprecation with the facet split means one
  major version and one migration window.
* The flat surface drops from 16 to 9 methods + 2 facet handles. That leaves room for
  future facet growth (`engine.cache.prewarm()`, `engine.stats.subscribe()`) without
  polluting the root.

#### What did not change

* `engine.authorize` / `can` / `check` / `explain` / `permissions` - identical signatures
  and semantics.
* `engine.preload` / `dispose` / `healthCheck` - unchanged.
* Bundle size stable at 41.6 KB (internal refactor, no shape change).
* 948/948 tests pass after the bulk rename.

## 2.2.0

### Architecture debt cleanup + bundle slim

Follow-up to the 2.1.0 security audit. Closes maintenance gaps the cycle surfaced and
trims the bundle so the "import everything" headline is no longer the only number.

#### Architecture

* **`runSingleFlight` + `runSingleFlightKeyed`**: 5 copies of the sentinel-compare
  in-flight pattern in `engine.ts` (`_loadPolicies`, `_loadRoles`, `_loadRbacPolicy`,
  `_loadAllPolicies`, `_resolveSubject`) collapsed to one helper. Same-class bugs (a
  missed sentinel in the merger) are now structurally impossible.
* **`runAdminAuthz` + `withAdminAudit`**: extracted from the 4 server adapters
  (express / hono / nest / next). The csrf + authorize + try + audit shape lives in one
  place. Future changes land in one file instead of four.
* **Per-Engine evaluation caches**: `regex` and `path` caches threaded end-to-end through
  `evaluate` / `evaluateFast` / `evaluatePolicy` / `evaluatePolicyFast` / `matchCandidate` /
  `ruleApplies` / `evalConditionGroup` / `evalCondition` / `resolve`. Multi-tenant
  deployments instantiate one Engine per tenant; each owns its own caches and cannot be
  evicted by hostile-tenant pattern flooding. `flushSharedCaches()` remains for legacy
  callers.
* **Drizzle typed selects**: 7 `as unknown as` casts at module-edge consolidated into 3
  typed helpers (`_selectAll`, `_selectFirst`, `_selectWhere`). Type system is
  load-bearing again.
* **Adapter compliance suite** at `src/adapters/__compliance__/`. Every shipped adapter
  passes the same 21 scenarios. Caught a `revokeRole` drift in `MemoryAdapter` and
  `FileAdapter` (omitting `scope` should remove all matching role rows, not just the
  unscoped one).
* **Builder auto-validate**: `PolicyBuilder.build()` and `RoleBuilder.build()` run
  `validatePolicy` / `validateRole` and throw on error. Power-users wiring the adapter
  directly (bypassing `engine.admin.savePolicy`) see failures where the bug was
  introduced.

#### Bundle slim

* **Lazy validator**: `engine.libs.ts` admin write paths (`savePolicy` / `saveRole` /
  `import`) now `await import('../validate')` on first call. The 12 KB validator chunk is
  skipped entirely by read-only services.
* **Subpath splits**: `@gentleduck/iam/core/validate`, `@gentleduck/iam/core/builder`,
  `@gentleduck/iam/core/explain`, `@gentleduck/iam/core/schema` each ship as separate
  entries. Tree-shaking drops them for consumers that don't import the subpath.
* **Barrel cleanup**: `src/index.ts` no longer re-exports `FileAdapter`, `MemoryAdapter`,
  or the validator. Adapter consumers go through subpath imports
  (`@gentleduck/iam/adapters/memory`).
* **Drop 26 `@deprecated` 2.0 to 3.0 type aliases**. The `.d.ts` surface is clean. The
  deprecation window from 2.0.0 is closed; consumers were warned for two minor versions.
* **Forensic comments scrubbed**: 452 redundant `@author` JSDoc tags and 326 audit-trail
  reference comments removed from source.

#### New APIs

* **`flushSharedCaches`** module-level export (`@gentleduck/iam` and
  `@gentleduck/iam/core`). The instance method `Engine#flushSharedCaches` is deprecated -
  it wiped process-globals despite being instance-bound.
* **`engine.preload({ validator: true })`** eagerly loads the lazy validator chunk at boot
  for operators who want every cost up front.
* **`engine.permissions(..., { telemetry: false })`** opts out of per-check `onMetrics` +
  `signals` allocation. Restores 2.0.x throughput on hot UI gates where `authorize()`
  already captures the metrics signal.
* **`escapeHtml`** from `@gentleduck/iam/core/explain`. Safe HTML escape for consumers
  rendering `Explain.IResult.summary` into a debug panel.
* **`createEvalCaches`** from `@gentleduck/iam/core` constructs a fresh per-Engine cache
  pair if a consumer needs to build their own evaluator pipeline.
* **`splitPermissionKey`** from `@gentleduck/iam/shared/keys` reverses
  `buildPermissionKey` honouring escape sequences.

#### Tests

* 836 to **943** (+107). The +107 is the new adapter compliance matrix applied to 5
  adapters.

#### Stryker mutation testing scaffold

`bun run mutation` wires Stryker against engine + evaluate + conditions + resolve +
validate + server/generic + all 5 adapters. Not in CI by default (5-15 min runtime);
operators run it on demand or via a scheduled job.

#### Benchmarks

Measured baselines (2.0.1 from a `git worktree` clean build, not eyeballed):

| Path                          | 2.0.1       | 2.1.0    | 2.2.0        |
| ----------------------------- | ----------- | -------- | ------------ |
| `evaluatePolicy` (conditions) | 1.33 µs     | 1.00 µs  | 1.00 µs      |
| `engine.can()` cached         | 4.86 µs     | 5.85 µs  | 5.18 µs      |
| `engine.permissions()` x20    | 20.06 µs    | 42.71 µs | 48.08 µs     |
| Bundle "import everything"    | **38.4 KB** | 44.8 KB  | **41.3 KB**  |
| Bundle realistic profile      | n/a         | n/a      | **15-25 KB** |

Net 2.0.1 to 2.2.0 bundle delta: **+2.9 KB (+7.5%)**. Earlier docs cited a ~21 KB
pre-cycle number - that was estimated from a partial dist, not a clean build. The full
security cycle cost ~6 KB raw; the bundle slim cycle recovered ~3 KB; net is +2.9 KB for
fail-closed hook contracts, per-Engine caches, default-on CSRF, and lazy validator
scaffolding.

`engine.permissions(..., { telemetry: false })` cuts the batch path back to ~22 µs for
callers who opt out.

## 2.1.0

### Adversarial security audit cycle (21 rescans, ~60 fix commits)

A second multi-round audit pass after 2.0.0, run by two independent adversarial
security-auditor agents plus a silent-failure hunter and a code-smell scanner. **21 rescan
cycles** produced **~60 fix commits** addressing 1 CRITICAL, 7 HIGH, 11 Medium, 12 Low, and
4 Info findings on top of the 2.0.0 hardening. Three consecutive clean rescans (Med+ free)
declared the source tree exhausted: *"the package is genuinely hard to break."*

The change set is **mostly backward compatible** with three intentional default changes
that close trivial auth-bypass / CSRF footguns.

#### CRITICAL (1)

* `FileAdapter._loadState` swallowed every `readFile` error and silently fell back to an
  empty store. EACCES (permissions drift), EISDIR (path overwritten), EIO (disk
  corruption) became `{policies:{},roles:{},...}`, so `defaultEffect` decided every
  request. With `defaultEffect:'allow'` + `allowFailOpen` this is total silent fail-open
  ("permit everything until restart"); with `'deny'` it is a total silent outage. Only
  `ENOENT` now recovers as empty; everything else throws a wrapped `Error`. **SEC-054.**

#### HIGH (8)

* HTTP adapter followed fetch redirects without re-validation. The `allowedHosts` /
  private-IP guard runs once at construction against `baseUrl`, so a 302 to
  `169.254.169.254` or `10.0.0.5:6379` bypassed it. `_fetchOnce` now passes
  `redirect: 'error'`. **SEC-042.**
* `_emitMetrics` invoked `onMetrics` without a try/catch. A throwing operator hook escaped
  `authorize`'s catch arm and replaced the documented fail-closed deny with a raw error.
  Wrapped via `_safeHookCall`; double-wrapped around `console.error` itself. **SEC-056.**
* `afterEvaluate` / `onDeny` ran inside `authorize`'s main try block; throws caught by the
  evaluation catch silently rewrote an allow verdict into a fail-closed deny. Trailing
  hooks now run outside the evaluation try; throws are routed to `console.error` without
  reshaping the decision. **SEC-055.**
* `engine.permissions()` passed `undefined` for `onPolicyError` to the evaluator, so
  per-policy throws vanished and UI gates silently allowed under `defaultEffect:'allow'`.
  Now forwards the same shim `authorize()` uses. **SEC-057.**
* Redis + Drizzle `getSubjectAttributes` returned `{}` on `JSON.parse` failure or a
  non-object root. ABAC conditions silently flipped to deny with no operator signal. Now
  throws; the engine routes through `onError` + fail-closed deny. **SEC-058.**
* `FileAdapter` JSON parse failure silently populated `_cache = {}`. The next `_flush()`
  overwrote the recoverable-but-corrupt file - **permanent data destruction triggered by a
  single transient parse error.** Now throws "store corrupt - refusing to load; restore
  from backup before retrying". **SEC-064.**
* `can()` / `check()` invoked `this._hooks.onError?.()` unwrapped, so a throwing operator
  `onError` propagated as an unhandled rejection. Now `_safeHookCall`.
* Hono and Next default `getUserId` trusted the spoofable `x-user-id` header.
  **Trivial auth bypass via curl** - `curl -H 'X-User-Id: admin' ...` ran `authorize()`
  under the spoofed identity. Hono: no header fallback. Next: required option, throws at
  construction without it. **SEC-101.**

#### Medium (11)

* The admin write path skipped validation. A hostile admin (or a buggy UI) could persist a
  policy the adapter read-side validator silently drops, leaving a tenant with zero
  policies so `defaultEffect` decided every request. `createAdmin.savePolicy` / `saveRole` /
  `import` now call `validatePolicy` / `validateRole` and throw on error.
* `assertValidOrThrow` echoed attacker-controlled values (`Invalid algorithm "<value>"`).
  An operator who opted into `includeErrorMessage: true` plus HTTP body echo turned the
  admin endpoint into a probe oracle. Now emits `INVALID_ALGORITHM at "algorithm"` -
  structural codes only. **SEC-052.**
* Redis migration versus `revokeRole` race. `_migrateLegacyAssignment`'s SADD-then-SREM let
  the migrator resurrect a just-revoked assignment. `_runSerialised` per-key chain orders
  writes; revoke now SREMs both encodings.
* File `_assertWithinRoot` ran once per adapter, so an attacker swapping the file for a
  symlink after the first I/O steered subsequent writes. The latch is dropped; `realpath`
  re-checks every read and write.
* `_assertWithinRoot` ran outside the load try, so a rejected promise stuck forever in
  `_loadInFlight` and every subsequent `_loadState()` returned the same rejection - a
  permanent admin DoS until process restart. The restructure clears in-flight via `finally`
  on any throw. **SEC-063.**
* Admin lockout: `setSubjectAttributes` called the getter first, and the getter now throws
  on corrupt existing data, so an operator could not overwrite. The setter catches the
  throw, logs, and treats the existing value as `{}`.
* HTTP adapter `getSubjectRoles` forwarded the server response verbatim while other
  adapters enforce unscoped-only. JSDoc now documents the operator's contract
  responsibility.
* The admin router shipped without CSRF guidance, exposing cookie-auth deployments to
  cross-site forms. Optional `csrfCheck` added to all 4 framework adapters; default-on via
  `defaultCsrfCheck`. **CAVEAT-2.**
* `engine.permissions()` had no outer try around
  `Promise.all([_resolveSubject, _loadAllPolicies])`. An adapter rejection crashed the whole
  batch without `onError` + a fail-closed map. Now wrapped in a try; returns an all-deny map
  keyed by every requested check and invokes `onError`.
* `_loadAllPolicies` merger had no in-flight sentinel, so a concurrent invalidate mid-load
  repopulated stale data. Added a `_mergedInFlight` sentinel.
* `getSubjectRoles` semantic drift: file/memory returned unscoped-only; redis/drizzle/prisma
  returned all collapsed. The same subject resolved differently across backends. Aligned all
  to unscoped-only; documented in `Adapter.ISubjectStore`. **SEC-059.**

#### Low (15)

* No way to chart the fail-open rate. Added `failOpen: boolean` to `IMetricsEvent` plus a
  counter to `createMetricsAggregator`. Threaded through `evaluate` / `evaluateFast` via
  optional `IEvalSignals`.
* The Redis invalidator v:1 envelope was unwrapped without HMAC verification when
  `secret: null`, so an attacker chose `instanceId` and silenced legitimate cross-instance
  invalidates. v:1 in unsigned mode is now dropped and warned.
* `permissions()` bypassed `_emitMetrics` entirely, so dashboards charting fail-open missed
  every batch UI gate. Now emits per check.
* File `rootDir` warn fired on every construction, causing log spam that operators filter
  out. A module-global latch fires it once per process.
* The file warn echoed the resolved path, giving a path-existence oracle via log scraping.
  The path is stripped from the message.
* The Redis invalidator's one-shot per-channel warn latch let an attacker burn the first
  warn on a benign reason and then flood silently. Replaced with a 60s rate limit plus
  suppressed-count surfacing. **SEC-032.**
* `errorToAuditString(includeMessage=true)` returned raw `String(err)` for non-`Error`
  throws - an unbounded leak. Now tagged `<non-Error <typeof>>`, capped at 256 chars, with
  a `JSON.stringify` fallback.
* Devtools `localStorage` prefix `__IAM_DEVTOOLS` is now vendor-namespaced
  `__GENTLEDUCK_IAM_DEVTOOLS_V1`.
* `_assertWithinRoot` parent-realpath fallback fired on ANY error, so ELOOP / EACCES
  bypassed the symlink check via a reconstructed path. Now gated on `code === 'ENOENT'`.
* The vanilla client listener-throw was totally silent. `console.error` surfacing added.
* The invalidator dropped shape-mismatched inner payloads without `warnDropOnce`, so
  operators saw nothing on sustained schema drift. Routed through warn.
* Invalidator `publish()` failure was silently swallowed. Added an optional
  `onPublishError(err, channel)` hook plus a rate-limited console fallback.
* `_safeHookCall` / `_emitMetrics` called `console.error` unwrapped; a throwing logger
  (closed stdout, broken pipe) would resurrect the failure. Defensive double-wrap added.
* `dt/lib/flow.ts` listener `catch{}` was silent. `console.error` added.
* Vanilla `extractAction` split the key on `:` naively, so resources containing `:` were
  mis-tokenised. Added `splitPermissionKey`, which honours the `\:` and `\\` escapes from
  `buildPermissionKey`.

#### Info (4)

* `createNextMiddleware` JSDoc example demonstrated the unsafe pattern. Replaced with a
  `getServerSession` example plus a warning.
* Only express had a CSRF regression test; hono/next/nest needed parity. Added.
* **INFO-A** `LRUCache` and Engine `maxPolicies` / `maxRoles` / `adapterTimeoutMs` accepted
  NaN, which silently disabled the bound. `Number.isFinite` is now required.
* **INFO-B** `Explain.IResult.summary` is plain text containing attacker-influenced values;
  consumers rendering it as HTML must escape. JSDoc added.

#### Deployment hardening (CAVEAT-1/2/3)

* **CAVEAT-1**: `createRedisInvalidator({ tenantId })` auto-prefixes the channel
  `'duck-iam:invalidate:tenant:${tenantId}'`. It validates `tenantId` against
  `/^[A-Za-z0-9_-]{1,64}$/` so attacker-controlled tenant slugs cannot inject pub/sub
  wildcards.
* **CAVEAT-2**: Admin routers are default-on CSRF via `defaultCsrfCheck` (a
  `Sec-Fetch-Site` check). `csrfCheck: false` opts out for bearer / mTLS APIs.
* **CAVEAT-3**: `SECURITY.md` adds a 10-section **Deployment Hardening Guide** covering
  identity sourcing, admin CSRF, Redis tenancy, multi-tenant cache scoping,
  `defaultEffect:'allow'` rationale, `explain()` output trust and HTML-escape
  responsibility, the adapter trust model, file `rootDir`, HTTP `allowedHosts`, and
  observability wiring.
* `getCachedRegex` / `getSegments` accept an optional per-instance cache override.
  `clearRegexCache()` / `clearPathCache()` are exported. `Engine.flushSharedCaches()` is
  the ergonomic operator API for multi-tenant deployments.

#### New APIs (additive)

```ts
// Engine: flush process-wide regex + path caches (multi-tenant)
engine.flushSharedCaches()

// Server: built-in Sec-Fetch-Site CSRF predicate
import { defaultCsrfCheck } from '@gentleduck/iam/server/generic'

// Admin router CSRF opt-out
adminRouter(engine, { authorize, csrfCheck: false })

// Redis invalidator: per-tenant channel + publish error hook
createRedisInvalidator({
  client,
  secret,
  tenantId: 'acme', // 'duck-iam:invalidate:tenant:acme'
  onPublishError: (err, channel) => alert(err),
})

// Metrics: fail-open chartable counter
const m = createMetricsAggregator()
m.snapshot().failOpen // subset of allow attributable to defaultEffect fallback

// Shared keys: escape-aware key parser
import { splitPermissionKey } from '@gentleduck/iam/shared/keys'

// Low-level cache controls
import { clearRegexCache } from '@gentleduck/iam/core/conditions'
import { clearPathCache } from '@gentleduck/iam/core/resolve'
```

#### Type additions

* `EngineTypes.IMetricsEvent.failOpen: boolean`
* `Metrics.ISnapshot.failOpen: number`
* `RedisInvalidator.IConfig.tenantId?: string`
* `RedisInvalidator.IConfig.onPublishError?: (err, channel) => void`
* `AdminAudit.IOptions.csrfCheck?: ((req) => boolean) | false`
* `Validate.ValidationCode` extended with `'ERR_REGEX_CATASTROPHIC'`

#### Behaviour changes (intentional defaults)

* `adminRouter` / `bindAdminRouter` / `createAdminHandlers` / `createAdminOperations`
  enforce `defaultCsrfCheck` by default. Pass `csrfCheck: false` to restore the old
  behaviour. Cookie-auth admin UIs get protection without any opt-in. **CAVEAT-2.**
* Hono `accessMiddleware` / `guard` no longer fall back to the `x-user-id` request header.
  The default is now `c.get('userId')`, populated by upstream auth. **SEC-101.**
* Next `withAccess` requires `getUserId` and throws at construction otherwise, with a
  message pointing to cookie/JWT-derived identity. **SEC-101.**
* `FileAdapter.listPolicies` and friends throw on non-ENOENT load failures (was silently
  empty). **SEC-054.**
* `FileAdapter` throws on malformed JSON (was silently empty plus permanent file
  destruction on the next flush). **SEC-064.**
* Redis / Drizzle `getSubjectAttributes` throw on a corrupt blob (was `{}`). **SEC-058.**
* All 5 adapters' `getSubjectRoles` return unscoped-only; `getSubjectScopedRoles` still
  surfaces scoped assignments separately. **SEC-059.**
* Engine ctor rejects NaN/Infinity for `maxPolicies` / `maxRoles` / `adapterTimeoutMs`.
  **INFO-A.**
* `LRUCache` ctor rejects NaN/Infinity for `maxSize` / `ttlMs`. **INFO-A.**

#### Migration

If you used the **Hono header default** for identity:

```ts
// Before
accessMiddleware(engine) // read x-user-id

// After
accessMiddleware(engine, {
  getUserId: (c) => (c.get('userId') as string | undefined) ?? null,
})
// And populate c.set('userId', ...) from upstream auth middleware.
```

If you used the **Next `withAccess` header default**:

```ts
// Before
withAccess(engine, 'read', 'doc', handler)

// After
withAccess(engine, 'read', 'doc', handler, {
  getUserId: async (req) => {
    const session = await getServerSession(req)
    return session?.user?.id ?? null
  },
})
```

If your **admin router** is called server-to-server with bearer tokens (no browser
involved):

```ts
adminRouter(engine, { authorize, csrfCheck: false })
```

Cookie-auth admin UIs need no changes - the default protects them.

If you relied on **`getSubjectAttributes`** returning `{}` for corrupt rows, wire
`setSubjectAttributes` (which now recovers automatically) or add a `try` / `catch` at your
call site.

If you relied on **`FileAdapter`** silently emptying on errors, wire `onPolicyError` and
handle the thrown error from the read path.

#### Tests

* 785 to **836 tests** (+51).
* 5 consecutive clean Med+ rescans: 010, 011, 012, 014, 017, 019, 020, 021 (intermediate
  Med+ found and fixed in 015 and 018).

#### Audit hygiene

* The `audit/` directory is gitignored; per-finding markdown reports and per-cycle
  `rescan-NNN.md` reports are tracked locally in `audit/STATE.md`.

#### Stats

* **0 P0/P1/P2/Low open** at release time.
* **3 Info-tier residuals** (SEC-049 doc IPv6 prefix not publicly routed; SEC-050
  architectural per-instance refactor deferred behind the public `flushSharedCaches`
  helper).

## 2.0.1

### Patch Changes

* 41a45ac: Standardize the README header to match the @duck-md template (centered logo, h1,
  tagline, nav, npm badges). Switch docs links from `iam.gentleduck.org` to path-based
  `gentleduck.org/duck-iam`. No runtime code changes.

## 2.0.0

### Breaking

* **Type API rewrite**: every interface now lives under a per-module namespace
  (`AccessControl`, `Request`, `Adapter`, `Primitives`, `Client`, `DotPath`, `EngineTypes`,
  `Evaluate`, `Explain`, `Validate`, `Config`, `Memory`, `File`) with an `I` prefix.
  Migration: rename `Policy` to `AccessControl.IPolicy`, `Decision` to
  `AccessControl.IDecision`, `AccessRequest` to `Request.IAccessRequest`, and so on.
  Interface names carry the `I` prefix; type aliases stay bare.
* **`Adapter.IAdapter` read methods accept an optional `IReadOptions`** with an
  `AbortSignal`. Backwards-compatible for adapters that ignore the parameter; custom
  adapters should plumb the signal through to their underlying driver where possible.
* **`adminRouter` (Express) signature changed**: it now requires
  `{ authorize: (req) => boolean }` as the second argument. Mounting unguarded admin
  endpoints used to be possible; it is no longer.

### Added

* **`policyCombine` cross-policy combine** (`'and'` / `'allow-overrides'` /
  `'first-applicable'`) configurable via `IConfig.policyCombine`, typed as
  `AccessControl.PolicyCombine`.
* **`hooks.onMetrics`** primitive-only telemetry event fired once per evaluation in both
  modes, payload typed as `EngineTypes.IMetricsEvent`. Zero overhead when unwired.
* **`hooks.onPolicyError`** routed when a single policy throws during evaluation
  (fail-skip, not fail-crash).
* **`engine.preload()`** warms `mergedPolicyCache` so the first request after boot is hot.
* **`engine.healthCheck()`** returns
  `{ ok, adapter, cacheHitRate, adapterLatencyMs, lastError? }` for `/healthz`.
* **`engine.admin.export()` / `engine.admin.import(snapshot, { mode })`** - schema-versioned
  policy + role snapshots; `'merge'` and `'replace'` modes.
* **`engine.dispose()`** releases the cross-instance invalidator subscription.
* **`IConfig.adapterTimeoutMs`** (default 5 s) wraps every adapter read in a timeout that
  triggers `AbortController.abort()`.
* **`IConfig.maxPolicies` / `maxRoles`** load-time caps; over-cap throws and routes to a
  fail-closed deny.
* **`IConfig.allowFailOpen`** required to combine `mode: 'production'` with
  `defaultEffect: 'allow'`.
* **`IConfig.invalidator`** - cross-instance cache-invalidation broadcaster contract.
* **`createRedisInvalidator`** at `@gentleduck/iam/invalidators/redis` - pub/sub helper with
  self-echo filtering.
* **`createMetricsAggregator`** at `@gentleduck/iam/observability/metrics` - p50 / p95 / p99
  over `onMetrics` events.
* **Hono `bindAdminRouter`**, **Next.js `createAdminHandlers`**, **NestJS
  `createAdminOperations`** - all require the `authorize` callback at construction time.
* **HttpAdapter** retry + per-request timeout + circuit-breaker (`retries`, `backoffMs`,
  `timeoutMs`, `circuitBreakerThreshold`, `circuitBreakerCooldownMs`).
* **FileAdapter** at `@gentleduck/iam/adapters/file` - JSON-on-disk store with a pluggable
  `File.IFS` interface.
* **`POLICY_JSON_SCHEMA`** - Draft 2020-12 JSON schema export for non-TS consumers and
  editor tooling.
* **`engine.stats()` / `resetStats()`** - cache hit/miss counters per cache.
* **Validator semantic checks** - emits `UNRESOLVABLE_FIELD`, `UNRESOLVABLE_VALUE`,
  `INHERITANCE_TOO_DEEP`, `BROAD_ALLOW`, and `LIMIT_EXCEEDED` codes.
* **`POLICY_LIMITS`** - DoS bounds (1000 rules per policy, 100 actions per rule, 100
  resources per rule, 1000 action x resource cartesian per rule).
* **`MAX_INHERITANCE_DEPTH = 32`** exported from `core/rbac`. The validator errors on chains
  that exceed it.

### Fixed

* `first-match` combiner now honors `rule.priority` across trace, fast, precomputed, and
  explain paths.
* `engine.explain()` populates `Decision.rule` from the deciding policy's trace.
* `engine.invalidateRoles(roleId?)` is scoped - only subjects holding the named role are
  evicted.
* `setSubjectAttributes` documented contract is now `merge`, matching every built-in
  adapter.
* Single-flight on `loadPolicies` / `loadRoles` / `resolveSubject` / `loadRbacPolicy`
  coalesces concurrent cold-start adapter calls. Sentinel-compare-on-resolve so a pending
  load can't write stale data after an invalidate.
* **NotApplicable semantics**: a policy whose `targets` don't match is skipped by the
  cross-policy combine, not folded as the default effect. The largest correctness fix in
  the project's history.
* Empty RBAC policy is skipped from the per-request policy set so it doesn't contribute a
  default-deny under AND combine.
* Fast path matches colon-prefix actions (`'posts:*'`), dot-hierarchy resources
  (`'dashboard.*'`), and parent-prefix patterns (`'org'` matching `'org:project'`)
  consistently with the trace path.
* `evaluatePolicyFast` returns `boolean | null` (null = NotApplicable). `evaluateFast`
  skips null in every combine mode.
* Engine ctor refuses `mode: 'production'` + `policyCombine: 'first-applicable'`.
* RBAC rule ids are opaque (`__rbac__#N`) - no longer dotted.
* `matches` operator refuses `$`-resolved RHS values (ReDoS via user-controlled regex).
* HttpAdapter `getPolicy` / `getRole` return `null` on 404 instead of throwing.
* Validator depth bound (`MAX_CONDITION_DEPTH=10`) and field-length cap
  (`MAX_FIELD_LENGTH=256`).
* Regex cache is LRU on hit, not FIFO on insert.
* Synthesised RBAC policy is deep-frozen (every rule + conditions tree).
* `Number.isFinite` priority check in the validator.

### Build / package

* `sideEffects: false` in `package.json` for tree-shaking.
* `./adapters/file` subpath export added.

### Tests

* 629 tests across 29 files (up from 309 at 1.7.0).
* A property-based oracle asserts `evaluate` is equivalent to `evaluateFast` over 1000
  random policy sets per `(combine, defaultEffect)` pair.
* Bench harness: `evaluate.bench.ts` + `resolve.bench.ts` + competitor benchmarks.

### Dot-path attribute access (`When` builder)

The `When.attr()` / `When.resourceAttr()` / `When.env()` methods now accept dot-paths into
nested attribute bags. Previously `resourceAttr` and `env` required `keyof` on the raw
object shape (one level deep). Now `'profile.tier'` typechecks against
`{ profile: { tier: string } }` and the value parameter narrows correctly.

New + reorganized in `DotPath`:

* **`SubjectAttrShape<TContext>`** - raw subject attribute bag object.
* **`ResourceAttrShape<TContext>`** - raw resource attribute bag object.
* **`EnvAttrShape<TContext>`** - raw environment object.
* **`SubjectAttrs<TContext>` / `ResourceAttrs<TContext>` / `EnvAttrs<TContext>`** - now
  return dot-path string unions (consistent), not raw objects.
* **`AttrValueAt<T, P>`** - walks a dot-path inside an attribute bag to resolve the leaf
  type.
* **`AttrValue<T, P>`** - rewritten on top of `AttrValueAt`, with an `AttributeValue`
  fallback.
* **`ResolvedResourceAttrPaths<TContext, TResource>`** - dot-paths into per-resource
  attribute narrowing.
* **`ResolvedResourceAttrs`** - now returns the resolved attribute SHAPE (object), paired
  with `ResolvedResourceAttrPaths` for keys.

`When` method signatures dropped `keyof` in favour of these dot-path types. Open attribute
bags (`IAnyAttributes` via a `string` index signature) widen to `string` so the legacy
`keyof IAnyAttributes` behaviour is preserved for `IDefaultContext`. The file is
reorganized into 8 labeled sections (context paths, condition adapters, shape extractors,
attribute paths, per-resource narrowing, value resolution, defaults, internal helpers).

### Module-local namespaces (added 2.0)

Every bare integration-config interface is now wrapped in a type-only namespace; deprecated
bare aliases are kept for back-compat and will be removed in `3.0`.

* `Http.IConfig` (was `IHttpAdapterConfig`) - `@gentleduck/iam/adapters/http`
* `Redis.ILike` + `Redis.IConfig` (was `RedisLike` / `RedisAdapterConfig`) -
  `@gentleduck/iam/adapters/redis`
* `Drizzle.IConfig` (was `IDrizzleConfig`) - `@gentleduck/iam/adapters/drizzle`
* `Express.IOptions` + `Express.IAdminAuthorize` + `Express.IAdminRouterOptions` (were
  `IExpressOptions` / `IAdminAuthorize` / `IAdminRouterOptions`) -
  `@gentleduck/iam/server/express`
* `Hono.IOptions` + `Hono.IAdminAuthorize` + `Hono.IAdminOptions` + `Hono.IRouterLike`
  (were `IHonoOptions` / `IHonoAdminAuthorize` / `IHonoAdminOptions` / `IHonoRouterLike`) -
  `@gentleduck/iam/server/hono`
* `Nest.IAuthorizeMeta` + `Nest.IGuardOptions` + `Nest.IAdminAuthorize` +
  `Nest.IAdminOptions` (were `IAuthorizeMeta` / `INestGuardOptions` / `INestAdminAuthorize` /
  `INestAdminOptions`) - `@gentleduck/iam/server/nest`
* `Next.IWithAccessOptions` + `Next.IMiddlewareOptions` + `Next.IAdminAuthorize` +
  `Next.IAdminOptions` (were `IWithAccessOptions` / `INextMiddlewareOptions` /
  `INextAdminAuthorize` / `INextAdminOptions`) - `@gentleduck/iam/server/next`
* `ReactClient.IContextValue` (was `IContextValue`) - `@gentleduck/iam/client/react`
* `RedisInvalidator.IPubSubLike` + `RedisInvalidator.IConfig` (were `IRedisPubSubLike` /
  `IRedisInvalidatorConfig`) - `@gentleduck/iam/invalidators/redis`
* `Metrics.IAggregator` + `Metrics.ISnapshot` + `Metrics.IConfig` (were
  `IMetricsAggregator` / `IMetricsSnapshot` / `IMetricsAggregatorConfig`) -
  `@gentleduck/iam/observability/metrics`
* `AccessControl.OpFn` (was bare `OpFn` in `conditions.libs.ts`)

Every new namespace is **type-only** (interfaces + type aliases only, no runtime values) so
it compiles to nothing and bundle size stays unchanged. Runtime helpers
(`evaluatePolicyFast`, `ops`, `regexCache`, `MAX_*`, `POLICY_*`, every adapter class, every
server factory, every client factory) remain bare module exports so tree-shaking still
works.

### Stability

`2.0.0` commits to SemVer. The type-API namespace rewrite is load-bearing; no further
public-API renames until `3.0.0`. Patch and minor releases stay non-breaking.

## 2.0.0 - detailed notes

The pre-2.0.0 development entry, kept here because it records the 13-round state of the
work that shipped as 2.0.0. Superseded by the 2.0.0 entry above. It carried an
`Unreleased` heading for several releases, which put an "unreleased" section in the
middle of shipped history; the heading was corrected without touching its content.

### Major refactor: namespaced type API + correctness hardening

A 13-round audit-driven hardening pass plus a full type-API refactor matching the duck-\*
monorepo convention.

**Type API: namespaced + I-prefixed.** Every interface now lives under a per-module
namespace (`AccessControl`, `Request`, `Adapter`, `Primitives`, `Client`, `DotPath`,
`EngineTypes`, `Evaluate`, `Explain`, `Validate`, `Config`, `Memory`, `File`). Interface
names carry an `I` prefix; type aliases stay bare.

**Engine correctness fixes:**

* `first-match` combiner now honors `rule.priority` across trace, fast, precomputed, and
  explain paths.
* `engine.explain()` populates `Decision.rule` from the deciding policy's trace.
* `engine.invalidateRoles(roleId?)` is scoped - only subjects holding the named role are
  evicted.
* `setSubjectAttributes` contract is now `merge`, matching every built-in adapter.
* Single-flight on `loadPolicies` / `loadRoles` / `resolveSubject` coalesces concurrent
  cold-start adapter calls.
* The `invalidate()` family clears in-flight slots + sentinel-compare-on-resolve so a
  pending load can't write stale data.
* **NotApplicable semantics**: a policy whose `targets` don't match is skipped by the
  cross-policy combine, not folded as the default effect. The largest correctness fix in
  the project's history.
* Empty RBAC policy is skipped from the per-request policy set so it doesn't contribute a
  default-deny under AND combine.
* Fast path matches colon-prefix actions (`'posts:*'`), dot-hierarchy resources
  (`'dashboard.*'`), and parent-prefix patterns (`'org'` matching `'org:project'`)
  consistently with the trace path.
* `evaluatePolicyFast` returns `boolean | null` (null = NotApplicable). `evaluateFast`
  skips null in every combine mode.
* Engine ctor refuses `mode: 'production'` + `policyCombine: 'first-applicable'`.
* RBAC rule ids are opaque (`__rbac__#N`) - no longer dotted.

**New APIs:**

* **`AccessControl.PolicyCombine`** - cross-policy combine strategy (`'and'` /
  `'allow-overrides'` / `'first-applicable'`). Configurable via `Engine.policyCombine`.
* **`EngineTypes.IMetricsEvent` + `onMetrics` hook** - primitive-only telemetry payload
  fired once per evaluation in both dev and prod modes. Zero overhead when unwired.
* **`FileAdapter`** at `@gentleduck/iam/adapters/file` - JSON-on-disk store with a pluggable
  `File.IFS` interface.
* **`POLICY_JSON_SCHEMA`** - Draft 2020-12 JSON schema export for non-TS consumers and
  editor tooling.
* **`Engine.stats()` / `resetStats()`** - cache hit/miss counters per cache.
* **Validator semantic checks** - emits `UNRESOLVABLE_FIELD`, `UNRESOLVABLE_VALUE`,
  `INHERITANCE_TOO_DEEP`, `BROAD_ALLOW`, and `LIMIT_EXCEEDED` codes.
* **`POLICY_LIMITS`** - DoS bounds (1000 rules per policy, 100 actions per rule, 100
  resources per rule, 1000 action x resource cartesian per rule).
* **`MAX_INHERITANCE_DEPTH = 32`** exported from `core/rbac`. The validator errors on chains
  that exceed it.

**Build / package:**

* `sideEffects: false` in `package.json` for tree-shaking.
* `./adapters/file` subpath export added.

**Testing:**

* 584 tests across 28 files (up from 309 at 1.7.0).
* A property-based oracle asserts `evaluate` is equivalent to `evaluateFast` over 1000
  random policy sets per `(combine, defaultEffect)` pair.
* Bench harness: `evaluate.bench.ts` + `resolve.bench.ts` + competitor benchmarks.

## 1.7.0

### Minor Changes

* 0e80f84: Add Redis adapter, Drizzle schemas, and full integration test coverage.

  **New: `RedisAdapter`** at `@gentleduck/iam/adapters/redis`. Distributed key/value backend
  with idempotent `assignRole` (set semantics), multi-tenant `keyPrefix`, and a minimal
  `RedisLike` interface that ioredis, node-redis v4+, and Upstash all satisfy directly.

  **New: pre-built Drizzle schemas** at
  `@gentleduck/iam/adapters/drizzle/schema/{pg,mysql,sqlite}`. Drop-in tables for all three
  SQL dialects with the right column types, FK cascade on `roleId`, a unique index on
  `(subjectId, roleId, scope)`, and auto-managed `created_at` / `updated_at`. Generate
  migrations via `drizzle-kit generate`.

  **Test coverage expansion**: every adapter, server middleware, and client integration now
  has dedicated tests. Total test count went from 309 to 498. New test files:

  * `adapters/prisma`, `adapters/drizzle`, `adapters/http`, `adapters/redis`
  * `server/express`, `server/hono`, `server/nest`, `server/next`
  * `client/react`, `client/vue`

  **Optional peer deps added**: `drizzle-orm`, `ioredis`, `redis` (all optional).

## 1.6.2

### Patch Changes

* 918b34c: Strip `workspace:*` and `catalog:` protocol tokens from `devDependencies` /
  `dependencies` / `peerDependencies` of every public package before `changeset publish`.
  Previously published artifacts leaked these tokens into npm metadata, which broke strict
  resolvers (bun, deno) for downstream consumers. Adds `scripts/clean-publish.ts` and wires
  it into the root `release` script with a `git checkout` restore step so source remains
  workspace-friendly.

## 1.6.1

### Patch Changes

* Add package README for npm page. Remove special characters from all documentation.

## 1.6.0

### Minor Changes

* Performance: `evaluatePolicyFast` now 2x vs CASL (was 5.2x). Inlined hot path, added a
  pre-computed results cache for unconditional rules, fixed an empty conditions bug, added
  a combined action+resource index.

## 1.5.0

### Minor Changes

* e682b61: Add optional scope parameter to `grant()` for permission-level scoping

  The `grant()` method now accepts an optional third `scope` argument:
  `.grant('update', 'post', 'org-1')`. This enables permission-level
  scoping directly without needing `grantScoped()`. The existing
  `grantScoped(scope, action, resource)` method remains available.

  Also fixed incorrect `first-applicable` references in JSDoc comments
  to use the correct algorithm names `first-match` and `highest-priority`.

## 1.4.0

### Minor Changes

* 72c449b: Add `FlexibleDollarPaths` for $-value autocomplete and fix `AttrValue` for
  optional properties

  * `FlexibleDollarPaths<TContext>` added directly to method value signatures so the IDE
    shows `$`-prefixed autocomplete (e.g. `$subject.id`) even without a custom context
  * `AttrValue` now strips `undefined` from optional properties - `yearsExperience?: number`
    correctly resolves to `number` instead of falling back to `AttributeValue`
  * `StringConditionValue` no longer includes `(string & {})` internally - the flexible
    string fallback is handled at the method signature level via `FlexibleDollarPaths`

## 1.3.2

### Patch Changes

* 2dd9f8b: feat: `FlexibleDotPaths` for `DefaultContext` autocomplete and strict
  `ConditionValue` type safety

  * `DotPaths` now bails to `never` (not `string`) for string-indexed types, preventing
    union pollution that killed IDE autocomplete.
  * New `FlexibleDotPaths<T>` detects open-ended attribute bags (like `DefaultContext`) and
    adds `(string & {})` so known structural paths autocomplete while arbitrary strings
    are still accepted. Fully typed contexts remain strict.
  * `ConditionValue` correctly restricts non-string value types: `env('hour', 'lt', '')`
    now errors when `hour` is `number`, instead of accepting any `AttributeValue`.

## 1.3.1

### Patch Changes

* b62bb5b: fix: prevent `DotPaths` from recursing into array methods and functions

  `DotPaths` now treats arrays as leaf paths and skips function-valued properties,
  so autocomplete only shows real data properties instead of array methods like
  `length`, `push`, `toString`, etc.

## 1.3.0

### Minor Changes

* Add `DollarPaths` type for $-variable autocomplete in conditions, refactor core into
  modular folders, and add JSDoc and inline FAQs to documentation

## 1.2.0

### Minor Changes

* 7fe860f: Add `TContext` type parameter for typed dot-path intellisense and per-resource
  attribute narrowing. Split `types.ts` into a modular `types/` directory. Add JSDoc across
  all source files.

## 1.1.2

### Patch Changes

* 66608fe: Add `publishConfig` with public access for scoped npm package.

## 1.1.1

### Patch Changes

* 37339e8: Fix release workflow to skip redundant CI checks during publish.

## 1.1.0

### Minor Changes

* 29ed55d: Initial release of `@gentleduck/iam`: identity and access management utilities.

## See also

* [Installation](/duck-iam/installation) - install the current version and its peer dependencies.
* [Production checklist](/duck-iam/guides/production) - the hardening items several of these releases introduced.
* [Troubleshooting](/duck-iam/guides/troubleshooting) - symptoms caused by behaviour that changed in 2.1.0 and 3.0.0.