# Changelog (/docs/reference/changelog)

> Release notes for the Better IAM packages and documentation site, every notable change in each version, newest first.



<!-- Generated from CHANGELOG.md by apps/docs/scripts/generate.mjs. Do not edit. -->

# Changelog [#changelog]

## 0.3.1 (2026-10-07) [#031-2026-10-07]

Security fixes for security clearances (only deployments that set the `clearances` option are affected; nothing else changes). An adversarial review of 0.3.0 found ways for labels to stop applying: moving a tenant to a parent without a scheme switched enforcement off for its labels (now refused, and labels without a scheme in force refuse everyone), deleting a managed resource and registering it again under another parent dropped the label it inherited (now kept), and labels on AI models in an organization did not reach its projects. SSH host labels now reach logins, verifiable-credential offers check the holder's clearance, scheme edits can no longer re-rank a level, and label types are validated. Upgrade if you enabled clearances.

* Security clearances: labels no longer slip away from what they protect (only with the `clearances` option).
  * `tenants.reparent` refuses (`RESOURCE_IN_USE`) a move that would change the classification scheme in force for a subtree holding labels or live (interim, active, suspended) clearances; before, moving a project below an organization without a scheme dropped every label in it, so root and members could read up. A label left in a tenant no scheme applies to now refuses everyone, root included, instead of being ignored.
  * `resources.delete` keeps what a registration inherited from its managed parents as its own label (audited as `classification:label` with `reason: 'resource-deleted'`), so deleting it and registering it again under another parent no longer declassifies it; deleting is refused while that inherited label cannot be read under the scheme in force.
  * An administrator's verifiable credential offer (`iam:vc:issue`) needs the holder to dominate the credential type's label when it is offered (audited `ACCESS_DENIED`), when the wallet redeems it, and in every `verifiableCredentials.sweep()`, which now revokes any credential (`access-changed`) whose holder no longer does, not only self-service ones.
  * A label on `ssh-host/{host}` applies to every login of the host (`ssh-login/{host}/{login}`, those added later included), whatever its `inheritToChildren`, so certificates, `myAccess` and the SSH sweep follow it; plans for `ssh-login` answer `UNSUPPORTED_FILTER` while a host is labeled.
  * Labels on `model/{name}` in any tenant above the deciding one apply too, so a model labeled where it is defined is refused in the projects that inherit it (inference calls, `listMine`, plans).
  * An application action on `iam/{type}/{id}` (plugin endpoints included) also applies what `resolveResource` answers for `{type}/{id}` itself, its `classification` and the parent it reports, and refuses when that resolver fails, so the alias never reads lower.
  * Label types must be resource type names (no `/`, `INVALID_INPUT` otherwise), so `{type}/{id}` keys can no longer collide and block labeling a resource whose id holds a slash.
  * `updateScheme` never re-ranks a level, in use or not (labels your resolver asserts and a `defaultLabel` may use any), and a removed level comes back only at its former rank (`RESOURCE_IN_USE`); the scheme remembers removed levels (at most 500).
  * `requireLabels: ['*']` no longer covers the built-in `model-tool` type, which refused every `inference:use-tool` decision.
  * Policy lint treats the `principal.clearance*` keys as server keys only when the deployment enables clearances, as before the option existed.
  * Decisions read the scheme once: `resolve` takes the scheme the prepared decision already loaded (`preparedLabeling`), and the root check is not repeated.
  * Docs: `declassify` of a label written under another scheme still needs a clearance that dominates it as the scheme in force reads it; only a label naming what the scheme does not define may be repaired by anyone with the permission.

## 0.3.0 (2026-10-07) [#030-2026-10-07]

Third release: 24 packages. Three new access-governance domains: B2B guest collaboration (outside people invited with a sponsor, an end date and cross-tenant admission rules), license management (products, seat pools, assignments, seniority and waiting lists, and `principal.licenses` / `identity.licenses` for policies and birthright packages), and security clearances with mandatory access control (classification schemes, adjudicated clearances and compartment read-ins, IAM-held resource labels, and a "no read up" check on every decision path, off unless the new `clearances` option is set). Threat detection gains list filters and a classified-access rule, and installing `better-iam` no longer installs every framework. Breaking: `guest`, `guestSponsorId`, `homeTenantId` and `licenses` are reserved principal names; with `clearances` enabled, more names are reserved and root administrators no longer read up on labeled resources.

* Installing `better-iam` no longer installs every framework: `@better-iam/next`, `@better-iam/react`, `@better-iam/vue`, `@better-iam/svelte`, `@better-iam/nestjs` and `@better-iam/react-router` mark their framework peers optional in `peerDependenciesMeta` (the umbrella already did), so npm stops auto-installing Next.js, React, Vue, Svelte, NestJS and React Router for apps that use none of them. Apps using a binding keep their framework installed as before.
* Security clearances on the decision path (only with the new `clearances` option; without it nothing changes): the first step of every evaluation is the mandatory check, keyed on the evaluated action (never `iam:*`), so no grant, relationship, ownership or delegation confirmation opens a labeled resource above the session's clearance, and the refusal (`CLEARANCE_REQUIRED`, masked as `ACCESS_DENIED` wherever reasons are masked) uses nothing up. `resolve` attaches every non-platform resource's effective label as the server-owned `ResolvedResource.classification` (`null` when unlabeled); `resolveResource` may return a `classification` of its own, which can only raise the label. Root keeps its override except on labeled application resources (`clearances.appliesToRoot`, default true): its prepared decision then becomes an evaluator, recognized everywhere through the new `isRootOverride` helper (usage, audit, cross-tenant SSH certificates and their sweep, credential requests), with `decideOn` as the one way to apply a prepared decision. Policies see `principal.clearanceLevel`, `principal.clearanceRank`, `principal.clearanceStatus`, `principal.clearanceCompartments` and `principal.clearanceCitizenship`, and `resource.classification`, `resource.classificationRank`, `resource.compartments`, `resource.noforn` and `resource.releasableTo` (written after the resource's attributes; policy lint knows them, the resource keys through the new `PolicyLintContext.clearances`; `policies.test` defaults them to an uncleared person and an unlabeled resource). `listAccessible` reads each type's labels in one batch, data filter plans (root's too) AND a clearance filter in (core `PlanInput.mandatory`) and refuse conditions on the derived resource keys (`PlanInput.derivedResourceKeys`, `UNSUPPORTED_FILTER`), SSH logins and hosts and credential types carry their labels like resolved resources, `accessPaths.find` offers no path for a clearance refusal, and a clearance refusal is audited with `{ mandatory: 'clearance' }`. Enforced access invariants are re-checked around `iam:clearances:adjudicate`, `iam:classifications:manage` and `iam:classifications:declassify`. `AgentDecisionScope` gains `agentIds` (the acting agent and every agent of a hand-off chain), and `isServerOwnedKey(key, clearances)` treats the clearance keys as the server's only when the option is set.
  * Breaking only with the option: `clearanceLevel`, `clearanceRank`, `clearanceStatus`, `clearanceCompartments` and `clearanceCitizenship` become reserved principal names and `classification`, `classificationRank`, `compartments`, `noforn` and `releasableTo` reserved resource attribute names (`INVALID_CONFIG` at startup, `INVALID_INPUT` for tenant-registered types), and values of those keys that `resolveContext` or a plugin supplies are removed before the server sets its own.
  * Breaking only with the option: root administrators no longer read up. With `clearances.appliesToRoot` left at its default (true), root is refused (`CLEARANCE_REQUIRED`, `ACCESS_DENIED` to callers) on every labeled application resource above its own clearance, in `authorize`, `listAccessible` and data filter plans alike; a clearance is adjudicated in its holder's own tenant, so root holds one only under a scheme the root tenant defines, and under an organization's scheme it reads none of that organization's labeled resources. Every `iam:*` action keeps the override, cross-tenant SSH stays `ROOT_SSH_RESTRICTED`, and `appliesToRoot: false` restores the old override. In a tenant whose scheme requires labels, a plugin endpoint with its own (non-`iam:`) action on an `iam/{type}/{id}` record of a required type is refused until that record is labeled (or the scheme sets a `defaultLabel`), as `authorize` is.
  * Fail closed at the edges: a stored label written under another scheme (a tenant moved below an ancestor that defines its own) refuses every party, and only `declassify` replaces it; data filter plans for application (unmanaged) types answer `UNSUPPORTED_FILTER` while any label in the tenant passes down to children, since the parents a resolver reports are invisible to a filter; an application action on `iam/{type}/{id}` gets the answer the named resource gets, a missing required label included; and `revoke` and `debrief`, which share `iam:clearances:adjudicate` with grants, are never blocked by an enforced "expect allow" access invariant ("expect deny" ones still apply).
  * Enforcement review fixes: `policies.effectiveActions`, `policies.whoCan`, access invariants and `impact.preview` on `iam/{type}/{id}` with an application action, and plugin endpoints with their own action on a record they name, now apply the named resource's label as `authorize` does (they resolved it without one and reported, or allowed, access the label refuses; only a platform resource resolved for an `iam:` action goes unlabeled), and the alias also joins what was resolved for it (a resolver's own `classification`, the parent its attributes report), which it ignored; and a resource inherits the labels of both its registration's parent and the parent its attributes report, so registering a tenant type named like an application type (tenant-defined mode) and a record of a resource no longer drops what the application's reported parent passes down, and such a type is planned as an application type for the application's actions (`UNSUPPORTED_FILTER` while labels pass down, or when the scheme requires labels on it).
* Security clearances, the domain and API (new `clearances` option, `{ appliesToRoot?: boolean }`, validated at construction; without it the new `clearances` API group answers `FEATURE_DISABLED` (403) for every method and nothing is read): a tenant defines one classification scheme (`defineScheme` from the `us`, `uk`, `nato` or `corporate` template or a definition, `updateScheme`, `getScheme`, `templates`) with ranked levels, compartments, owner countries, the NOFORN and REL TO caveats, the resource types that must carry a label (`requireLabels`, `*` for every application type) and an optional `defaultLabel`, a `guestCeiling`, `interimAllowed` and the `adjudication` mode. The scheme in force is the one defined closest to the root, so defining one below or above another is `CONFLICT`; new levels go above every kept level and a level or compartment that a live clearance or a label uses cannot be removed or re-ranked (`RESOURCE_IN_USE`). Officers `grant`, `update`, `readIn` (optionally backed by an NDA agreement: the compartment counts only while the person's acceptance of its current version is current), `debrief`, `suspend` (`iam:clearances:suspend`, no recent sign-in, for incidents), `reinstate` and `revoke` one clearance per identity: level, adjudicated citizenship (the only source of NOFORN and REL TO decisions, never identity attributes), interim status, investigation, reinvestigation date and end; `get`, `list`, `explain` (officers only: which dimension a person fails on a resource) and `mine` (no permission). Nobody adjudicates their own clearance, owners and root included (an audited `ACCESS_DENIED`); guests never adjudicate, are held to the guest ceiling and are never read into compartments; under `within-own` adjudication an officer grants only levels and compartments their own clearance holds, except that an owner or root may bootstrap one nobody in the scheme's subtree holds yet (audited with `bootstrap: true`). Status, expiry, the guest ceiling and NDA acceptance are evaluated at decision time. Labels are IAM-held and outlive their resources: `label` only raises (`iam:classifications:label`), `declassify` lowers or removes (`iam:classifications:declassify`, recent sign-in, and only someone whose clearance dominates the current label), `getLabel` and `listLabels`; a label marked `inheritToChildren` applies to every managed descendant (16 levels at most, fail closed beyond). Offboarding terminates the leaver's clearance and debriefs every compartment (`clearancesTerminated`, reported when non-zero), deletion keeps it as terminated history, and the instance job `iam.clearances.sendReminders()` (daily) emails the scheme tenant's owners and the scheme's `notify.emails` about reinvestigations due and clearances ending within 60 days (template `clearance-reminder`); suspensions, reinstatements and revocations email the person (template `clearance-status`, unless `notifyPerson: false`). Both templates name the level only, never compartments; link builder `links.clearances`. Permissions `iam:clearances:read`, `iam:clearances:adjudicate`, `iam:clearances:suspend`, `iam:classifications:manage`, `iam:classifications:label` and `iam:classifications:declassify` on `iam/clearances[/{identityId}]`, `iam/classifications/scheme` and `iam/classifications/labels[/{type}/{id}]`; audited as `clearance:grant`, `clearance:update`, `clearance:read-in`, `clearance:debrief`, `clearance:suspend`, `clearance:reinstate`, `clearance:revoke`, `clearance:terminate`, `clearance:reminder`, `classification:scheme-define`, `classification:scheme-update`, `classification:label` and `classification:declassify`, with level and compartment ids only. New tenant-scoped collections `classificationSchemes`, `clearances` and `resourceLabels`, purged with their tenant; exports `attachLabel`, `clearanceScope`, `labelsForType`, `effectiveScheme`, `labelKey`, `clearanceCollections`, `clearanceLimits` and the module's types.
  * Review fixes: under `within-own` adjudication, `reinstate` also requires the officer to hold every compartment the suspended clearance is read into (reinstating gives them back), so an officer outside a compartment can no longer restore someone's access to it; and a clearance revoked for cause stays `revoked` through offboarding (no `clearancesTerminated`) and identity deletion, instead of being rewritten as `terminated` and dropping out of `list({ status: 'revoked' })`.
  * Console (the console's configuration enables `clearances`): Security › Clearances shows your own clearance and the register (level, status and what decisions count, read-in count, reinvestigation and end dates; filtered by status, level or ending soon, with tiles for clearances in force, suspended, not counted, ending and reinvestigations due) and grants clearances; each person's clearance page shows the record, read-ins with their NDA and whether they count, and the history from the audit trail, and grants, changes (only the fields edited are sent), reads in and debriefs, suspends (optionally linked to a threat incident, without telling the person), reinstates and revokes, asking for the password again where a recent sign-in is required. Security › Classification defines the scheme from a template or a definition, edits its settings, default label, levels and compartments, lists labels with raise and declassify forms, labels a resource, looks up a resource's own and inherited label, and gives officers the `explain` tool. Member pages gain a Clearance card. Every form is offered only to those `authorizeMany` allows, and compartment names reach only holders of `iam:clearances:read` (others see compartment ids). The console runs `iam.clearances.sendReminders` daily.
  * Docs: new guide `docs/security-clearances.md` (the model and the Bell-LaPadula simple security property, what is and is not enforced, turning it on and what breaks, schemes and templates, clearances and adjudication with the bootstrap, labels and inheritance, parties per session kind, root, policy keys, listings, data filters and reviews, `explain`, incidents, reminders, emails and limits), linked from the README; `docs/events.md` gains Security clearance events (every `clearance:*` and `classification:*` action and the `{ mandatory: 'clearance' }` refusal marker), `docs/policies.md` the clearance and classification context keys and the check in the evaluation order, `docs/data-filtering.md` the clearance filter, and `docs/security.md` the root note. The docs site gains the `clearances` API reference (`descriptions/api/clearances.md`, listed under Access model) and the `iam.clearances.sendReminders` job, `CLEARANCE_REQUIRED` with the error codes and in the decision reason tables, the clearance step in the decision order, and clearance notes on `authorize`, `listAccessible`, `planResources`, the `filters` group, `whoCan`, `FEATURE_DISABLED`, `RESOURCE_IN_USE` and `UNSUPPORTED_FILTER`. `docs/api-reference.md` is regenerated (adds the `clearances` and `licenses` groups).
  * Threat detection: a new rule `classified-access-attempts` (high, MITRE ATT\&CK T1213, on by default: 3 within an hour, tunable from 1 to 1000 within one minute to seven days) counts clearance refusals (`deny` events marked `{ mandatory: 'clearance' }`) per responsible actor, the administrator behind a "view as" session included, and reports the actions and how many resources, never a label (`threats.rules` lists 22 rules). A "view as" refusal caused by the administrator's own clearance is now `CLEARANCE_REQUIRED` rather than `IMPERSONATOR_DENIED`, so it is audited with the marker and counted against the administrator. A new response action `suspend-clearance` (`threats.respond` and playbooks) suspends the identity's clearance until an officer's `clearances.reinstate` (`threats.release` never touches it). By hand it also needs `iam:clearances:suspend` on `iam/clearances/{identityId}` (checked before anything about the clearance is read; never while impersonating or as a guest); playbooks skip owners and root administrators (`protected`), people without a clearance (`no-clearance`) and suspended ones (`already-applied`); and writing a playbook with it, adding it to one, changing such a playbook's trigger or switching one on needs `iam:clearances:suspend` on `iam/clearances`. Without the option it is `FEATURE_DISABLED` (a stored playbook's is skipped as `feature-disabled`). Audited as `clearance:suspend` and `threat:suspend-clearance`; the person is not emailed. The console offers it on incidents and in playbooks to people who may suspend clearances.
* Threat detection API: `threats.listIncidents` takes `status` as one status or a list (`['open', 'investigating']` lists every active incident in one call, ordered and paged together); `threats.timeline` takes `until` (epoch milliseconds, after `since`; `since` then defaults to seven days before it) to bound the window; detections and incidents returned by the `threats` group give an identity subject of the tenant its `kind` (`user`, `service`, or `agent`, looked up when read and never stored; new types `ThreatSubjectView`, `ThreatDetectionView`, `ThreatIncidentView`); and `threats.reportSuspicious` and `threats.configure` name people in their detections as the detection rules do, `Carol (carol@acme.test)` rather than `Carol`. The console's threats overview reads active incidents with one call (and offers an "open or investigating" filter on the incidents page), links agent subjects to agent pages, and bounds an incident's identity timeline to the incident's window.
* B2B guest collaboration: a new `guests` API group invites people from outside the organization (or from another tenant) as guests. A guest is an ordinary person of the host tenant with a server-owned `Identity.guest` marker (`GuestProfile`: sponsor, since, home tenant and domain), so bindings, groups, packages, separation of duties, invariants, certifications and offboarding apply unchanged. `invite` emails a personal invitation (template `guest-invitation`, link builder `links.guestInvitation`) naming a sponsor (the caller by default: an active member who is not a guest), optional roles, groups and access packages authorized like `identities.invite`, how long access lasts (`accessDays`, default 90, at most 365) and how long the invitation stays valid (1 to 30 days, default 14); one pending invitation per address. The public `redeem` (rate limited, sets the session cookie) re-checks everything, creates the guest with a password and a verified email, grants the roles, groups and packages under the inviter's authority until the guest's access ends, applies separation-of-duties rules and enforced invariants, runs birthright rules, and signs the guest in. `listInvitations`, `revokeInvitation`, `resendInvitation` (sends only an invitation that can still be redeemed: the settings, the sponsor and the inviter's rights are checked again), `list` (by sponsor, status, or access ending soon, with the last sign-in), `get`, `attest` (the sponsor renews access without any permission, others need `iam:guests:manage`; guests renew nobody's access; what the invitation granted is renewed with it), `setSponsor`, `remove` (disables the guest and removes everything they hold), `convertToMember`, `mine` (the guests a person sponsors), `getSettings` and `configure` (unknown settings are refused). Cross-tenant access settings per tenant: inbound (`allowGuests`, allowed and blocked domains, partner organizations) and outbound (`allowGuestInvitations`, partners) rules, where an address on a domain another tenant verified is that tenant's member and needs both sides to allow it; the default access and review periods; and a `guestBoundary` ceiling on every guest's decisions. Policies see `principal.guest` (always), `principal.guestSponsorId` and `principal.homeTenantId`; birthright package rules gain `identity.guest`, and rules (also onboarding and workflow scopes) that never test it no longer match guests. The instance jobs `iam.guests.sendReviewReminders()` (template `guest-review`; each review date and each access end is reminded once) and `iam.guests.sweep()` (lapsed invitations, ended guests, missing sponsors, template `guest-sponsor-missing` to owners) run hourly. Offboarding hands a leaver's guests to the successor (`guestsReassigned`, `guestsUnsponsored`, reported when non-zero), deletion flags them, a guest's email address cannot be changed (by administrators or through the guest's own `auth.requestEmailChange`), and redeemed, revoked and expired invitations are swept 90 days after they lapsed. Permissions `iam:guests:read`, `iam:guests:invite`, `iam:guests:manage` and `iam:guests:settings` on `iam/guests`, `iam/guests/{identityId}`, `iam/guests/invitations[/{id}]` and `iam/guests/settings`; audited as `guest:invite`, `guest:invitation-revoke`, `guest:invitation-resend`, `guest:redeem`, `guest:attest`, `guest:sponsor-change`, `guest:sponsor-missing`, `guest:remove`, `guest:convert`, `guest:settings`, `guest:expire` and `guest:review-reminder`. New tenant-scoped collections `guestInvitations`, `guestAccounts` and `crossTenantAccess`, purged with their tenant; new types `GuestProfile`, `GuestView`, `GuestPage`, `GuestInvitationView`, `GuestInviteInput`, `GuestSettings`, `GuestSettingsInput`, `GuestSettingsView`, `GuestRemoval`, `GuestReminderResult`, `GuestSweepResult` and `IamGuests`. New error: `GUEST_NOT_ALLOWED`. Guide: `docs/guests.md`.
  * Breaking: `guest`, `guestSponsorId` and `homeTenantId` are reserved principal names, so a deployment that declares an identity attribute with one of those names fails at startup with `INVALID_CONFIG`. Values of `principal.guest`, `principal.guestSponsorId` and `principal.homeTenantId` that `resolveContext` or a plugin supplies are now removed before the server sets its own.
  * Console: Directory › Guests lists the guests with their sponsor, access end, next review and last sign-in (filtered by sponsor, status or access ending soon, with tiles for guests needing a sponsor, reviews due and access ending), shows each person the guests they sponsor with one-click renewal, and invites guests with roles, groups and packages; a guest's page renews access, changes the sponsor, makes the guest a member or removes them, and shows what the invitation granted. Guest invitations can be filtered, sent again or revoked, and Directory › Cross-tenant access edits the inbound and outbound rules, access and review periods and the guest boundary. Member pages show a Guest badge and a Guest card with the sponsor. Invitees accept at `/cloud/join?kind=guest`. The console runs `iam.guests.sweep` and `iam.guests.sendReviewReminders` hourly.
  * Review fixes: an invited address's domain is compared, stored and emailed in canonical form (lowercase, punycode, no trailing dot), so `gil@globex.test.` or an internationalized spelling no longer slips past verified domains, blocked domains, partner entries and the home organization's outbound settings, at invitation or redemption (a domain that is not a DNS name is `INVALID_INPUT`). Naming someone else as the sponsor needs `iam:guests:manage`, and an assumed role cannot invite (redemption could never re-check its rights). Guests never make anyone a member or choose a sponsor, themselves included. Renewing a guest who owns the organization, or clearing their end with `convertToMember`, needs an owner or root caller, as `identities.update` does. `remove` releases the guest's license seats at once. Redemption counts the client's budget before the organization-wide one. An overdue review is reminded once also for a guest without an end. Delegation tokens for a guest's delegations stay within the guest boundary, when issued and on live verification. The workflow `disable` step revokes the guest invitations a person sent or sponsors, as `identities.setStatus` does. Inviting a former guest's address explains that their disabled account must be deleted first.
* License management: a new `licenses` API group tracks who holds a seat of what you sell or buy. Products (`createProduct`, `updateProduct`, `retireProduct`, `listProducts`, `getProduct`) have a permanent key, a name, a description and up to 50 feature keys; the root tenant defines platform products that every tenant sees (root administrators only), and any other tenant defines products for itself and the tenants below it, never repeating a key an enclosing tenant defines (a later enclosing key marks the lower product `shadowed`). Retiring a product releases its seats in every tenant and keeps its pools and assignments as read-only history. Pools (`addPool`, `updatePool`, `removePool`, `listPools`) give a tenant seats of a product, optionally from `startsAt` until `endsAt` and referencing the billing subscription that paid for them; capacity is granted by the product's owner (root for platform products, a tenant's managers for its own products, to itself or a tenant below it) and never by the tenant that consumes it, and ended pools are kept 400 days as purchase history. Products are assigned to people of any kind and to groups (`assign`, `assignMany` for up to 100 people, `unassign`, `listAssignments`; a team through its backing group). Seats are materialized per person (`listSeats`): the claimants (direct assignees and live members of assigned groups, active and unexpired identities, guests included) are ranked by seniority, the first `capacity` hold an active seat and the rest wait in order with their `position`, and a capacity cut moves the newest active seats back to the waiting list instead of failing. Seats move in the same transaction as assignments, pool changes, retirement, group membership changes from every writer (groups, teams and team sync, SCIM, packages), group deletion and identity deletion, and right after disabling or offboarding a person. `usage` reports capacity, active, waiting, available and reclaimable seats per product; `mine` gives people their own licenses, waiting places and feature keys without a permission (not audited); `getSettings` and `configure` turn on inactive-seat reclaim (`reclaimAfterDays`, 7 to 365) and waiting-list email (`notifyWaiting`, templates `license-waiting` and `license-activated`). What a license unlocks uses existing governance: the policy context key `principal.licenses` (the keys of the products the principal holds an active seat for in the decision's tenant, read only when a document names it; assumed roles hold none; simulations, `whoCan` and access invariants see the real seats; `policies.test` defaults it to `[]`; policy lint knows it), the birthright package rule key `identity.licenses` (product keys, checked when a package is saved and by both `config.plan` and `config.apply`; seats held only through memberships a package created do not count; `autoAssign.warnings` flags rules that test it; a rule whose product disappears is suspended), and `iam.licenses.features(identityId, tenantId)` / `iam.licenses.products(identityId, tenantId)` for application code. Scheduler jobs `iam.licenses.reconcile({ tenantId? })` (hourly: pool terms, lapsed memberships and accounts, repairs; birthright packages follow for the people whose seats changed) and `iam.licenses.reclaim({ tenantId? })` (daily: removes the direct assignments of people with no sign-in or credential use for `reclaimAfterDays`). Assignment and pool changes run the tenant's enforced access invariants, and capacity granted from an enclosing tenant runs the receiving tenant's as well (`INVARIANT_VIOLATION`, nothing saved). Permissions `iam:licenses:read`, `iam:licenses:manage` and `iam:licenses:assign` on `iam/licenses/products[/{id}]`, `iam/licenses/pools[/{id}]`, `iam/licenses/assignments`, `iam/licenses/seats` and `iam/licenses/settings` (creating and retiring products need a recent sign-in); audited as `license:product-create`, `license:product-update`, `license:product-retire`, `license:pool-add`, `license:pool-update`, `license:pool-remove`, `license:assign`, `license:unassign`, `license:seat-activate`, `license:seat-waiting`, `license:seat-release`, `license:reclaim` and `license:settings`. Collections `licenseProducts`, `licensePools`, `licenseAssignments`, `licenseSeats` and `licenseSettings`, removed with their tenant; `iam.sweepExpired()` removes pools 400 days after they ended. Exports `licenseCapacity`, `licenseCollections`, `licenseContextKey`, `licenseKey`, `liveLicensePool`, `maxLicenseProductsPerTenant`, `maxLicenseFeatureKeys`, `maxLicensePoolsPerProduct`, `maxLicensePoolQuantity`, `maxLicenseAssignmentsPerProduct` and the module's types. Guide: `docs/licenses.md`.
  * Breaking: `licenses` is a reserved principal name, so a deployment that declares an identity attribute called `licenses` fails at startup with `INVALID_CONFIG`, and `principal.licenses` values supplied by `resolveContext` or a plugin are removed before the server sets its own.
  * Console: Organization › Licenses shows seats in use per product, every product the organization can use, the waiting list, and the reclaim and waiting-list email settings, and lets managers define products; each product's page shows its pools (seats granted to the organization or its projects, changed and removed there for the organization's own products), the people and groups given it (one, several at once, or a group), the active seats with who has been inactive, and the waiting list in order. Member pages gain a Licenses card that gives or takes back a product. The console runs `iam.licenses.reconcile` hourly and `iam.licenses.reclaim` daily.
  * Review fixes: when an enclosing tenant (the platform included) defines a key a tenant below already uses, the enclosing product wins: the lower product is `shadowed` and its seats no longer grant the key or its feature keys (`principal.licenses`, `identity.licenses`, `iam.licenses.products`/`features`, `licenses.mine`), so no tenant can claim a key such as `enterprise` first and mint seats that count as the platform's. A tenant moved out from under a product's definer (`tenants.reparent`) stops holding it at once, the next `iam.licenses.reconcile()` releases its seats (assignments stay), and the definer can still `removePool` the capacity it granted there. Changing the members of a group a product is assigned to (or of a group that team sync copies into a licensed team) through `groups.addMember`, `addMembers` and `updateMember`, packages, workflows or configuration sync needs `iam:licenses:assign` as well, and the `identity.licenses` rule warning now names team maintainers and directory sync as the other ways in. Seats now follow the backing groups team sync fills (the synced teams' and those above them), accepted member invitations into licensed groups, SCIM deactivation, reactivation and DELETE of a person (new optional SCIM host callback `identityStatusChanged`), and SCIM DELETE of a group, which also removes the group's license assignments (callback `groupDeleted`), all in the same transaction. `groups.addMembers`, offboarding and configuration sync reconcile each product once per batch instead of once per membership. The reclaim job leaves the assignments of retired products alone.
  * Feature keys and seat claims: `iam.licenses.features(identityId, tenantId, { trustedTenantId? })` counts only products defined by the trusted tenant or a tenant above it, the root tenant (platform products) by default, so a tenant can no longer mint a paid feature by defining a product of its own that lists its key. `licenses.mine` marks each license `platform` and `definedBy`; its top-level `featureKeys` now holds platform products' keys only, and `allFeatureKeys` (new) every product's. Every other writer that puts people into a licensed group now needs `iam:licenses:assign` too (`ACCESS_DENIED`, audited as a denial): `identities.createMany` and `identities.invite` with `groupIds` (re-checked at acceptance, and by `identities.resendInvitation`, which now refuses with `INVITATION_INVALID` to send an invitation acceptance would refuse), guest invitations (re-checked at redemption), `guests.convertToMember` with `clearExpiry` for a licensed group the guest would stay in for good, access packages (assign, approve, extend, saving a rule; an automatic rule whose owner lacks it has its additions suspended as an issue), onboarding completion groups, team administrators (`iam:teams:update`) on a licensed team or one below it, and configuration sync. Team maintainers, directory sync (SCIM) and renewing a guest (`guests.attest`, by the sponsor or an administrator) stay exempt by design; a renewal now lengthens only the memberships the invitation created at redemption (`GuestGrants.packageMembershipIds`), so a group a package gained since, or a membership the guest holds by hand or through another package, keeps its end.

## 0.2.0 (2026-09-24) [#020-2026-09-24]

Second release, and the first under Apache-2.0: 24 packages, with the new `@better-iam/ldap`. New security services (key management, secrets vault, tokenization, private CA, SSH certificates, verifiable credentials, an LDAP gateway, identity threat detection, device posture, a Shared Signals receiver), governance additions (privacy and consent, lifecycle workflows, a compliance center, an application catalog), data filtering and API usage plans, a redesigned documentation site, and fixes from three security reviews.

* Relicensed under the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0) (was MIT): the root and every package's `LICENSE` file, `license` in every manifest (`validate-packages` now requires `Apache-2.0`), a root `NOTICE`, and the READMEs and docs. Version 0.1.0 on npm remains MIT; the next published version carries Apache-2.0. Package code is otherwise unchanged since 0.1.0.
* Redesigned the documentation site in black and white on [BoardUI](https://www.boardui.com). The BoardUI components and design tokens are source files in `apps/docs`, and a monochrome layer (`styles/monochrome.css`) makes controls black in light mode and white in dark mode. Fumadocs' palette maps onto the same tokens, so the docs and the landing pages match. There are no radial gradients: states are told apart by weight (solid, outlined, dashed, hatched), code blocks and Mermaid diagrams are grayscale, and social cards are monochrome.
* Icons come from react-icons: Remix Icon (the set BoardUI is drawn with) through the `lib/icons.ts` registry for frontmatter, `meta.json` and MDX, and Simple Icons for framework logos in the sidebar, the framework cards and the landing page. `lucide-react` was removed.
* New landing page layout: every band sits between the same two page-length rails with crosshairs where they meet section lines, and marketing pages scroll smoothly with Lenis (in-page links glide to their section below the sticky bars).
  * A new hero: an editorial headline and a walkthrough panel whose three steps (Authenticate, Authorize, Audit) play real code beside a live result. The audit scene verifies its chain in the browser with `verifyAuditChain`.
  * The pipeline chapter is now a compact trace: a timeline sized to each stage's share of the request auto-advances, and stage panels slide sideways beside the code. It is no longer pinned to page scrolling.
  * Every chapter is more interactive, with hover states and scroll-in animations: the decision demo uses BoardUI switches and lights its checks in order, clicking a tenant suspends it down the tree, the elevation timeline can be scrubbed, the audit chain breaks visibly when edited and can be restored, and federation lanes light up on hover.
  * Diagrams that play by themselves hold while the pointer is over them.
* BoardUI-styled search trigger, theme toggle, callouts, cards and page components in the docs, and a restyled policy playground. BoardUI's MIT notice is in `apps/docs/THIRD-PARTY-NOTICES.md`.
* The site header's call to action is now a GitHub button, and repository links (GitHub, "Edit this page", source files, issues) default to github.com/Better-IAM/better-iam.
* Credits Sean Filimon, the creator of Better IAM, in page metadata, the site footer, the hero, the docs sidebar, at the end of every docs page, on social cards, in `llms.txt`, in the FAQ, and on the Introduction page.
* Search and social metadata for the documentation site. Favicons (SVG that follows the light or dark color scheme, a 16/32/48 px ICO, and an Apple touch icon), a web app manifest with regular and maskable icons, and the same browser icons in the console. Every page has a canonical URL, keywords, and complete Open Graph and X card tags. Social cards are redesigned 1200×630 images in the site's typefaces, with new cards for the home page and the playground. Docs pages add article tags, Slack preview labels, and schema.org `TechArticle` and `BreadcrumbList` data, and the home page describes the site and the software in JSON-LD. Docs frontmatter gains an optional `metaTitle` for pages whose sidebar title is too terse on its own (31 pages use it), API reference groups are titled "\{group} API", and 9 over-long descriptions were shortened to fit search snippets. Optional build variables: `DOCS_X_HANDLE` and search console verification tokens. The Markdown copy of each page sends a canonical `Link` header, the 404 page is titled "Page not found", and the console is marked `noindex`.
* Every published package has its own npm description and more search keywords. Most packages previously shared one generic description.
* Search engine indexing for better-iam.com. `www.` and Railway-generated hosts redirect permanently to the canonical origin, and `/docs` now redirects to `/docs/introduction` permanently instead of temporarily. `llms.txt` and `llms-full.txt` are marked `noindex`. Bing and the other IndexNow engines get changed pages after every successful Railway deploy (`apps/docs/scripts/indexnow.mjs`, `.github/workflows/indexnow.yml`).
* Key management (KMS): a new `keys` API group gives each tenant keys whose material never leaves the server. AES-256-GCM keys encrypt and decrypt up to 4 KiB, bound to an encryption context, and issue data keys for envelope encryption; `reEncrypt` moves ciphertexts between keys. ECDSA (P-256, P-384), Ed25519 and RSA keys sign and verify, RSA keys can also encrypt with OAEP, and HMAC keys compute and check MACs. `signJwt` and `verifyJwt` work with signing and MAC keys, and `publicKey` / `jwks` publish public keys for offline verification. Keys have aliases, tags, versions with on-demand and automatic rotation, disable/enable, and deletion after a 7 to 30 day waiting period. Policies authorize `iam:kms:*` actions on `iam/kms/{keyId}` with conditions on key tags, spec, aliases, encryption context and algorithm, and key grants delegate named operations to one identity, re-checked against the grantor's own access on every use. Alias names are authorized on `iam/kms/alias/{name}`, and `signJwt` exposes the token's `sub`, `iss` and `aud` to policies. Every call is audited without plaintexts, and plugins never see plaintexts or tokens. Material is sealed under the deployment secret and re-sealed by `iam.rotateSecrets()`. The scheduler job `iam.kms.maintain()` rotates keys, destroys deleted ones and removes lapsed grants. New errors: `KEY_STATE_INVALID`, `INVALID_CIPHERTEXT`, `KEY_MATERIAL_UNAVAILABLE`. Guide: `docs/key-management.md`.
* Private certificate authority: a new `pki` API group runs X.509 certificate authorities per tenant, for mutual TLS, internal HTTPS and workload identity. Roots and intermediates (path lengths, name constraints, CRL distribution points) sign with KMS keys pinned to one version. `issueCertificate` takes a PKCS#10 request (`createCertificateRequest` builds one without OpenSSL) and decides `iam:pki:issue` separately for every name on the certificate (`resource.name`, `resource.nameType`, validity, usage, key type), so policies can limit teams to their own DNS names. `requestCertificate` gives a service account, agent or person a SPIFFE X.509-SVID for its own identity. Revocation (a subordinate CA's included), cached signed CRLs served publicly through `iam.pki.crlResponse`, trust bundles, and `iam.pki.verify` for servers that terminate mutual TLS (it checks the stored chain, revocation and authority state, and returns the workload identity). New errors: `INVALID_CSR`, `NAME_NOT_PERMITTED`, `AUTHORITY_UNAVAILABLE`, `CERTIFICATE_INVALID`. Guide: `docs/private-ca.md`.
* Data protection by tokenization: a new `protection` API group replaces sensitive values with tokens. Profiles per data type (`card`, `ssn`, `email`, `phone`, `generic`) choose format-preserving tokens (a card token keeps its length and last four digits but never passes the Luhn check) or random `tok_` tokens, deterministic tokens for joins, a display mask and a retention. Values are validated, normalized, and encrypted with one data key per call wrapped under a tenant KMS key. `tokenize`, `detokenize` (with a required `purpose`, visible to policies as `resource.purpose`), `mask` and `deleteTokens` (by token, or by value through a keyed fingerprint, for erasure requests) are separate permissions on `iam/protection/{profile}`. The audit trail records counts and purposes, never values or tokens. `iam.protection.sweep()` enforces retention. Guide: `docs/data-protection.md`.
* Key management, private CA and data protection hardening (security reviews): keys a certificate authority or a data protection profile uses are managed by it (`managedBy`, `KEY_MANAGED` for direct KMS use); changing a key's tags or aliases can never give the caller a KMS action they are refused now; delegated agents use up one confirmation per KMS, CA or data protection call; refusals are audited with the profile and purpose. The CA decides canonical names (wildcards as `nameType: wildcard`), requires the common name among the alternative names, reserves SPIFFE identity paths for `requestCertificate`, caps SVIDs at a day and at the credential and identity lifetimes, keeps trust domains unique, enforces path lengths over the whole chain, revokes descendant authorities with their parent, bundles roots only, and `iam.pki.verify` requires `tenantId` and a live identity. Data protection masks never show more than half a value (except first six and last four digits of 15+ digit cards), mask styles are per data type and policies see the style used, letters and digits of every script are masked and replaced, text is NFC-normalized and control, bidi and unpaired-surrogate characters are refused, format-preserving `ssn`/`phone` profiles are deterministic, SSN tokens avoid ITIN ranges, tokens are unique per profile (hashed key), the lookup key is wrapped under the profile's KMS key, customer keys need the caller's own KMS permission on every call, `tokenize` no longer returns `created`, shortening retention needs `iam:protection:delete`, sweeps run in batches and are audited, profiles report `tokens`, and deleting a profile retires its key. Migration `0006_protection_indexes` indexes value fingerprints (SQLite and libSQL).
* Secrets vault: a new `vault` API group keeps each tenant's secrets under path-like names, authorized as `iam:vault:read|reveal|write|rotate|lease|manage` on `iam/vault/secrets/{name}`, with the secret's tags as policy attributes (`resource.tag.{key}`). Secrets are `text` or `json`, versioned with stage labels (`current`, `previous`, `pending`, custom), kept to `maxVersions`, and can be disabled or destroyed per version. Rotation stages a generated value (CSPRNG generators with charsets, exclusions and character classes), lets a deployment-configured rotator apply it to the system it unlocks, and promotes it; failures keep the pending value for an idempotent retry and are recorded redacted. Check-out policies hand shared privileged credentials out one holder at a time, for a limited time, with a reason, rotating them when they come back. Dynamic secrets mint a credential per caller through an engine (`vault.engines`) with renewable, revocable leases. Values are sealed with the deployment secret, bound to tenant, secret and version, or under a customer-managed KMS key (`kmsKey`), so disabling the key shreds them. Deletion has a 7 to 30 day recovery window. `accessLog` shows who used a secret. Server code reads secrets and resolves `vault://name#field` references through `iam.vault`, whose jobs `rotateDue`, `expireLeases` and `purgeDeleted` run the schedule. Values never leave through "view as" sessions. New errors: `SECRET_PENDING_DELETION`, `CHECKOUT_REQUIRED`, `SECRET_CHECKED_OUT`, `VERSION_DISABLED`, `VERSION_DESTROYED`, `ROTATION_FAILED`, `ENGINE_FAILED`. Guide: `docs/secrets-vault.md`.
* Data filtering (query planning): `iam.planResources` and the `filters` API group (`plan`, and `planFor` for an administrator's preview with `iam:policies:simulate`) answer "which resources of this type may the caller perform this action on?" as a filter over the resource's `id` and attributes, by partially evaluating the same grants, denies, boundaries, relationships, sessions and delegations a decision uses (`always`, `never`, or `conditional`). The planner, `planResources` in `@better-iam/core`, mirrors the engine exactly (a randomized differential test compares it with `evaluatePolicy` and with SQLite), and the filter compiles with `filterToSql` (PostgreSQL and SQLite, parameterized and NULL-safe), `filterToPrisma`, `filterToMongo`, `filterMatches` and `describeFilter`. The Express, Hono, Fastify, SvelteKit and React Router integrations gain `plan({ action, type })` on their per-request helpers, and the console a Data filters preview page. Prepared decisions expose their inputs read-only (`DecisionInputs`). New error: `UNSUPPORTED_FILTER`. Guide: `docs/data-filtering.md`.
* SSH certificate authority (`ssh` option): a new `ssh` API group makes Better IAM the certificate authority for your servers. People, service accounts and agents get short-lived OpenSSH user certificates (`issueCertificate`, CLI `ssh-cert`) naming one principal `{login}@{host}` per host and login their policies allow: `ssh:login` on `ssh-login/{host}/{login}` with the host's labels as attributes, and `ssh:port-forward`/`ssh:agent-forward`/`ssh:x11-forward` on `ssh-host/{host}`. Certificates live only as long as the session or API key that requested them and the time-limited grants (just-in-time activations, temporary bindings and memberships, access windows) that allowed them; agents acting for people get ten minutes. Tenant settings can require MFA, FIDO security keys (with `verify-required`) or source-address binding. Hosts enroll once with a join token (`enrollHost`, CLI `ssh-host-enroll`), get a host certificate, the trusted user authority, per-login principals files, a key revocation list and an sshd\_config drop-in, and check in with a renewal token (`syncHost`, CLI `ssh-host-sync`) that can never move them to another key. The host authority only vouches for names the organization owns (single labels, private addresses, its verified domains, within `hostPatterns`), each unique to one host and within the administrator's own `iam/ssh/hosts/{name}` scope; host keys can never be authority keys or another host's key; members' clients trust it for the enrolled names only, and the public trust route lists no host names. Revocation is by two KRLs, `user` for sshd `RevokedKeys` and `host` for clients' `RevokedHostKeys`; `iam.ssh.sweep()` (CLI `ssh-sweep`) re-validates each certificate's own session and revokes those whose holder became inactive, whose session ended, or whose hosts, logins or forwarding policies no longer allow. Suspended organizations still publish revocations (all user certificates). Renewal tokens rotate with every renewed host certificate, public host routes and issuance are rate limited, and refused host calls are audited. Authorities rotate in two phases (pending, active, previous, retired), are sealed under the deployment secret and re-sealed by `iam.rotateSecrets()`. `whoCanLogin` reviews host access, and the platform root override never opens an organization's hosts. New errors: `SSH_NOT_CONFIGURED`, `SECURITY_KEY_REQUIRED`, `SOURCE_ADDRESS_UNKNOWN`, `TOO_MANY_HOSTS`, `ROOT_SSH_RESTRICTED`, `HOST_OUTSIDE_PATTERNS`, `HOST_NAME_TAKEN`, `HOST_KEY_IN_USE`, `HOST_KEY_CHANGED`. Guide: `docs/ssh-access.md`.
* LDAP directory gateway: the new `@better-iam/ldap` package (also `better-iam/ldap`) is a read-only LDAPv3 server (`createLdapServer({ iam, tls })`) so applications that only speak LDAP (VPNs, NAS devices, CI servers, wikis) look up people and groups and check passwords against Better IAM. Each organization publishes its directory under its own base DN with the new `ldap` API group (`getSettings`, `updateSettings`, `directory`; permissions `iam:ldap:read`, `iam:ldap:manage`): people under `ou=people` (`uid` as email, local part or ID), groups as `groupOfNames` with `member`/`memberOf`, optionally service accounts, and only the identity attributes it selects. People bind with their password as a Better IAM sign-in (rate limits, lockouts, IP rules and alerts apply; people who use MFA append their current code), exactly one attempt per bind; service accounts bind with an API key. Binds without TLS are refused except from loopback, names with empty passwords are refused, and searches are authorized as the bound account: the whole directory with `iam:ldap:read` (audited as `ldap:directory:read`), otherwise only its own entry. Search (all RFC 4515 filters, paged results), compare and WhoAmI are supported; writes are refused. New error: `LDAP_BASE_TAKEN`. Guide: `docs/ldap.md`.
* Verifiable credentials (`verifiableCredentials` option): every organization issues SD-JWT VCs (IETF SD-JWT-based Verifiable Credentials, RFC 9901 selective disclosure) that people keep in digital wallets. Credential types (`iam:vc:manage`) map account data to claims: email, name, organization, teams, department, declared identity attributes, static values, each selectively disclosable unless marked otherwise. People get credentials for themselves where policies allow `vc:request` on `credential-type/{name}` (and MFA when a type requires it), through OpenID4VCI (`createOffer` returns an `openid-credential-offer://` link with a pre-authorized code and optional PIN, served by issuer endpoints mounted at `{basePath}/vc/{tenantId}` with `/.well-known` metadata, token, nonce and credential endpoints) or directly with `request`; administrators offer them to others (`iam:vc:issue`). Credentials are bound to a holder key proven with an `openid4vci-proof+jwt` over a single-use nonce. Revocation and suspension use an IETF Token Status List with random, never-reused indexes, served as `application/statuslist+jwt`; holders can revoke their own, and `iam.verifiableCredentials.sweep()` revokes those of people who left or may no longer request them. Self-service credentials end with the time-limited grant that allows `vc:request` (expiring bindings and memberships, just-in-time activations, access windows) or the role session they came from, and delegated agent sessions cannot obtain them. `verify` (public) and `iam.verifiableCredentials.verify` check signature, disclosures, key binding (audience and nonce, both required, freshness, `sd_hash`) and status. Proof nonces are signed rather than stored, public issuer endpoints only read, wrong wallet PINs and refused redemptions are audited as denied `vc:offer:redeem`, and a type's `vct` may not claim another organization's issuer. Issuer ES256 keys are sealed under the deployment secret and rotate with `rotateKey`/`retireKey`. `issueSdJwt`, `presentSdJwt`, `verifySdJwt`, `signStatusList`, `readStatusList` and `verifyHolderProof` are exported for wallets and verifiers. New errors: `TYPE_DISABLED`, `IDENTITY_INACTIVE`, `CLAIM_UNAVAILABLE`, `INVALID_PROOF`, `INVALID_NONCE`, `ACCESS_EXPIRING`. Guide: `docs/verifiable-credentials.md`.
* Privacy and consent management: a new `privacy` API group records each tenant's processing purposes with their legal basis (consent opt-in or opt-out, legitimate interests, contract, legal obligation, vital interests, public task), data categories and retention, and each person's decision on them, made by the person (`mine`, `decide`), recorded by the application for its own customers (`record`, `importDecisions`, or `iam.privacy.record` without a credential) and checked with `check`, `filterSubjects`, `audience` or `iam.privacy.check`. Purposes have versions that ask for consent again and optional consent lifetimes. Every decision appends to an append-only history and returns an HMAC-signed consent receipt that `verifyReceipt` checks, across secret rotations. Policies see `principal.consents`, the purposes that may be processed for the person now. Data-subject requests (access, portability, erasure, rectification, restriction, objection, opt-out) are filed by the person, by staff with a verification record, or through an optional public form confirmed by email (`submitPublic`, `confirmPublic`); requests known only by an email address are linked to their subject (`linkRequest`) or declined as `no-data`. They run on GDPR, UK GDPR, CCPA, LGPD and PIPEDA deadlines with one extension where the law allows, and fulfilment builds exports (downloadable for a limited time, then swept), erases accounts and privacy records (an erased application subject stays suppressed as `ERASED`), restricts processing, or withdraws consents. Legal holds refuse erasure and every deletion of the person's account. `iam.privacy.sendDeadlineReminders()` reminds handlers of due and overdue requests and lapses unconfirmed ones. Authorized as `iam:privacy:read|manage|record|check|handle`; audited as `privacy:*` events; emails `privacy-request-verify`, `privacy-request-received`, `privacy-request-update` and `privacy-request-due` with the `links.privacy` builder. Console pages: Organization › Privacy and Home › Your privacy. New errors: `LEGAL_HOLD`, `CONFIRMATION_INVALID`. Guide: `docs/privacy.md`.
* Security fixes from a review of authentication, sessions, temporary credentials and the framework integrations:
  * SvelteKit `protect` rules now match the path SvelteKit actually routes (percent-decoded, and the matched route id for `reroute` aliases); before, `/%61dmin` skipped a `/admin` rule. Remote functions still need their own guards.
  * A password alone can no longer enroll an authenticator over an existing factor: when a passkey is registered, the MFA challenge answers `enrollmentRequired: false` (new `authenticatorEnrolled` field) and `beginMfa`/`confirmMfa` from the login challenge are refused with `MFA_REQUIRED`.
  * NestJS: `@Public()` no longer turns `@Authorize`, `@RequireMfa`, `@Credentials` or `@RequireClaims` into no-ops for anonymous or invalid credentials (401 instead); CSRF checks also cover GraphQL over HTTP; the assertion guard accepts a lowercase `bearer` scheme.
  * `roles.assume` refuses delegated agent sessions (`CREDENTIAL_CHAINING_DISABLED`), so an agent cannot mint a role JWT that outlives its delegation.
  * An identity past its `expiresAt` is refused at sign-in and on every session use in the authentication service itself, not only in authorization decisions.
  * `startPasswordless` replaces earlier codes and links for the destination; the first emailed sign-in to a self-registered account whose address was never verified ends its sessions and removes the password chosen at sign-up (new `Identity.unprovenPassword`, cleared by verifying the address or resetting the password; the owner sets a password through reset), so an account registered in someone else's name cannot be kept by its registrant; verify-email and password-reset links only work for the address they were sent to; emailed MFA codes are stored with a keyed digest; new authenticator secrets are 160 bits.
  * `reauthenticate` refuses an expired password (`PASSWORD_EXPIRED`) and honours `emailPassword: false`.
  * Impersonation sessions also end when the administrator's session idles out (the administrator's tenant idle timeout), when the tenant turns `allowImpersonation` off, or when the member becomes an owner or root administrator.
  * Lowering `sessionLifetimeMs` or `trustedDeviceDays` now also shortens sessions and remembered devices issued under the longer value. Network blocks also judge the address presenting a user session, and `bindSessionsToIp` also applies when a user session is the source of a role session.
  * Passkey sign-in with an email answers an unknown address, or one without passkeys, with the same shape of options as a real account, so it no longer reveals which accounts exist.
  * Assertions: scoped credentials (API keys with scopes, role sessions with a session policy) are refused like scoped session tokens, and only the canonical spelling of a signature verifies (server and `@better-iam/next` edge verifier).
  * Web-identity trusts: a `StringLike` pin on `token.sub` may use a wildcard only after two complete segments (`repo:acme/*` is accepted; `repo:*`, which admits every repository on the platform, and `repo:acme*`, which also admits `acme-evil`, are refused with `WEAK_TRUST_CONDITIONS`). Existing trusts are not re-validated: review them with `trust.list`. With a recorded client address, `sts.assumeRoleWithWebIdentity` counts `maxExchangesPerWindow` per trust and address (ceiling: ten budgets per trust), so anyone posting junk tokens to a public trust id can no longer block the real workload.
  * Delegation tokens never carry a scope that a deny among the person's grants could touch, including denies whose author's authority was revoked (these still apply to decisions).
  * Sealed values require a 128-bit GCM tag and a 96-bit IV; delegation-token and agent-card verifiers fail closed on a NaN or out-of-range clock tolerance; web-identity replay records are kept for the largest possible clock tolerance.
  * `Origin: null` is refused by the middleware and Next.js CSRF checks (it equals the `.origin` of trusted entries such as `capacitor://localhost`); `auth/confirmMfa` is pinned to the organization address like other sign-in calls; the Fastify and h3 fallback paths cap request bodies at 2 MiB; the Next edge middleware treats only `/api/iam` and `/api/iam/*` as the IAM API.
  * Sign-in notices summarize the User-Agent ("Chrome on Windows", new `describeUserAgent`) instead of quoting attacker-chosen text, email subjects are kept on one line, and display names have control characters replaced at creation.
* Security fixes from a review of authorization and the management API (each covered by `tests/security-*.test.ts`):
  * "View as" sessions: every check an operation makes on the side (may this role be granted, may this group be changed) now needs the impersonating administrator's rights too, not only the first one. No grant authority can be used while impersonating (`IMPERSONATION_RESTRICTED`), and view-as sessions can neither decide access requests nor change or remove bindings the member issued. Before, support staff could "view as" a broader administrator to create accounts with that administrator's roles or approve their own requests.
  * Policy matching: a pattern's `*` now also matches a literal `*` in the value, so a Deny on `file/shared/*` covers `file/shared/*x`. `?` is one character for astral characters on every path. Evaluation has a per-call work budget and refuses with reason `evaluation-limit` past it; `*`-only globs match by native substring search. One crafted `policies.test` call could block the server for seconds.
  * Denies outlive their author: when the authority behind a binding, role or attached policy is revoked (or its administrator offboarded), what it granted lapses and what it denied still applies. An editor can no longer remove or change a deny statement that another authority relies on (it attached the policy, bound the role, or its role inherits it): `PROTECTED_RESOURCE`, unless root.
  * Separation-of-duties rules follow role inheritance, and `roles.update` is checked like other granting operations.
  * Tenant-defined mode: a type only a tenant registered answers from its registry for its own `{type}:{verb}` actions; the application's actions on an undeclared type always go to `resolveResource`, so an organization cannot register `document` and pass checks on another organization's documents.
  * A principal of another tenant is refused before any resource or action is looked up, and the refusal is recorded in its own tenant (metadata `targetTenantId`) instead of the target's audit chain and webhooks. `authorize` returns a denial instead of `NOT_FOUND`/`RESOURCE_MISMATCH` for such callers.
  * `audit.verify` (and `verifyAuditChain` with a `head`) checks the whole stored chain: new failures `missing-prefix` (oldest events deleted without an `audit:prune` checkpoint) and `unchained` (an event inserted without chain fields); deleting every event fails the head check. Startup backfill chains only tenants without a chain yet.
  * Teams: moving a team, or opening an admins-only team to its maintainers, needs grant authority over what the teams hold; the maintainer path no longer overrides an explicit deny. Onboarding completion groups, group-to-team sync, team leave (when a team carries a deny) and maintainer standing through admins-only teams are re-checked against grant authority.
  * Access packages: `extend` and membership hand-over only lengthen what the package still bundles, automatic grants run the enforced access invariants, and `autoAssign.warnings` flags rules on facts other administrators control.
  * Identities and tenants: non-owners cannot disable, expire or delete owners; administrators cannot edit their own attributes, manager or expiry, and setting attributes or a manager at creation needs `iam:identities:update`; invitations are re-checked against the inviter at redemption; child tenants inherit the stricter of their parent's plan limits; subtree webhooks and the root tenant's sign-in policy, sessions and network blocks are root's; scoped API keys only issue keys within their scopes; demoting an owner revokes the owner grant authority.
  * Billing: plan prices apply only to usage inside the subscription and trials once per account and plan; seat recording uses internal keys callers cannot claim; sub-accounts inherit contract terms; usage cannot be attributed to platform identities or teams; payment terms and built-in meter keys are the platform's; CSV exports neutralize formulas.
  * Also: plugin endpoints can name the record they act on (`PluginEndpoint.resource`), so per-record policies apply; resource IDs cannot contain `*` or `?`; `resources.list` and `relationships.list` leave out records a policy keeps from the caller; approvers can shorten but not lengthen an activation; role mining is linear on crafted inputs; certification auto-close acts only for a creator still in good standing; analysis suppressions need a current finding.
* Identity lifecycle workflows (joiner, mover, leaver): a new `workflows` API group runs steps for each person a trigger fires for. Triggers are `joiner` (people who join after the workflow is enabled, or everyone with `includeExisting`), `mover` (watched attributes or `managerId` changed, against a baseline taken when the workflow starts), `leaver` (an active person was disabled), `date` (days before or after `createdAt`, `expiresAt` or an ISO date attribute such as `startDate`; a moved date fires again) and `manual`. A scope in the access-package rule language picks the people. Steps add to and remove from groups (`remove-from-all-groups` leaves team and package memberships alone), assign and revoke packages (rule-made assignments stay with their rule), send emails with placeholders (`workflow-message`) to the person, their manager, or addresses of members and verified domains only, sign people out, disable, enable, set attributes and expiry, delete (last step only), emit `workflow:event` for webhooks, and wait up to five times. A run keeps the steps it started with and the rights of the owner who approved them (the workflow's owner when it started, or whoever retried it), re-checked at every step with the owner's MFA state (`OWNER_INACTIVE` when the owner is gone); `update` with `activeRuns: 'cancel'` stops runs in progress. Steps never strip or edit owners or root administrators (`PROTECTED_RESOURCE`), and nobody changes their own account through a workflow they own, start or retry. A daily brake (`maxRunsPerDay`, 25 when a step takes access away, else 200) starts runs up to the day's allowance, holds the rest, and records `workflow:brake`. `preview` shows who is in scope, who would start and upcoming dates; `run`, `retryRun`, `cancelRun` and `evaluate` handle runs by hand, and the readers show names only to callers with `iam:identities:read`. `iam.workflows.runDue()` (every few minutes) and `iam.workflows.subscribe()` (with `iam.dispatchAuditHooks()`; `iam.workflows.idle()` waits for its background evaluations) run them; finished runs are swept after 180 days. Authorized as `iam:workflows:read|manage|run`; audited as `workflow:run:start`, `workflow:step`, `workflow:run:complete`, `workflow:run:fail`, `workflow:brake` and `workflow:event`. Console: Access › Workflows. New error: `OWNER_INACTIVE`. Guide: `docs/workflows.md`.
* Identity threat detection and response (ITDR): a new `threats` API group watches each tenant's audit trail for identity attacks. Twenty-one detection rules, each mapped to a MITRE ATT\&CK technique: `password-spray`, `brute-force`, `brute-force-success`, `mfa-bombardment`, `session-hijack`, `new-network` (privileged people, or `everyone`), `dormant-reactivated`, `account-takeover-persistence` (sign-in methods changed after a risky sign-in), `privilege-escalation`, `root-admin-granted`, `mass-deletion`, `directory-mass-change`, `impersonation-burst`, `denial-burst` (AI agents included), `recon-burst`, `guardrail-weakened`, `token-replay`, `audit-tampering`, `invariant-broken`, `user-reported` and `upstream-signal` (an upstream provider's Shared Signals event about a matched person: compromise, a disabled account, or medium/high risk). The scheduler job `iam.detectThreats({ tenantId?, maxEvents? })` (CLI `detect-threats --tenant --max-events`, every minute) reads each active tenant's audit chain from a sequence cursor, at most 2000 events per run by default, verifies the hash chain as it goes (a break raises `audit-tampering`; prune checkpoints are followed), and never slows a sign-in. Detections are deduplicated per triggering event or burst, grouped into one open incident per subject (identity, network, tenant or SCIM connection), and raise a per-identity risk score (10/25/50/80 points by severity, decaying with the tenant's half-life, levels `none`, `low`, `medium`, `high`, administrator overrides). Methods: settings (`rules`, `getSettings`, `configure`: per-rule `enabled`, `severity`, `threshold` and `windowMs` within each rule's bounds, trusted networks, dormancy, risk half-life, notification recipients, the containment brake), detections (`listDetections`, `getDetection`, `dismissDetection`), incidents (`listIncidents`, `getIncident`, `updateIncident`, `addNote`, `resolveIncident`, `summary`), responses (`respond` with `revoke-sessions`, `forget-devices`, `contain`, `block-network` and `notify`; `release`), risk (`listRisk`, `getRisk`, `setRisk`, `timeline`), playbooks (`listPlaybooks`, `createPlaybook`, `updatePlaybook`, `deletePlaybook`), `detect` (run now), and self-service `reportSuspicious` ("this wasn't me": no permission, ends the person's other sessions and forgets their devices). Authorized as `iam:threats:read|manage|respond` on `iam/threats/...`. Automatic responses never contain owners or root administrators, contain at most `maxAutomaticContainments` (3) identities per tenant and run, and never block trusted networks; nobody clears risk raised about themselves. Policy context keys `principal.riskLevel` and `principal.riskScore`, read only when a document names them (simulated principals see `none` and 0). Audited as `threat:detection`, `threat:incident-open`, `threat:risk-change`, `threat:settings`, `threat:detection-dismiss`, `threat:incident-update`, `threat:note`, `threat:incident-resolve`, `threat:risk-override`, `threat:revoke-sessions`, `threat:forget-devices`, `threat:contain`, `threat:release`, `threat:block-network`, `threat:notify`, `threat:response-braked`, `threat:playbook-create|update|delete` and `threat:user-report`; Shared Signals sends `threat:revoke-sessions` as CAEP `session-revoked` and `threat:contain` as RISC `account-disabled`. Email template `threat-alert` with the `links.threats` builder. Collections `threatDetections`, `threatIncidents`, `threatNotes`, `identityRisk`, `threatBaselines`, `threatCursors`, `threatSettings`, `threatPlaybooks`, `threatResponses`. Exports `threatRules`, `riskLevelFor`, `effectiveRisk`, `resolveThreatSettings` and the module's types. Guide: `docs/threat-detection.md`.
  * Breaking: `riskLevel` and `riskScore` are reserved identity attribute names (declaring either fails with `INVALID_CONFIG`), and `principal.riskLevel` / `principal.riskScore` supplied by `resolveContext` or a plugin are removed before the server sets its own values.
* Device posture: a new `devices` API group lets policies ask which device a request comes from and whether an MDM or EDR finds it compliant. A browser or agent enrolls a public key once (`enroll`, self-service for people: EC P-256 or Ed25519, identified by its RFC 7638 thumbprint) and then sends a short-lived proof in the `x-better-iam-device` header: a compact JWS (`typ: device-proof+jwt`, `kid` the thumbprint, `iat` within 300 seconds, `sid` the presenting session) whose algorithm is pinned by the enrolled key, whose header may carry no key material, and which verifies only with the session it names. Decisions verify it only when a document names `request.device`, never write, and treat a missing or invalid proof as no device. Policies see `request.deviceAssurance` (`none`, `registered`, `managed`, `compliant`), `request.deviceManaged`, `request.deviceCompliant` and, when a proof verified, `request.deviceId` and `request.devicePlatform`; `policies.test` defaults the first three to `none` and false. People list, check and retire their own devices (`mine`, `check`, `retireMine`). Administrators use `list`, `get`, `update` (rename, reassign, mark lost), `retire`, `delete` and `removeKey`; one-time enrollment codes (`createEnrollment`, `listEnrollments`, `revokeEnrollment`; `biam_denr_…`, stored hashed) that bind a browser or agent key to a managed record; compliance requirements (`getSettings`, `configure`: encryption, screen lock, firewall, EDR health, jailbreak, minimum OS version per platform, check-in age); and MDM/EDR integrations (`listIntegrations`, `createIntegration`, `updateIntegration`, `deleteIntegration`; Intune, Jamf, Kandji, Workspace ONE, Google endpoint management, CrowdStrike, SentinelOne or custom). Integrations post up to 500 devices per call with `report`: matched by vendor id, with owner (email or id), posture, OS version, and the thumbprint of a key the vendor's agent enrolled. Authorized as `iam:devices:read|manage|report` on `iam/devices…` (`report` on `iam/devices/integrations/{id}`, meant for a service account's API key); audited as `device:enroll`, `device:retire`, `device:update`, `device:delete`, `device:key-remove`, `device:enrollment-create`, `device:enrollment-revoke`, `device:settings`, `device:integration-create`, `device:integration-update`, `device:integration-delete`, `device:report` and `device:compliance-change`. The HTTP handler allows the header cross-origin, the Next.js and Express/Hono/Fastify/SvelteKit in-process clients forward it, and `AuthenticatedPrincipal.deviceProof` carries it (unverified) to decisions. Deleting a person retires their self-enrolled devices and unassigns their managed ones; the retention sweep deletes expired enrollment codes. New client entry point `@better-iam/client/device` (also `better-iam/client/device`): `createDeviceProver` (creates and keeps the key pair, returns the public JWK and thumbprint, caches proofs, and `headers(sessionId)` plugs into `ClientOptions.headers`), `indexedDbKeyStore` (a non-extractable key in the browser) and `memoryKeyStore`; WebCrypto only, for browsers and Node 22 or later. Registered devices are separate from remembered devices and never satisfy MFA. Guide: `docs/device-posture.md`.
  * Behaviour change: `request.deviceAssurance`, `request.deviceManaged`, `request.deviceCompliant`, `request.deviceId` and `request.devicePlatform` are now server-owned context keys, so values that `resolveContext` or a plugin supplies under those names are removed before the server assigns its own. Applications that passed their own device signals under these names should rename them.
* Compliance center: a new `compliance` API group runs 14 automated checks over each tenant's identity and access state (`mfa-enforced`, `mfa-coverage`, `password-length`, `session-lifetime`, `inactive-accounts`, `leaver-access`, `stale-api-keys`, `api-key-lifetime`, `access-reviews`, `privileged-access`, `owner-redundancy`, `separation-of-duties`, `audit-integrity`, `privacy-deadlines`), with parameters, mapped to requirements of SOC 2, ISO/IEC 27001:2022, NIST SP 800-53 Rev. 5 and GDPR as evidence for them (not full coverage). Tenants adopt a framework (`adoptFramework`, one shared control per check) or add controls of their own (`createControl`, free-form mappings), evaluate them (`evaluate`, or the daily scheduler job `iam.compliance.evaluateAll()`), accept findings with exceptions that expire within a year, see each requirement's status (`status`), and export evidence packs (`exportEvidence`) that carry the latest results, active exceptions, recent runs and the audit chain head. Packs are signed with an Ed25519 key derived from the deployment secret: `evidenceKeys` publishes the public keys, `verifyEvidencePack` checks a pack offline and `verifyEvidence` on the server (earlier HMAC-signed packs still verify, across secret rotations). Exceptions take a second person's approval (`approveException`; revoked ones are kept as history), the audit-integrity check verifies the chain incrementally from a checkpoint, checks read outside transactions, results report when they are stale, and CLI `compliance-evaluate` runs the evaluation from a scheduler. Each run's results digest is recorded in the audit chain. Results and runs are kept 400 days. Authorized as `iam:compliance:read|evaluate|manage`; audited as `compliance:evaluate`, `compliance:control:fail`, `compliance:control:recover`, `compliance:framework:adopt`, `compliance:control:create|update|delete`, `compliance:exception` (proposed), `compliance:exception:approve`, `compliance:exception:revoke` and `compliance:evidence-export`. Exports `complianceChecks` and `complianceFrameworks`. Console: Governance › Compliance. Guide: `docs/compliance.md`.
* Shared Signals receiver: Better IAM now receives OpenID Shared Signals (SSF 1.0 with CAEP and RISC) from upstream identity providers. A new `signals` API group registers each organization's transmitters as signal sources (`createSource`, `listSources`, `getSource`, `updateSource`, `deleteSource`, `rotatePushToken`; at most 20 per organization, one per issuer). A source has an issuer with up to 5 aliases, accepted audiences, static public keys, a key URL or SSF discovery (`/.well-known/ssf-configuration`, then the legacy RISC document), an algorithm allowlist (never HMAC or `none`), push or poll delivery, subject mapping, and an action per event type. Events arrive by RFC 8935 push at `{signals.pushPath}/{sourceId}` (default `/api/iam/signals/push/{sourceId}`: `application/secevent+jwt`, an optional bearer token shown once and stored hashed, 64 KiB bodies, rate limited per source, 202 or `{ err, description }` with 400, 401, 404, 413, 415, 429 and 503 while keys cannot be fetched) or by RFC 8936 poll through the new scheduler job `iam.signals.poll()` (acknowledgements only after the event is stored, sent with the next request; `setErrs` for refused events; up to five requests per source and run; the poll token sealed with the deployment secret and re-sealed by `iam.rotateSecrets()`). `iam.signals.receive(sourceId, set)` hands in SETs from custom transports, and `signals.poll` polls one source on demand. Each SET is checked for size (16 KiB), compact JWS form, no key headers (`jwk`, `jku`, `x5u`, `x5c`) or `crit`, the allowed algorithm, `typ` `secevent+jwt` (per-source `requireTyp: false` for legacy Google RISC), signature, issuer, audience, `iat` within seven days, one event, no `nonce` and a consistent subject, then deduplicated per organization on issuer and `jti`. Subjects (RFC 9493 formats, SSF `complex`, legacy RISC `subject_type`) map to people of the source's organization only: `iss_sub` through the federation links of chosen sign-in connections, `iss_sub` and `opaque` through the `externalId` of users provisioned by chosen SCIM connections, and email or `acct:` subjects only at domains the organization verified. Service accounts, agents and deleted identities never match. Events are recorded for 90 days with a status (`applied`, `recorded`, `unmatched`, `ignored`, `failed`; `listEvents`, `getEvent`, and `reprocess` after the mapping is fixed). The only direct action is `revoke-sessions` (every session except API keys) for the event types a source chooses, never against root administrators from other organizations. Everything is audited under names the transmitter does not publish, so nothing echoes back upstream: `signal:received` (actor `signal:{sourceId}`, with `sourceId`, `eventType`, `jti`, `status`, `identityId`, `reasonAdmin`, `currentLevel` and `credentialType` for threat detection), `signal:revoke-sessions`, `signal:source-create|update|delete|rotate` and `signal:reprocess`. Authorized as `iam:signals:read|manage` on `iam/signals/sources` and `iam/signals/events`. Deployment option `signals` (`pushPath`, `allowPrivateNetworks`, `allowInsecureLocalhost`); key, discovery and poll requests go through the SSRF-guarded transport. Collections `signalSources` and `signalEvents`. `@better-iam/core` exports the pure pieces: `signalEventUris`, `signalEventType`, `parseSubjectIdentifier`, `legacyRiscSubject`, `readSecurityEvent`, `userSubjects` and `SignalFormatError`. New error: `SIGNAL_REJECTED`. Guide: `docs/shared-signals-receiver.md`.
* Application catalog and My apps: a new `applications` API group lists the tools an organization gives its people, each an OpenID Connect client of this deployment (`oauthClientId`, kind `oidc`) or a plain link (`link`), with a category, logo, owners, and `everyone` or `assigned` visibility (at most 500 per tenant). Apps are assigned to people and groups, optionally until a date (`assign`, `unassign`, `listAssignments`); people without an app can request it through a requestable access package (`requestPackageId`). People see their apps with `mine` and open them with `launch`, which records and audits the launch and returns the app's URL (refused in "view as" sessions with `IMPERSONATION_RESTRICTED`). A deployment's sign-in pages can refuse people who lack an OIDC app: `iam.applications.allowed(...)`, `check` over the API, and the OAuth provider's new optional `clientAllowed(identityId, tenantId, clientId)` hook, which the protocol host wires to the catalog. `usage` shows launches per app and the direct assignments nobody uses, and `removeUnused` removes them. Deleting or clearing an app that governs an OAuth client needs `releaseClient: true`. Deleting a person or group releases their assignments. Authorized as `iam:applications:read|manage|assign`; audited as `app:*` events. React `useMyApps`. Console: Home › My apps (a launcher grouped by category) and Access › Applications. Guide: `docs/applications.md`.
* API usage plans and quotas: a new `quotas` API group limits how much of your API (or anything else you count) a caller may use. A plan counts one meter with a token-bucket throttle (`ratePerSecond`, `burst`) and/or at most one limit per `minute`, `hour`, `day`, `week` and `month` (days, weeks and months in the plan's `timeZone`), per subject or pooled for the tenant (`scope: 'tenant'`). Plans are assigned to API keys, identities (people, service accounts, agents) and groups; the most specific applies (the presented key, the acting agent, the identity, its highest-priority group, then the tenant's `default` plan), and a meter without a plan is unlimited. `iam.quotas.enforce` throws `QUOTA_EXCEEDED` (429, with `retryAfterMs`), `consume` (in process or over HTTP) returns the decision instead, `status` reads what is left without counting, and `consumeFor` counts work your code attributes itself; a refused call counts nothing. Counters live in the database, so every instance sees the same totals, and `iam.sweepExpired()` removes old ones. `alertThresholds` record `quota:threshold` once per window, and the first refusal of a period window records `quota:exceeded`, for webhooks (throttle refusals are not audited). Methods `createPlan`, `updatePlan`, `deletePlan`, `getPlan`, `listPlans`, `assign`, `unassign`, `listAssignments`, `usage`, `reset`; authorized as `iam:quotas:read|manage` on `iam/quotas…`. New error: `QUOTA_EXCEEDED`. Guide: `docs/quotas.md`.
* Security fixes from a review of the protocols, integrations, storage, CLI and console (each covered by `tests/security-*.test.ts`):
  * Outbound requests to addresses tenant administrators choose go through one SSRF guard (`createGuardedFetch`, exported from `@better-iam/auth`): webhooks, outbound SCIM targets, Shared Signals streams and organization-chosen inference base URLs refuse loopback, private, link-local, CGNAT, reserved and metadata addresses unless the deployment sets `events.allowPrivateNetworks`, the SCIM provisioner's or SSF transmitter's `allowPrivateNetworks`, or `inference.allowPrivateNetworks`. Changing a SCIM target's or SSF stream's URL requires its token or authorization again. A guarded response body cancelled by its reader no longer crashes the process.
  * SAML: managed connections get a random `providerKey` that keys their account links (deleting a connection unlinks them), trust-widening edits are re-checked and audited, IdP-initiated responses are judged fresh by `IssueInstant`, and only encrypted assertions that are direct children of the response count.
  * OAuth and OpenID Connect: `identity_id` appears only on client-credentials tokens, the registration gate normalizes its path, `iam` claims come from live grants, token exchange refuses revoked or disconnected subjects and DPoP-only clients, DPoP proofs have a replay cache, `prompt=login` and `max_age` are honored at the end of an interaction, and resource servers can pin a `tenantId`.
  * SCIM: outbound provisioning adopts existing remote accounts only by verified email and escapes filter values; inbound provisioning honors plan limits, only changes the status of accounts it provisioned, revokes fully on deprovisioning, and audits every change.
  * Shared Signals transmitter subjects use verified emails only and carry a `tenant_id` claim. Federation never provisions just-in-time into the root tenant, and OAuth sign-in connections accept `allowedEmailDomains`.
  * Delegation confirmations are single-use. Inference models on a parent tenant's provider and platform-set budgets need root, and a provider key must be supplied again when its base URL changes. Configuration sync needs a recent sign-in to change the tenant access policy (audited as `tenant:access-policy`), hides SCIM groups named like administrator groups, and keeps team approver groups and binding dates; pruning agents through AI configuration sync needs a recent sign-in. Plugin endpoints of `@better-iam/projects` name the record they act on.
  * CLI: `help` and shell completion never import a discovered configuration file, discovery stops at the repository root or home directory and skips files others could plant, a `cli.defaults['*']` entry no longer fills destructive retention flags such as `audit-prune`'s (only a default for that very command does), exports and profiles are written with mode 0600, and terminal control sequences in server data are neutralized in text output.
  * A2A and MCP gates refuse non-string skill and tool names. The console sends security headers and refuses the in-memory mail inbox in production.

## 0.1.0 (2026-09-23) [#010-2026-09-23]

First public release: all 23 packages published to npm as `better-iam` and `@better-iam/*`.

* Licensed under MIT (was proprietary / `UNLICENSED`). Every package now carries `repository`, `homepage`, `bugs`, `keywords`, and `publishConfig.access: public`; `validate-packages` enforces them. CI publishes all packages to npm with provenance after a green push to `main` that carries a version not yet on npm, then tags it and creates a GitHub release (needs the `NPM_TOKEN` secret).

* Billing and spend tracking for people, teams, departments, projects and organizations. Guide `docs/billing.md`.
  * New `billing` API group (meters: `createMeter`, `updateMeter`, `deleteMeter`, `listMeters`; rate card: `setPrice`, `listPrices`, `quote`; usage: `record`, `recordMany`, `listUsage`; spend: `spend`, `trend`, `mySpend`, `teamSpend`, `departmentSpend`, `accounts`; budgets: `createBudget`, `updateBudget`, `deleteBudget`, `listBudgets`, `check`; credits: `grantCredit`, `revokeCredit`, `listCredits`; profiles: `getProfile`, `setProfile`, `deleteProfile`; statements: `listStatements`, `getStatement`, `previewStatement`, `closePeriod`, `markPaid`, `voidStatement`; actions `iam:billing:read|manage|record`; collections `billingMeters`, `billingPrices`, `billingUsage`, `billingRollups`, `billingBudgets`, `billingBudgetAlerts`, `billingCredits`, `billingProfiles`, `billingStatements`).
  * Platform meters (root) and organization chargeback meters; per-unit, graduated, volume and package prices with free allowances and negotiated prices per organization; `sum` and `unique` (active users) meters; reported-cost meters. Each meter's monthly total is priced once per billing account and shared out by quantity, so person, team, department and project spend add up to the account's charges. Usage is attributed at record time to the identity, their teams and department (agents to their sponsor's), with tags and idempotency keys.
  * Spend budgets per month, quarter or year for a tenant, team, department or person with threshold and forecast alerts (`spend-alert` email, audit `billing:budget-alert`) and optional enforcement (`SPEND_LIMIT_REACHED`, 402). Team maintainers and department heads read their own spend without a permission.
  * Billing profiles (a project becomes its own billing account only from its parent), credits, and monthly statements with lines, credit, a breakdown by project/team/department/person and a content hash (`billing-statement` email, `TemplateLinks.billing`); late usage for an invoiced month fails with `BILLING_PERIOD_CLOSED`. New codes `BILLING_PERIOD_CLOSED`, `METER_ARCHIVED`, `SPEND_LIMIT_REACHED`.
  * Contract terms per billing account (`billing.setTerms`, root-only; `billing.getTerms`; collection `billingTerms`, audit `billing:terms`): a discount off the subtotal, a minimum monthly commitment with a true-up line, and tax; statements carry `discount`, `commitment` and `tax` (in the content hash when present, so earlier statements still verify).
  * Policy context keys `principal.spendExceeded` (an enforced budget covering the principal is spent) and `principal.budgetsExceeded` (names of spent covering budgets), read only when a document names them; reserved as identity attribute names.
  * Insights: spend spikes (`billing.anomalies`; daily job `iam.billing.detectAnomalies` with audit `billing:anomaly` and the `spend-anomaly` email), showback (`spend({ shareUnattributed: true })` spreads unattributed spend over people, teams or departments by their share), and CSV exports (`billing.exportSpend`, `billing.exportStatement`; console downloads).
  * `iam.billing` (`record`, `recordMany`, `check`, `spend`, and the jobs `checkBudgets`, `recordSeats`, `closePeriod`, `detectAnomalies`), option `billing` (currency, time zone, team attribution, payment terms, statement prefix, usage retention), `ctx.billing.recordPriced` (inference calls land on the built-in `inference` meter). CLI `billing-close`, `billing-alerts`, `billing-seats`, `billing-anomalies`, `spend`. Console: organization Billing page (spend by person/team/department/project/meter/day, trend, budgets, statements, meters, profile, chargeback), statement page, and the admin Billing page (accounts, close a month, credit, prices, mark paid/void).
  * Invoicing in the style of Stripe and Orb. Invoice lifecycle `draft` → `finalized` → `paid` / `uncollectible` / `void` (`billing.finalizeInvoice`, `markUncollectible`; `closePeriod({ draft })` and option `billing.autoFinalize`); invoice items (`createInvoiceItem`, `listInvoiceItems`, `deleteInvoiceItem`; negative amounts are credits, a negative balance becomes account credit); payments (`recordPayment`, partial and over-payments; trusted `iam.billing.recordPayment` with an idempotency key for payment-processor webhooks); credit notes (`createCreditNote`, `listCreditNotes`, numbered `{invoice}-CN-01`); payment reminders (`iam.billing.sendPaymentReminders`, option `billing.paymentReminderDays`, `payment-reminder` email, CLI `billing-reminders`); a printable invoice (`renderInvoice`, option `billing.issuer`). Plans with fees, seats and meter prices, billed in advance or arrears (`createPlan`, `updatePlan`, `listPlans`); subscriptions with trials, prorated first invoices, seat changes, plan changes and cancellation (`subscribe`, `updateSubscription`, `changePlan`, `cancelSubscription`, `resumeSubscription`, `listSubscriptions`; self-serve plans for account billing managers); coupons (`createCoupon`, `deactivateCoupon`, `listCoupons`, `redeemCoupon`, `listDiscounts`, `removeDiscount`). Invoice lines show the tiers a quantity used, free units, service periods and prorations; prices take `minimumAmount` / `maximumAmount`. Invoice amounts are whole cents. Summaries gain `amountPaidMicros`, `amountDueMicros`, `billingReason`; accounts `outstandingMicros`; `closePeriod` results `drafted`. Collections `billingInvoiceItems`, `billingCreditNotes`, `billingPlans`, `billingSubscriptions`, `billingCoupons`, `billingDiscounts`. Console: invoice page (tiers, payments, credit notes, print), subscription/plans/promotion-code/next-invoice cards on the organization Billing page, and plans, subscriptions, coupons, invoice items, payments, credit notes and finalize/write-off on the admin page.

* Teams inside organizations and departments. Guide `docs/teams-and-departments.md`.
  * New `teams` API group (`create`, `update`, `delete`, `list`, `get`, `listMembers`, `addMember`, `addMembers`, `updateMember`, `removeMember`, `candidates`, `listForIdentity`, `listMine`, `requestToJoin`, `cancelRequest`, `listRequests`, `approveRequest`, `denyRequest`, `leave`, `reconcile`; actions `iam:teams:create|read|update|delete`; collections `teams`, `teamMembers`, `teamJoinRequests`). Teams nest (`parentId`, ten levels), have maintainers who manage membership without an administrator permission (`memberManagement: 'admins'` turns that off), take join requests (`joinPolicy: 'request'`, emails `team-join-request` / `team-join-decided`, `TemplateLinks.team`), and grant access through a team-managed backing group (`team.groupId`, `Group.teamId`) that holds the members of the team and of every team below it. The groups API, access packages, invitations, and onboarding refuse backing groups (new code `TEAM_MANAGED`, 409); configuration sync ignores them.
  * New `departments` API group (`create`, `update`, `delete`, `list`, `tree`, `get`, `listMembers`, `assign`, `unassign`, `ofIdentity`, `mine` (a person's own department and, for heads, the people they lead, no permission), `importFromAttribute`, `syncManagers`; actions `iam:departments:read|manage`; collections `departments`, `departmentMembers`): a department tree with heads, codes and cost centers, one department per person, import from a string identity attribute, and department heads as managers for manager-routed approvals.
  * Policy context keys `principal.teams`, `principal.departments`, `principal.departmentId` (read only when a policy names them; reserved as identity attribute names). Offboarding reports `teamsLeft` and `departmentsReassigned` when non-zero. Helpers `teamsOf`, `primaryTeamOf`, `teamMaintainers`, `isTeamMaintainer`, `departmentOf`, `departmentPath`, `departmentHeads`. Access analysis reports `team-maintainers-grant-admin` (high), `team-without-maintainer`, and `department-without-head` (finding subjects gain `team` and `department`). Configuration as code gains the `teams` (by slug: parent, department, join settings, maintainers, members, roles) and `departments` (by name: code, parent, head, cost center, members) kinds (`TenantConfigTeam`, `TenantConfigDepartment`; change kinds `team`, `department`).
  * Birthright access by org structure: access package rules may test `identity.teams` (a person's teams and those above them) and `identity.departments` (their department and those above it); team and department changes made through the API reconcile the people they touch at once; a team or department a rule names cannot be deleted (`RESOURCE_IN_USE`); configuration documents name them by slug and name (teams and departments are applied before packages, deleted after); console team and department pages list their birthright packages. A team membership copied by team sync counts only through a group membership no package created. `departments.suggestBirthright` / `teams.suggestBirthright` (`iam:analysis:read`) propose such packages from the roles and groups most of a unit already holds by hand, each ready for `packages.create` (console: "Suggested birthright access" with a Create package button). Team membership reviews: `teams.startReview` / `decideReview` / `completeReview` / `cancelReview` / `getReview` / `listReviews` (maintainers decide keep or remove, nobody decides on themselves, removals apply on completion with `onUndecided` for the rest), the scheduler job `iam.closeOverdueTeamReviews()`, email `team-review-requested`, audit `team:review:*`, `listMine.reviews`, collections `teamReviews` / `teamReviewItems`, and a Membership review card on the console team page.
  * Console: Teams and Departments pages (org chart, join requests, maintainer view), teams and department on member pages, team-backed groups marked on the Groups page. React and Vue: `useTeams` (`@better-iam/react`, `@better-iam/vue`). The retention sweep (`sweepExpired`) deletes ended team memberships (`teamMembers`). Team sync: `Team.syncGroupIds` keeps a team's members in step with up to ten ordinary groups (SCIM directory groups included, through the protocol host's group sync); synced memberships carry `source: 'sync'`, mirror the source membership's end, and can only be changed through the source group; `teams.reconcile` also returns `synced`; the config `teams` kind gains `syncGroups`.

* AI agents as accounts, delegation, and MCP authorization. Guide `docs/agents.md`.
  * `Identity.kind` gains `'agent'` with an `Identity.agent` profile (`AgentProfile`: `sponsorId`, `model`, `provider`, `purpose`, `url`, `protocols`, `delegable`, `maxDelegatedSessionSeconds`, `boundary`, `suspended`). New `agents` API group (`create`, `get`, `list`, `listMine`, `update`, `suspend`, `resume`, `delete`, `standing`; actions `iam:agents:create|read|update|delete`; plan limit `TenantLimits.agents`). An agent's keys (`credentials.create` now accepts agents) work only while its sponsor is an active person of the tenant; sponsors can suspend their agents without a permission; offboarding hands agents to the successor (`agentsReassigned`, or `agentsUnsponsored` without one). Audit `agent:create|suspend|resume|sponsor-change`. New code `INVALID_SPONSOR`.
  * Delegation: new `delegations` API group (`grant`, `request`, `approve`, `deny`, `revoke`, `get`, `list`, `listMine`, `assume`; actions `iam:delegations:read|revoke`; collection `delegations`). A person lets an agent act for them within a scope; the agent opens `biam_dlg_…` sessions of the new kind `'delegated'` (`Session.agentId`, `Session.delegationId`) that act as the person, bounded by the delegation scope, the agent's `boundary` and an optional scope-down policy, re-validated on every use. Agents can request consent (`delegation-request` email template, `TemplateLinks.delegation`). Offboarding or deleting a person revokes their delegations. New codes `DELEGATION_EXISTS`, `DELEGATION_PENDING`, `DELEGATION_INACTIVE`, `DELEGATION_NOT_ALLOWED`.
  * Policy context keys `principal.delegated` (always present), `principal.delegationId`, `principal.agentId`, `principal.agentSponsorId`, `principal.agentModel`, `principal.agentProvider`; `principal.kind` is `agent` for an agent's own key. `AuditSessionContext` and `sts.getCallerIdentity` carry `agentId` and `delegationId`. Delegated sessions are temporary (`temporarySessionKinds`), never recent, owner or root, cannot mint session tokens or assertions, and are swept by `sweepExpired`. `credentialTokenKinds` gains `dlg`; update secret-scanning rules to `credentialTokenScanPattern`.
  * Human-in-the-loop confirmations: a delegation's `confirm` action patterns hold those actions back until the person approves each call (`delegations.requestConfirmation` by the delegated session, `decideConfirmation` by the person, `listConfirmations`, `getConfirmation`; collection `delegationConfirmations`; email template `delegation-confirmation`; audit `delegation:confirmation-request|confirm|reject`). An approval opens exactly that action on that resource for `validSeconds`.
  * Accountability: `agents.activity` (everything an agent did, for its sponsor or `iam:agents:read`), `delegations.activity` (everything done under a delegation, for the people involved), `agents.catalog` (the delegable agents people may pick). Access analysis reports `agent-without-sponsor`, `agent-admin`, `unbounded-agent`, `broad-delegation`, and `unused-delegation`. `@better-iam/next` `IamPrincipal` accepts delegated sessions (`session.agentId`, `session.delegationId`) and agent identities. Metrics gain `better_iam_sessions_live{kind="delegated"}`.
  * Console: new "AI" section with Agents (sponsor kill switch, keys, delegation switch, sponsor hand-over), Delegations (approve requests and confirmations, grant with a confirm list, organization-wide list), and Models & budgets pages.
  * New package `@better-iam/mcp` (`better-iam/mcp`): `createMcpGate` puts tool-level authorization in front of any Streamable HTTP MCP server (Better IAM credentials decided by policy, OAuth tokens by scope, RFC 9728 metadata and challenges, refused `tools/call`, filtered `tools/list` in JSON and SSE); `createMcpAuthorizer` for SDK tool handlers. For delegated callers whose delegation holds a tool's action back, the gate files the confirmation request and returns its id in `_meta['better-iam/confirmationId']`. `tenantId` is required: decisions are made in the organization that runs the server, never in the caller's own tenant, and OAuth tokens for another tenant are refused. Bodies are read with a byte limit and forwarded as parsed.
  * Inference model `fallbacks` (up to five): when a provider is unreachable or answers 429/5xx/529, the gateway tries the next fallback the caller may use in the same wire format, metering the failed attempt and naming the serving model in `x-better-iam-model` with `x-better-iam-fallback-from`. Also in the `inferenceModels` config kind.
  * Delegation tokens: `delegations.issueToken` gives an agent acting for a person (delegated session) a short-lived JWT for one service outside Better IAM (`typ` `biam-delegation+jwt`, signed with the `a2a` card keys; `sub` the person, nested RFC 8693 `act` claims for the agent chain, `scope` from the delegation). Audiences must match `AgentProfile.tokenAudiences` of every agent in the chain (new profile field, in `agents.catalog` and the `agents` config kind). Scopes must be allowed outright by every limit on the session, and may not be actions the person confirms call by call or ones a deny among the person's own grants could touch. Audience patterns match by URL structure (`*` only as a leading host label or in the path). Lifetime is 30 s to 1 h (5 min by default); audited as `delegation:token-issue`; issued tokens are recorded (collection `delegationTokens`, swept at expiry) so `live` verification re-checks the chain, the acting key, the audience and the scopes. `@better-iam/a2a` gains `verifyDelegationToken` (offline, trusted issuers, optional replay check; `DelegationTokenError`) and `createDelegationTokenCache`. `iam.a2a.verifyDelegationToken(token, { audience, live })` verifies in the deployment, with an optional live check of the chain (`DELEGATION_TOKEN_INVALID`). Core exports `readDelegationTokenClaims`, `delegationActor`, `delegationTokenType`, `delegationTokenLimits`, `delegationTokenScopePattern`.
  * Provider-run tool governance: `InferenceModel.providerTools` (`allow` by default, `deny`, or `policy`) controls tools the provider runs itself (web search/fetch, code execution/interpreter, file search, image generation, computer use, remote MCP servers). With `policy`, each tool is decided as `inference:use-tool` on the new resource type `model-tool` (`model-tool/web_search`, `model-tool/mcp:{host}`; attributes `kind`, `host`). The gateway reads tools from Anthropic `tools`/`mcp_servers`, OpenAI Responses `tools` and Chat Completions `web_search_options`, and refuses with 403 `TOOL_NOT_ALLOWED` before the provider is called (a Responses `prompt` counts as the tool `prompt`; non-canonical MCP URLs read as `mcp:invalid`; more than 256 tools or 64 MCP servers are refused); `InvocationCheck` reason `TOOL_NOT_ALLOWED` with `tool`. `inference.check` and `iam.inference.authorize` take `tools`. Exports `providerToolsOf`, `inferenceToolAction`, `inferenceToolResourceType`, `ProviderToolsMode`; `providerTools` is in the `inferenceModels` config kind.
  * Inference gateway endpoints: OpenAI Responses (`POST /v1/responses`, `max_output_tokens` capped, usage from `response.completed` when streamed), Embeddings (`POST /v1/embeddings`) and Anthropic token counting (`POST /v1/messages/count_tokens`, checked but not metered). Responses conversations stay with their caller: `previous_response_id` must name a response the same person (through the same agent) created through the gateway, else 404 `RESPONSE_NOT_FOUND`; `conversation`, `background` and references to objects stored at the provider (`item_reference`, `file_id`, Anthropic `file` sources, existing containers) answer 400 `UNSUPPORTED_PARAMETER`; a continued response must be at the provider that answered it. Streams that end without the provider's final counts are metered from estimates (request size, generated characters) rather than as free. New collection `inferenceResponses` (swept with usage records), `GatewayRuntime.ownsResponse`, exported `UsageFormat`.
  * Access analysis reports `open-handoff` (delegations handed on to any agent) and `agent-denials` (agents refused 20+ times in a day).
  * `agents.suspendAll` (organization-wide emergency stop, optionally by sponsor, provider or model; audit `agent:suspend-all`) and `agents.directory` (the current attested A2A cards of the tenant's agents in good standing, by skill or protocol; collection `agentCards`, filled by `signCard`, swept when expired).
  * Configuration as code for AI: new kinds `agents` (by name, sponsor by email, profile), `inferenceModels` (the tenant's own models, provider by name) and `inferenceBudgets` (subject `tenant`, `{ group }`, `{ identity }` or `{ agent }`) in `config.export|plan|apply` (new `server/src/ai-sync.ts`; change kinds `agent`, `inferenceModel`, `inferenceBudget`). Keys, provider keys and delegations stay runtime state.
  * Delegation spending caps: `spend: { period, maxCostUsd?, maxTokens?, maxRequests? }` on `grant`, `request`, `approve` (`null` removes) and `handoff` caps the AI model calls made under a delegation and the hand-offs below it (checked and metered like an inference budget; `BUDGET_EXCEEDED`, audit on `delegation:{id}`); summaries report `spend` with what the current window used. Console Delegations page shows "AI spend: $x of $y today".
  * Hand-offs between agents: a person may let their agent hand parts of a delegation on to other agents (`handoff: { agents?, depth? }` on `grant`, `request`, `approve`; new method `delegations.handoff` for the delegated session; audit `delegation:handoff`). A hand-off is a delegation with `parentId` and `chain` that the receiving agent assumes with its own key. It is bounded by every delegation above it, every agent's ceiling in the chain, and the handing session's scope-down policy, key scopes and key issuer, and it is checked link by link on every use. Revoking a delegation revokes the hand-offs below it (`handoffsRevoked`), the person's `confirm` list travels down, and `delegations.activity` includes the hand-offs' events. A hand-off is bound to the handing agent's API key (ends with it, never outlasts it), the handing agents' budgets count its calls, and an agent's requested `handoff` takes effect only when the person restates it on `approve`. New policy context key `principal.delegationChain`. `inference.record` meters a ticket as the caller the check saw, even after their session ended.
  * Agent-to-agent (A2A): option `a2a` (card-signing keys, `jwksUrl`, `cardLifetimeSeconds`, `issuer`) and `agents.signCard`, which attests an agent's A2A card. IAM confines the card's endpoints to the agent's registered origin, sets `provider.organization` to the tenant, adds the extension `urn:better-iam:a2a:attestation:v1`, and signs the RFC 8785 canonical card as a detached JWS (audit `agent:card-sign`). `iam.a2a.jwks()` and `jwksResponse()` publish the keys. `@better-iam/core` exports `canonicalizeJson` (RFC 8785).
  * New package `@better-iam/a2a` (`better-iam/a2a`): `verifyAgentCard` and `discoverAgent` check that a remote agent is attested by a trusted deployment; `createCardAttestor` keeps an agent's served card signed; `createA2aGate` and `createA2aAuthorizer` authorize A2A JSON-RPC servers in a required `tenantId`. They decide messages by skill or an agent-wide rule, keep tasks and conversations (and `referenceTaskIds`) private to the caller who started them, refuse ambiguous skills and non-POST requests, filter the extended card's skills, and ask a person to confirm when their delegation requires it (`A2A_ACCESS_DENIED` -32050, `A2A_CONFIRMATION_REQUESTED` -32051). `trustedIssuers` binds verification keys to attestation issuers. `withHandoff` / `handoffOf` (`handoffMetadataKey`) carry a delegation hand-off in an A2A message's metadata to the called agent.
  * React: `useDelegations`, `useConfirmations`, `useAgentCatalog` and `useModels` (`@better-iam/react`). The key that opens a delegated session now also caps that session (its scopes or session policy, and its issuer's authority).

* Inference access control for AI models (`inference` option). Guide `docs/inference.md`.
  * Resource type `model` and action `inference:invoke`: model access is an ordinary policy decision with model attributes (`resource.tier`, `resource.provider`, prices, ...). New `inference` API group (`createProvider`, `updateProvider`, `deleteProvider`, `listProviders`, `createModel`, `updateModel`, `deleteModel`, `listModels`, `listMine`, `setBudget`, `deleteBudget`, `listBudgets`, `usage`, `myUsage`, `check`, `record`; actions `iam:inference:manage|read|record`; collections `inferenceProviders`, `inferenceModels`, `inferenceBudgets`, `inferenceCounters`, `inferenceUsage`, `inferenceTickets`).
  * Provider keys (`anthropic`, `openai`, `openai-compatible`) are sealed with the deployment secret and re-sealed by `rotateSecrets`; custom base URLs need root unless `inference.allowCustomBaseUrls`. Models and providers are inherited by sub-tenants.
  * Budgets cap tokens, cost and/or request count (`maxRequests`) per minute (a rate limit), hour, day or month for a tenant, a group or an identity (an agent's budget covers its delegated sessions too), shared or per identity, optionally over some models; `BUDGET_EXCEEDED`, audit `inference:budget-exceeded` and `inference:budget-alert`. Every call is metered (`inferenceUsage`, cost in micro-dollars) and reported by identity, agent, model or day.
  * `iam.inference`: `gateway()` (Anthropic `POST /v1/messages`, OpenAI `POST /v1/chat/completions`, `GET /v1/models`; streaming passthrough with usage capture; provider keys never leave the server; output held to the model's `maxOutputTokens`), `authorize`, `record`, `models`. External gateways use `inference.check` tickets and `inference.record`. Exports `createInferenceGateway`, `usageFrom`, `usageCost`, `budgetWindow`. Metered calls also feed the billing ledger as the `inference` meter when billing is configured. The console mounts the gateway at `/api/ai`.

* Customizable onboarding at platform, organization, and project level. Guide `docs/onboarding.md`.
  * New `onboarding` API group (`createFlow`, `updateFlow`, `deleteFlow`, `listFlows`, `getFlow`, `effective`, `setSettings`, `mine`, `submitStep`, `setup`, `submitSetupStep`, `progress`, `memberProgress`, `verifyStep`, `resetProgress`; actions `iam:onboarding:read|manage`; collections `onboardingFlows`, `onboardingSettings`, `onboardingProgress`).
  * Member flows onboard people (forms, acknowledgements, self-attested or administrator-verified tasks, terms of use, email verification, MFA, passkeys); tenant flows are setup checklists for the organizations or projects below, with live checks (verified domain, members, owners, MFA policy, terms of use, sign-in alias, SSO, directory sync, member onboarding).
  * Flows defined at the platform reach every tenant below; organizations and projects add their own, switch off unlocked inherited flows, and override the welcome screen. Flows can target people with the access-package rule language, ask only newcomers (or everyone with `includeExisting`), fill empty identity attributes of the defining tenant's own people from answers, and add people to groups on completion.
  * Policy context keys `principal.onboarding` and `principal.pendingOnboarding` (read only when a condition names them). Audit events `onboarding:step`, `onboarding:complete`, `onboarding:verify`, `onboarding:reject`, `onboarding:reset`, `onboarding:groups`. Deleting a person removes their answers.
  * Console: Get started checklist and banner, organization/project Onboarding page with a flow builder and templates, Setup checklist page and overview card, and a platform Onboarding page in the administration panel.

* Organization sign-in addresses, custom hostnames, and regions. Guide `docs/hosts-and-regions.md` (site: Deployment → Sign-in addresses and regions).
  * `hosts.patterns` gives every organization with an alias its own address, like an AWS account sign-in URL: `'{tenant}.signin.example.com'`, with the region if you like (`'{tenant}.signin.{region}.example.com'`), and `'{tenant}.localhost:3000'` in development. Requests on an organization's address are pinned to it: public sign-in calls act in it (`tenantId` may be left out), and another organization's `tenantId`, session, API key, or page is refused with `HOST_MISMATCH` (403). Organization origins are trusted like the deployment's own; cookies stay host-only. `nodeHandler` now keeps the request's `Host` when addresses are configured, and `hosts.forwardedHost` reads `X-Forwarded-Host` behind your own proxy.
  * Custom hostnames (`hosts.customHostnames`): new `hostnames` API group (`add`, `list`, `verify`, `setPrimary`, `delete`; actions `iam:hostnames:*`; collections `tenantHostnames`, `hostnameOwners`) verifies `login.acme.com` with a DNS TXT record (through `domains.resolveTxt`) and returns the CNAME to publish (`hosts.cnameTarget`). New codes `HOSTNAME_TAKEN` (409) and `HOSTNAME_NOT_ALLOWED`. Passkey ceremonies on a hostname outside the RP ID answer `FEATURE_DISABLED`.
  * Regions (`regions: { current, regions, locate? }`): `Tenant.region` (inherited from ancestors; organizations under the root default to the creating region), `tenants.create({ region })`, root-only `tenants.setRegion` (audit `tenant:region`). `tenants.lookup`, `domains.discover`, public sign-in calls, and organization addresses answer `WRONG_REGION` (421) with `region` and `location` (the sign-in URL in the home region) everywhere else; `IamClientError` gains `region` and `location`. `regions.locate(alias)` redirects aliases that live in another region's database.
  * `tenants.lookup({ host })`, and `region` / `signInUrl` in `tenants.lookup` and `domains.discover` results. `iam.hosts` (`region`, `resolve`, `signInUrl`, `allowed` for on-demand TLS checks). Every email and SMS message carries `signInUrl`, which `renderDeliveryMessage` passes to link builders; link builders now also receive the tenant ID for sign-in emails whose payload lacks it.
  * The Next.js and Express/Hono/Fastify/SvelteKit integrations keep the visitor's host on in-process calls, so server actions and loaders are pinned too.

* Developer experience: the CLI is rebuilt on a declarative command registry, and everything it does is available from code. Guide `docs/cli.md`.
  * Every existing command keeps its flags, error codes, and results. New for all of them: `--flag=value`, `better-iam help <command>` / `<command> --help` with each flag's default and environment variable, "did you mean" suggestions, `help --json` (a machine-readable manifest, also `cliManifest()`), `--format json|compact|table` and `--query PATH`, `CODE: message` plus a `Hint:` line on failure, and exit status 2 for command-line mistakes (1 otherwise).
  * Configuration discovery: `--config`, then `BETTER_IAM_CONFIG`, then the nearest `better-iam.config.{mjs,js,ts,mts,cjs}` in the working directory or a parent, then `BETTER_IAM_DATABASE_URL` + `BETTER_IAM_SECRET` with no file (`configFromEnv`). Factories receive `{ command, env, cwd }`. A configuration module may export `cli = { defaults }` (flag defaults per command or `'*'`) and `commands` (project commands built with `defineCommand`, which get the same parsing, help, output, and completion). `init --typescript` writes `better-iam.config.ts`; the template is a side-effect-free `defineConfig` factory.
  * Token commands (`config-*`, `analyze`, `report`, `mine-roles`, `check-invariants`, `whoami`, and the new ones below) run in process or against a running server with `--url` / `BETTER_IAM_URL`, and read `--tenant` from `BETTER_IAM_TENANT`.
  * New commands: `api GROUP.METHOD` calls any HTTP API route (HTTPie-style `key=value`, `key:=json`, `key:=@file.json`, nested keys, `--data @file|-`; `api --list` shows every route and whether it needs a credential); `login` (password from `BETTER_IAM_PASSWORD` or a hidden prompt, authenticator or `--email-code` MFA, `--with-token` for API keys) saves sessions as profiles in an owner-only credentials file, with `logout`, `profiles [use|remove]`, and `token`; `can`, `explain`, and `who-can` answer authorization questions; `config-validate` checks tenant configuration offline (`--strict` fails on names the file does not define); `secret` prints a new deployment secret; `completion bash|zsh|fish|powershell`.
  * Configuration as code: `config-plan` / `config-apply` / `config-validate` accept `.mjs`/`.js`/`.ts` modules whose default export is the configuration or a factory of `{ tenantId, env }`, and `config-export --output x.ts|x.mjs` writes a typed module.
  * Code: `@better-iam/server` exports `defineConfig`, `configOptions`, and `defineTenantConfig`; `@better-iam/cli` exports `runCli`, `createCli`, `defineCommand`, `main`, `runBinary`, `loadConfig`, `configFromEnv`, `findConfigFile`, `lintTenantConfig`, `loadTenantConfig`, `localTransport` / `remoteTransport`, `listRoutes`, `createProfileStore`, `formatResult`, and `selectPath`. `runCli`'s `io` gains optional `err`, `prompt`, `stdin`, `cwd`, and `fetch`. The umbrella `better-iam` package now installs the `better-iam` binary.
  * Saved sessions stay with the deployment that issued them: a profile's token is never sent to another `--url` / `BETTER_IAM_URL` or used with another `--config` (the command fails with `MISSING_ENV` and says why), and `login` refuses (`PROFILE_IN_USE`) to replace a profile saved for another deployment unless `--profile` names it. `--url` must be `https://` except for localhost, and `--url` with `--config` is refused.

* Feature flags at platform and tenant level. Guide `docs/feature-flags.md`.
  * New `features` API group: `create`, `update`, `delete`, `list`, `setTarget`, `listTargets`, `setOverride`, and `evaluate`. Catalog actions `iam:features:read`, `iam:features:manage`, and `iam:features:override` act on `iam/features/{key}`. Audit actions `feature:create`, `feature:update`, `feature:delete`, `feature:target`, and `feature:override`. New error `FEATURE_LOCKED`.
  * Flags on the root tenant are platform flags. Any other tenant's flags reach its own subtree, and a key belongs to the tenant closest to the root, so tenants cannot shadow platform flags.
  * Resolution order: kill switch, then the closest target or tenant override (a tenant's override beats a target on the same tenant, and a `locked` target silences overrides beneath it), then a stable percentage rollout per branch, then the default. Targets can lapse (`expiresAt`), and `internal` flags stay hidden from tenants.
  * `iam.features` (`evaluate`, `values`, `isEnabled`) for server code. React hooks `useFeatureFlags` and `useFeatureFlag`. `rolloutBucket` is exported.
  * Decisions expose the flags that are on as the `tenant.features` context key, read only when a condition names it and stripped from `resolveContext`/plugin context. `policies.test` fills it in, and policy lint knows it.
  * Console: an Administration "Feature flags" page and an organization "Features" page.
  * Collections `featureFlags` and `featureTargets` are removed with their tenant on purge.

* Temporary credentials (STS): AssumeRole parity, session tokens, signed session JWTs, and OIDC web-identity federation. Guide `docs/temporary-credentials.md`.
  * `roles.assume` takes `sessionName`, `sourceIdentity`, `tags`, `format` and `audience`, and accepts API keys and session tokens as sources. The source's scopes or session policy bound only the `iam:roles:assume` decision (re-checked on every use), not the role session, which acts with the role's permissions within the trust `ceiling` and its own `policy`. Durations are capped by the new `sts.maxRoleSessionSeconds` (default 3600, up to 43200) and a per-trust `maxSessionSeconds`; a duration outside the range is `INVALID_INPUT`. `role:assumed` is recorded in the target tenant.
  * Identity trusts gain `maxSessionSeconds`, `passSourceAttributes`, `allowedTagKeys`, `sourceIdentityMode` and `description`; new root-only `trust.update`. New `analysis` finding `trust-passes-foreign-attributes`.
  * `sts.getSessionToken` (`iam:session-tokens:create`): a `biam_sts_` token for the caller's own identity from a user session or API key, bounded by an optional `policy` and the source's scopes, with an optional TOTP `mfaCode` step-up (`auth:mfa:step-up`) for the MFA-then-assume pattern; capped by `sts.maxSessionTokenSeconds` and `sts.maxSessionTokensPerIdentity`. New error `CREDENTIAL_CHAINING_DISABLED`.
  * `sts.getCallerIdentity` for every credential kind (CLI `better-iam whoami`), and `roles.listSessions` with allowlist projections that never carry hashes, policies or authority ids.
  * Revoke older sessions: `roles.revokeSessions`, `trust.revokeSessions` and `oidcProviders.revokeSessions` (`iam:roles:revoke-sessions`) move a `sessionsRevokedBefore` watermark and delete the matching rows; `trust.revoke` now deletes the trust's live sessions. `identities.revokeSessions({ keepApiKeys: true })` ends everything but the API keys. `oidcProviders.update` also ends every session issued through the provider (watermark plus deletion, for good) when it changes `jwks`, `jwksUri`, `algorithms`, `audiences`, `maxTokenLifetimeSeconds` or `clockToleranceSeconds`, or disables the provider; a rename, a `replayProtection` change or enabling keeps them. Rotating static `jwks` therefore makes workloads exchange again.
  * Session JWTs (`format: 'jwt'`, EdDSA/ES256) with the new `sts.jwt` option, `GET {basePath}/.well-known/jwks.json`, `iam.sessionTokens` (`jwks()`, `verify()`), and the runtime-neutral `createSessionTokenVerifier` (`@better-iam/server/session-tokens`, umbrella `better-iam/session-tokens`) with `verifyRequest`. IAM accepts a JWT bearer only with a matching stored session row.
  * Web identity (`sts.webIdentity`, off by default): tenant-managed `oidcProviders`, `trust.create({ kind: 'web-identity' })` with claim `conditions` over `token.<claim>` keys (a `token.sub` pin is mandatory, else `WEAK_TRUST_CONDITIONS`), the public `sts.assumeRoleWithWebIdentity` exchange (uniform `WEB_IDENTITY_REJECTED`, per-trust rate limit, single-use replay protection, SSRF-guarded key fetch), and the `trust.evaluateWebIdentity` dry run.
  * Session-aware policy keys: `principal.sessionId`, `tokenIssueTime`, `authTime`, `mfaTime`, `sessionTagKeys`, `sessionTags.<key>`, `sourceTenantId`, `sessionName`, `sourceIdentity`, `webIdentityProvider`, `webIdentitySubject`, and `request.sourceIp`; audit events and webhook bodies carry `sessionContext`. New audit actions `role:assumed`, `role:assumed-with-web-identity`, `session-token:issued`, `role:sessions-revoked`, `auth:mfa:step-up`; new catalog actions `iam:trust:update`, `iam:roles:revoke-sessions`, `iam:session-tokens:create`, `iam:oidc-providers:*`. `@better-iam/server` now depends on `jose`.
  * Integrations: the client accepts JWTs and prefixed tokens, `@better-iam/next` principals add kind `session-token` with `sessionName`/`sourceIdentity`, the NestJS guard's CSRF check matches the bearer scheme case-insensitively, and the console labels the new kinds.
  * Migration: new tokens are prefixed and checksummed (`biam_ses_`, `biam_key_`, `biam_rol_`, `biam_sts_`, 58 characters; `credentialTokenScanPattern` for scanners). Legacy unprefixed tokens still work until they expire. Tooling that assumed 43-character tokens needs updating.
  * Migration: `auth.requireRecent` now refuses temporary credentials (role sessions and session tokens) with `RECENT_AUTH_REQUIRED`. Role sessions previously passed for five minutes after their source signed in; perform those operations from a signed-in session.
  * Migration: sign-in results no longer carry `tokenHash`: `SessionResult.session` is now `SafeSession` for every flow, in process and over HTTP.
  * Migration: new cross-tenant identity trusts default to `passSourceAttributes: false`, so source attributes no longer reach their role sessions unless enabled. Existing trusts keep passing them; turn it off with `trust.update` (the new finding lists them). `trust.create` and `trust.list` return the public projection (`requiresExternalId`, never `externalIdHash`), and the `roles.assume` response is a superset of the previous `{ token, session }`.
  * Migration: `credentials.revoke` only accepts API keys (`INVALID_CREDENTIAL` for any other session id).
  * Migration: the new server-owned context keys (and every existing `principal.*` key, `request.time`, `request.sourceIp` and `principal.sessionTags.*`) now strip same-named values supplied by `resolveContext` or plugins, even when the server leaves the key absent. The new session names are reserved: identity attributes named `sessionId`, `tokenIssueTime`, `authTime`, `mfaTime`, `sourceTenantId`, `sessionName`, `sourceIdentity`, `sessionTags`, `sessionTagKeys`, `webIdentityProvider` or `webIdentitySubject` fail with `INVALID_CONFIG`.

* React Router: new package `@better-iam/react-router` (umbrella `better-iam/react-router`) for framework mode, v7.9+ and v8. `createIamRouter(iam, options)` gives:

  * `middleware` for the root route: per-request helpers in the router context, with cookies from the in-process client put on the response
  * `api` for an `api/iam/*` resource route
  * `guard(loader, spec)`: login and step-up redirects (`.data`-aware `next`), and a 403 `data({ code, message })` for error boundaries or `deniedRedirect`
  * `action(fn, spec)`: an Origin check first, then IAM refusals returned as `data({ code, message }, { status })`
  * `helpers(args)`, `requireSession`, `require`, and `sessionData`

  Guide `docs/react-router.md`, runnable `examples/react-router` (react-router-serve, typegen + `tsc`, smoke test), tests `tests/react-router.test.ts`.

* Express, Hono, and Fastify: new package `@better-iam/middleware` (umbrella `better-iam/express`, `better-iam/hono`, `better-iam/fastify`, and `better-iam/middleware`). `createIamExpress` / `createIamHono` / `createIamFastify` do four things:

  * serve the IAM API (Express and Fastify through `iam.nodeHandler`, so node-only protocol mounts listed in `serve` work too; Express also accepts bodies a parser already consumed and restores a stripped mount path)
  * attach per-request helpers (`req.iam`, `c.get('iam')`, `request.iam`): memoized `getSession`, `requireSession`, `require`, batched `can`, `authorize`, `listAccessible`, `assertion`, `credential`, `signOut`, and an in-process typed `client` whose cookies land on the response
  * provide `requireSession({ stepUp })` / `authorize(action, { resource, tenantId, stepUp })` route guards, which also refuse cookie-authenticated unsafe requests from untrusted origins (`UNTRUSTED_ORIGIN` / `CSRF_REJECTED`; `trustedOrigins`, `csrf: false`, exported `checkRequestOrigin`)
  * map refusals to the IAM JSON envelope, or to `loginPath` / `stepUpPath` redirects for page navigations (`errorHandler` / `onError`)

  The framework-neutral core (`createRequestHelpers`, `enforceGuard`, `refusalResponse`, `checkStepUp`, `IamRequestError`) now also backs `@better-iam/svelte/kit`. Guide `docs/node-frameworks.md`, tests `tests/middleware.test.ts` (real Express and Fastify servers, a Hono app).

* SvelteKit and Svelte: new package `@better-iam/svelte` (umbrella `better-iam/svelte` and `better-iam/svelte/kit`). `createIamKit(iam, options)` gives SvelteKit apps:

  * a `handle` hook that serves the IAM API and enforces `protect` path rules (login redirect with `?next=`, step-up page, `authorize` with `deniedRedirect` or a 403 carrying the IAM code)
  * per-request `event.locals.iam`: `getSession`, `requireSession`, `require`, batched `can`, `authorize`, `listAccessible`, `assertion`, `credential`, `signOut`, and an in-process typed `client` whose `Set-Cookie` answers land in `event.cookies`, so sign-in forms work without JavaScript
  * `guard(load, spec)` for server loads, `action(fn, spec)` for form actions (IAM refusals become `fail(status, { code, message })`), `sessionData(event)`, `safeRedirectPath`, and `checkStepUp`

  The browser entry `createIam({ client, initialSession })` gives Svelte 4/5 stores: `session`, `authorize`, `can`, and `accessible`. Inputs can be stores, and `initial` values come from server loads, so hydration needs no second request. It also exports `setIamContext` / `getIamContext`. Guide `docs/sveltekit.md`, runnable `examples/sveltekit` (adapter-node, smoke test, `svelte-check`), tests `tests/svelte.test.ts` and `tests/svelte-kit.test.ts`.

* Storage queries run in SQL: `find()` filters become typed JSON conditions evaluated by the database, with pagination in SQL when the whole filter is expressible. Hot lookup fields (`INDEXED_FIELDS`: session token hashes, identity/group/role ids, email, OAuth artifact hashes, delivery queues) are indexed: partial expression indexes on SQLite/libSQL, one `jsonb_path_ops` GIN index on PostgreSQL, and id-ordered tenant and natural-key indexes. A session lookup among 20,000 sessions on SQLite fell from \~26 ms to \~0.25 ms. The SQLite adapter caches prepared statements. Schema steps are recorded by name in `iam_migrations` (new migration `0002_query_indexes`). Results are ordered by id in code-point order on every adapter. Identifiers (collection, id, tenant, natural key) with an unpaired surrogate are refused (`INVALID_RECORD`) instead of silently aliasing the U+FFFD spelling; reads and deletes treat them as absent. Field values still accept any string; the PostgreSQL adapter stores U+0000 and unpaired surrogates in a reversible jsonb-safe encoding (`encodeJsonbDocument` / `decodeJsonbDocument`), and migration `0002` rewrites existing rows that need it. SQLite and libSQL compare numbers by their JSON text, so integers above 2^53 and extreme exponents match exactly. PostgreSQL migrations wait up to ten minutes for another instance's migration. New exports: `RecordDriver.query`, `planQuery`, `MAX_QUERY_CONDITIONS`, `JSONB_ESCAPE_KEY`, `sqliteSelect`, `postgresSelect`, `applyMigrations`, `schemaMigrations`, `matchesFilter`, `compareIds`, `storableString`, and the adapter conformance suite `@better-iam/core/conformance` (`adapterConformanceCases`, `runAdapterConformance`), now run against SQLite, libSQL, and PostgreSQL.

* Storage operations:
  * Store snapshots move a deployment between databases and adapters, for example SQLite to PostgreSQL. CLI `store-export --output FILE` writes a consistent JSON Lines snapshot with a header and a counting trailer. `store-import --input FILE` loads it into an empty database in one transaction and rolls back on a truncated, corrupt, or refused snapshot. `store-copy --target-config FILE` copies directly. Copying a database onto itself fails with `SAME_DATABASE`. Core exports `exportStore`, `importStore`, and `copyStore`.
  * SQLite and libSQL: a second adapter instance of the same kind on the same file, used inside the first one's transaction, now fails with `DATABASE_IN_USE` instead of waiting forever for its own caller's lock. Paths are compared after resolving links, Windows short names, and `file://` URLs. A SQLite and a libSQL adapter on one file still wait for the busy timeout and fail with `STORAGE_BUSY`.
  * Retention sweep: `iam.sweepExpired({ limit, batchSize, deliveryRetentionMs, graceMs })` and CLI `sweep [--limit N] [--retention-days N]` delete, in short batches, records that nothing uses any more and that until now accumulated forever. API keys and other session kinds, invitations, access requests, usage records, and pending deliveries are kept. Migration `0004_expiry_indexes` indexes `expiresAt`, `deliveredAt`, and `failedAt` across each collection; the sweep walks these indexes once per run and steps over records it keeps. Shared Signals deliveries now record `failedAt`, and dispatched audit hooks record `deliveredAt` (`tests/retention.test.ts`). Swept:
    * user and role sessions, trusted devices, and relationship tuples past expiry;
    * OAuth artifacts and login states (grants 31 days after expiry), and SAML request, relay, and replay records;
    * delivered or abandoned outbox messages and abandoned Shared Signals deliveries past the retention;
    * dispatched audit hook rows.
  * `find()` accepts an id cursor (`after`) for keyset pagination, served by the primary-key and tenant indexes; snapshots page with it, so exporting a large audit log does not slow down page by page.
  * Migration `0005_lookup_indexes` indexes `sourceSessionId` and `trustId` (SQLite and libSQL). Lookup indexes are now listed per schema step (`LOOKUP_INDEX_STEPS`), so released steps never change; `INDEXED_FIELDS` is their union.
  * Continuous audit archiving: the `auditArchive: { write(batch), batchSize }` option and `iam.archiveAudit()` (CLI `audit-archive [--tenant ID] [--limit N]`) copy each tenant's audit chain to independent storage.
    * Batches are verified and in chain order, and a per-tenant cursor records what has been archived.
    * A per-tenant lease lets one run at a time archive a tenant (`busy` lists the others).
    * `createJsonlAuditArchive({ directory })` is a crash-safe, write-once file sink (`ARCHIVE_CONFLICT` for a different batch under an existing name). CLI `audit-verify-archive --directory DIR --tenant ID` verifies an archive without the database.
    * Broken chains (`AUDIT_CHAIN_BROKEN`), sink failures, and gaps are reported rather than skipped.
    * Once a tenant has an archive cursor, `pruneAudit` deletes only events the archive holds, in every process, and reports `heldForArchive`; `doctor` reports `audit-archive-behind` (`tests/audit-archive.test.ts`).
    * `better-iam init` configurations read `BETTER_IAM_PREVIOUS_SECRETS`.
  * Deployment secret rotation: the new `previousSecrets` option keeps authenticator secrets, webhook secrets, pending deliveries, emailed links, and assertions sealed or signed under an old secret working. New values use `secret`. `iam.rotateSecrets()` (CLI `rotate-secrets [--dry-run]`) re-seals stored values with the current secret. `iam.assertionKeys()` lets downstream services accept both keys during the switch. `verifyAssertion`, `IamAssertionModule.forRoot`, and the Next.js `verifyAssertionToken` all accept a key list. The run reports `complete` and `done`, and `doctor` never calls a partial sample done (`secret-rotation-unverified`). The deployment guide describes a staged rollout that is safe with several instances. The outbound SCIM provisioner takes `previousEncryptionKeys` and offers `rotateKeys()`, for keys an application derives from the secret. `doctor` reports values still pending rotation, and values no configured secret opens (the secret was replaced outright). Auth exports `openSecret`, and `decryptSecret` accepts a list of secrets (`tests/secret-rotation.test.ts`).
  * Deployment self-check: `iam.selfCheck()` reports findings with a severity and a fix: schema behind, not bootstrapped, SQLite durability that can corrupt, in-memory or asynchronous-commit storage, a placeholder secret, a short metrics token, no email transport, and scheduled jobs that are not running (sweep backlog, lapsed records `purge` should have removed, stalled or abandoned outbox messages, stalled audit hooks). `doctor` prints them, and `doctor --strict` exits non-zero on any error or warning (`tests/self-check.test.ts`).
  * `doctor` adds `storage` from the new optional `IamStore.describe()`: schema version, applied migrations, record counts per collection, and adapter settings. A database that cannot be reached fails `describe()` instead of reporting as empty.
  * Ordered reads: optional `IamStore.findOrdered` and the `findOrdered` helper page by a numeric field in SQL (migration `0003_ordered_indexes`). Audit `list` and `export` and `pruneAudit` use it, so their cost follows the page size instead of the log length. Optional `IamStore.collections()` lists collections.
  * SQLite file databases default to WAL with `synchronous = FULL`; `journalMode: 'delete'` and `durability: 'normal'` opt out.
  * Session validation writes `lastSeenAt` at most once a minute, or once per tenth of the idle timeout when that is shorter.
  * Authorization reads a subject's bindings with indexed lookups per group instead of scanning the tenant's bindings.
  * `instrumentStore` and `summarizeStoreCalls` report storage calls without values; `pnpm bench:scale` measures hot paths at a configurable deployment size.
  * Tests: `tests/store-snapshot.test.ts`, `tests/store-describe.test.ts`, and `tests/retention.test.ts`.

* Security: an assumed-role session whose source account owned another tenant passed the target tenant's owner gates (`identities.setOwner`, owner offboarding) and satisfied `principal.owner` / `principal.rootAdmin` policy conditions there. Those gates now require an owner of the target tenant on an ordinary session, and role sessions report `principal.owner` and `principal.rootAdmin` as false (`tests/owner-gates.test.ts`).

* `auth.getSession` and `auth.listSessions` no longer return a session's `uniqueKey`, which held its token hash; `SafeSession` omits it.

* Role mining: `roleMining.suggest` (`iam:analysis:read`) finds direct bindings a group already covers, roles every member of a group holds directly, roles with identical grants, and role bundles many people hold together (closed itemsets, skipped when a package or inheriting role already matches); `roleMining.apply` (`iam:analysis:update`) carries out the binding suggestions in one transaction under the original grant authority; `roleMining.outliers` reports access few peers (same manager or identity attribute) hold and access most peers hold that a person lacks. CLI `mine-roles`, console Role mining page.

* Access usage tracking: the `accessUsage` option records which actions each identity was allowed to use (authorization checks and provisioning operations, not root overrides or impersonation), buffered in memory and written in batches to the new `accessUsage` / `accessUsageTracking` collections (`iam.flushAccessUsage()` writes immediately). `roleMining.usage` lists it and `roleMining.rightSize` reports bindings whose holders used none or only some of a role's actions in a window, plus per role the actions nobody used. Console: a Least privilege card on the Role mining page (the console enables tracking).

* Console redesign: grouped, collapsible sidebar sections with a "Jump to…" page filter; a Governance hub per organization (urgency-ranked "Needs attention" list and tiles for findings, invariants, separation of duties, unused access, role mining, certifications, approvals, and terms of use); a "Governance at a glance" card on the overview; and an Organization health page in the administration panel that ranks every organization by findings and broken invariants.

* Review fixes for the governance features: the usage recorder's timer and early writes no longer inherit the async context of the transaction that recorded first (which broke periodic writes and could abort `authorize`), failed writes back off; `listAccessible` counts as usage and root overrides do not; enforced invariants also guard resource deletion, root grants, campaign closes, and agreement publishing, evaluate every subject, and refuse changes that make them unevaluable; role mining no longer treats package-owned memberships as cover or suggests group bindings for groups with inactive members; sign-in evidence (recommendations and the dormant-access finding) ignores "view as" sessions; `accessPaths.find` masks deny reasons; `impact.preview` is refused while impersonating.

* Review recommendations: `roleMining.reviewRecommendations` suggests keep or revoke for each access-certification item from account status, recorded usage (when it covers the window), or the last sign-in, with the reason; the console's campaign page shows them.

* Change impact preview: `impact.preview` (`iam:policies:simulate`) applies a candidate role update, policy document, or role deletion with the real validation and edit rights inside an always-rolled-back transaction and reports, per holder of the affected roles (including inheritors and group members) and per resource, the actions gained and lost. Console: a Change impact page.

* Access invariants: `invariants.create/update/delete/list/run` (built-in actions `iam:invariants:read|manage`, collection `accessInvariants`) state who may, or must never, perform an action on a resource (one identity, a group, everyone with an attribute value, or everyone). `run` evaluates them; `enforce` mode re-checks them around every access-changing operation and refuses a change that newly breaks one with `INVARIANT_VIOLATION`, while pre-existing violations do not block unrelated work. `impact.preview` reports invariants a candidate change would break or fix. Console: an Access invariants page. CLI `check-invariants --tenant ID [--fail-on-broken]`. Scheduler job `iam.checkInvariants()` / CLI `monitor-invariants` records `invariant:broken` and `invariant:restored` audit events once per status change (`lastCheck` on each invariant).

* Terms of use: `agreements.create/update/delete/list/status` (built-in actions `iam:agreements:read|manage`, collections `agreements` / `agreementAcceptances`) publish versioned agreements with optional periodic re-acceptance; members call `agreements.listMine` / `accept` without a permission (audited `agreement:accept`). Policies see the new context keys `principal.agreements` and `principal.pendingAgreements` (known to the linter and reserved from identity attributes), so a deny statement can require acceptance. Console: a Terms of use page and an acceptance banner for members.

* Self-service access paths: `accessPaths.find({ tenantId, action, resource })` tells a denied person what they could do themselves — step up to MFA, accept pending terms of use, activate one of their eligible bindings, or request a requestable package — each verified by simulating it in a rolled-back transaction; needs only an ordinary session.

* `@better-iam/react` and `@better-iam/vue`: `useAgreements` (pending terms of use and `accept`) and `useAccessPaths` (self-service ways to be allowed).

* Configuration as code covers `invariants` (subjects by group name, member email, attribute, or everyone) and `agreements` (a content change publishes a new version); both kinds are exported only when present, so existing documents are unchanged.

* Birthright access packages: `autoAssign` rules (policy-condition include/exclude clauses over identity attributes, kind, owner flag, email, email domain and verification, manager, and direct group memberships) give a package to every matching identity and take it away when they stop matching, with an optional grace period. Rules run under their owner's grant authority and rights, re-checked on every run (suspended, never widened, when the owner leaves or loses rights). They reconcile after identity changes and rule saves, through `packages.reconcile`, and through the new scheduler job `iam.reconcilePackages()` / `better-iam reconcile`; brakes hold back unusually large unattended changes until confirmed. Also: `packages.previewAutoAssign`, `listAssignments({ source })`, `automatic` on assignments, `packages.assign` taking over automatic assignments, configuration documents carrying `autoAssign` (with group names), the console rule editor and preview, the new collection `packageRuleIssues`, and `package:auto-*` audit events.

* `@better-iam/next` auth forms, step-up, service credentials, and background work:
  * **Auth forms.** `iamNext.authActions(options)` (and `createAuthActions`) returns drop-in server actions: `signIn` (password or emailed sign-in code, then authenticator, emailed, or recovery codes, first-time enrollment with one-time recovery codes, 'keep me signed in' and 'remember this device'), `reauthenticate` (step-up that ends the session it replaces), `signOut` (clears a stale cookie too, through the new `clearSessionCookie()`), `requestPasswordReset`, `resetPassword`, `signUp`, `verifyEmail`, and `acceptInvitation`. The organization is resolved from `tenantId`, an `org` slug, `resolveTenant`, or the email's verified domain. They return a serializable `AuthFormState` that never holds passwords, codes, tokens, or sessions. `@better-iam/next/client` adds matching unstyled, accessible forms that work without JavaScript (`SignInForm`, `ReauthenticateForm`, `PasswordResetRequestForm`, `PasswordResetForm`, `SignUpForm`, `InvitationForm`, each with a `*View` twin).
  * **Step-up.** `stepUp: { mfa: true | 'fresh', maxAgeMs }` on `page`, `route`, `action`, `requireSession`, `pages.withSession`, and `pages.api` redirects pages to `stepUpPath?next=&reason=` and answers handlers and actions with `MFA_REQUIRED`, `RECENT_AUTH_REQUIRED`, or `IMPERSONATION_RESTRICTED` (403). `checkStepUp` is exported, and page errors carry a `BETTER_IAM_STEP_UP:` digest for `error.tsx`.
  * **Service credentials.** `apiRoute()` accepts API keys and assumed roles through `iam.authenticate` and passes a whitelisted `IamPrincipal`.
  * **Background work.** `dispatchAfterResponse` schedules outbox and event dispatch with `after()` after every in-process call and every POST to the mounts. `iamNext.background` offers `dispatch()`, `schedule()`, and `cron()`, a route for schedulers: bearer `CRON_SECRET`, fails closed, and runs isolated tasks (purge, audit retention, digest, reminders, outbox, events).
  * **Hardening.** `route()`, `apiRoute()`, and `pages.api()` refuse cookie-authenticated cross-origin mutations (`CSRF_REJECTED` / `UNTRUSTED_ORIGIN`; new `trustedOrigins` option) and map only `IamError` / `IamClientError` to responses. `safeRedirectPath` no longer lets dot segments (`/.//evil.example`) normalize into a protocol-relative redirect. `sessionForClient()` and `pages.withSession` props drop the session's `uniqueKey`. Sessions refused for network or tenant-tree reasons read as signed out. The umbrella `better-iam/next/client` re-exports by name, because Next rejects `export *` in a `'use client'` module.
  * **Example.** `examples/nextjs` uses all of this: the login, step-up, and password-reset pages, a development inbox, `apiRoute`, and a cron route.

* SCIM directory sync: the enterprise extension's `manager` becomes `Identity.managerId` (`mapManager`, on by default; by SCIM ID, `externalId`, or `userName`, back-filled when the manager arrives later, loops and self-references skipped, administrator-set managers never cleared). SCIM can no longer reactivate or regroup an identity an administrator deleted. `createScimService` serves a JSON administration API for consoles at `{adminBasePath}/connections/{list,create,rotate,revoke,groups,mappings}` (default `/scim/admin`; CSRF header, Origin check, 64 KiB bodies) and gains `listGroups`. Console: a Directory sync page (connections with their SCIM endpoints, one-time tokens, rotation, revocation, and group-to-role mappings), inbound SCIM mounted at `/api/iam/scim/v2`, and the console's IAM route now forwards `PUT`, `PATCH`, and `DELETE`.

* Policy linter: `analysis.lintPolicy` (and the exported `lintPolicy`) checks a candidate or stored document against the catalog and reports statements that are valid but probably wrong: unknown context keys such as `request.ip`, denies on optional keys that silently never fire, negated conditions over unresolvable variables, arrays compared with string operators, type mismatches, shadowed allows, duplicates, and wildcard administration. Its shadowing check is budgeted against adversarial documents. Console: a Lint card with a draft checker on each policy page.

* Access analysis gains `standing-privileged-access`, `unused-eligible-binding`, `orphaned-manager`, `manager-cycle`, and `policy-lint` findings.

* Manager-review certifications: `reviewerMode: 'manager'` routes each person's items to their manager, who decides them with `certifications.review` without holding a certification permission; `certifications.listMine`, `certifications.remind` (`certification-reminder` emails), and `autoClose` with the deployment job `iam.closeOverdueCertifications` / CLI `close-certifications`. `renderDeliveryMessage` renders `certification-review` and `certification-reminder` with a `links.certification` button. Console: "Assigned to you" review queue, reviewer column, "Remind reviewers", and manager-mode, due-date, and auto-close fields.

* Separation of duties also covers access-package assignment and approved package requests.

* Access-package and access-lifecycle hardening from a multi-agent review:
  * `packages.extend` now needs the rights `assign` needs, plus a grant authority, whenever it lengthens an assignment or removes its end. It moves the package's bindings to the extender's authority. Shortening still needs only `iam:packages:assign`.
  * Request and activation decisions are refused from impersonation sessions (`IMPERSONATION_RESTRICTED`).
  * `packages.listApprovals` lists only requests the caller holds `iam:packages:approve` on.
  * Requests that name approvers but would reach none are refused.
  * A request lapses no later than the end it asks for. `listRequests` reports lapsed requests as `expired`.
  * Tightening a package cancels pending requests that no longer fit it. A direct `assign` marks the pending request approved.
  * Package bindings are the assignment's own (their own uniqueness key), so they never replace or depend on hand-made grants.
  * Packages that share a group hand its membership to each other.
  * Hand edits (`bindings.update`, `groups.updateMember`, re-adding a lapsed member) take a package's record over.
  * Assignments whose bindings no longer grant are reported `broken` and can be assigned or requested again.
  * Offboarding revokes package assignments under package ownership.
  * `groups.delete` refuses an approver group still in use.
  * Offboarding hands reports to a successor without creating self-management or cycles. A manager must be active, and tombstones drop their `managerId`.
  * The access report ignores disabled expired identities, lapsed memberships, and activations that can no longer grant. The purge worker removes the activations of purged bindings and disabled identities.
  * Expiry-reminder dedupe is kept in a new `expiryReminderMarks` collection, so it survives audit pruning.
  * Configuration documents validate package and binding durations, treating `null` as no cap, and match package names case-insensitively, so a case-only difference is a rename.
  * `bindings.update` accepts a `managerApproval`-only change.
  * CLI `remind --within-days` is limited to 1–365.
  * The console member page keeps name and attributes, manager, and deactivation in separate forms.

* Shared Signals Framework transmitter: `createSharedSignalsTransmitter` (`better-iam/oauth`) turns IAM activity into signed Security Event Tokens (RFC 8417) and pushes them (RFC 8935) to each tenant's receivers. It covers CAEP `session-revoked` (sign-outs and administrator or tenant-wide revocations) and `credential-change` (password, authenticator, passkey), and RISC `identifier-changed` (email), `account-disabled` (offboarding, expiry), and `account-purged`. Subjects are `iss_sub` or `email`, events carry `initiating_entity` and the audit event as `txn`. Streams are managed by tenant administrators (`createStream`/`listStreams`/`getStream`/`updateStream`/`deleteStream`, built-in actions `iam:ssf:streams:*` on the new internal resource type `ssf`; audited) with encrypted receiver credentials, event and subject filters, pause and resume, `verifyStream` verification events, delivery history, retries with backoff (`dispatch`), and `/.well-known/ssf-configuration` metadata. New collections `ssfStreams` and `ssfDeliveries`.

* MCP-ready OAuth: dynamic client registration (RFC 7591) at the discovered `registration_endpoint` when `createOAuthProvider({ registration })` is set. Registrations present a tenant-scoped token from `createRegistrationToken` (`listRegistrationTokens` and `revokeRegistrationToken` manage them; tokens are stored hashed with a use limit) or pass the host's `registration.anonymous` hook. New clients are bound to that tenant, public with PKCE unless `allowConfidential`, limited to the authorization code and refresh grants, HTTPS or loopback or reverse-domain redirect URIs, and the allowed scopes and resources. Nothing the provider would fetch can be registered. They are audited and listed with `registeredVia`. New collection `oauthRegistrationTokens`. RFC 9728 protected resource metadata (`protectedResourceMetadata`, `protectedResourceMetadataUrl`, `createProtectedResourceHandler`), and the verifier's `challenge(error, realm, { resourceMetadata, scopes })` points clients at it. `createResourceGuard` combines metadata serving, token verification, and RFC 6750 challenges into one `check(request)` for MCP servers and other APIs. Plain OAuth 2.1 authorization codes (no `openid`) now receive a refresh token when the client is registered for the refresh grant, since MCP hosts send neither `offline_access` nor `prompt=consent`. OpenID requests are unchanged. `tests/oauth-mcp-integration.test.ts` runs the whole chain against a real server: challenge, discovery, registration, consent, the guarded call, and revocation on sign-out.

* Console email verification: `/cloud/verify-email` completes a `verify-email` link on a click, the account page shows an unverified address with a "Resend verification email" button, and the deliveries page opens verification links in development.

* Security notices link home: `renderDeliveryMessage` accepts the message's `tenantId` and a `links.account({ tenantId })` builder; `new-sign-in` and `sign-in-failures` emails then carry a "Review your account" button (text and HTML). The console's deliveries page points it at the organization's account page.

* Cookie options: `http.cookieSameSite` (`lax` by default, or `strict`) and `http.persistentCookies` (`true` by default). A request that issues a session may send `X-Better-IAM-Persistent: 0` (or `1`) to receive a browser-session cookie that disappears when the browser closes, or a lasting one, regardless of the default; the server session keeps its own lifetime either way. Preflight responses now also allow `X-Request-Id` and `X-Better-IAM-Persistent`. Console: a "Keep me signed in on this browser" checkbox on the login page, carried through the second-factor step.

* Discoverable passkey sign-in: `auth.beginPasskeyAuthentication({ tenantId })` without an email issues options that name no credential, so the authenticator offers its own passkeys for the relying party, and `finishPasskeyAuthentication` finds the account from the presented credential (user handle and ownership are still checked; disabled accounts are refused). Such anonymous starts are limited per address at ten times the ordinary allowance. Console: the login page offers passkeys in the email field through browser autofill as soon as it loads, and "Sign in with a passkey" no longer needs an email.

* Client options: `onUnauthenticated(error)` runs once per request the server refused as `UNAUTHENTICATED` (a lapsed or revoked session; never a wrong password), so an application redirects to its login page in one place; `retryRateLimited` (`true` or `{ maxWaitMs }`, off by default) retries a `RATE_LIMITED` call once after the server's `retryAfterMs` when that wait is short enough; `requestId` (`true` or a function) sends an `X-Request-Id` on every request, which the server echoes and records on its spans, and `IamClientError.requestId` carries it for support tickets.

* Named passkeys: `finishPasskeyRegistration` accepts a `name` (at most 64 characters; the default names the device kind from the authenticator's transports), `listPasskeys` returns `name`, `createdAt`, `lastUsedAt` (stamped by passkey sign-ins and passkey MFA), `deviceType`, `backedUp`, `aaguid`, and `transports` newest first, and `auth.renamePasskey({ id, name })` relabels one (audited as `auth:passkey:rename`). Console: the account page names each passkey with "synced" or "this device only", when it was added and last used, an inline rename, and a name suggestion (browser and system) when adding one.

* Tenant policy `bindSessionsToIp`: a user session is accepted only from the client IP it was issued from; presented from another address it is refused with `SESSION_NETWORK_MISMATCH` (401) and the attempt is recorded in the person's trail as `auth:session:mismatch` with both addresses, so a stolen cookie is useless elsewhere and the person signs in again from the new network. Sessions and requests without a recorded address are not judged. Console: the toggle and status on organization settings.

* Network blocks: `security.blockNetwork({ tenantId, network, reason, durationMs?, platform? })` (`iam:security:manage`, recent authentication, audited as `security:network-block`) refuses every authentication flow and live session whose recorded client IP falls in an IPv4/IPv6 address or CIDR block with `IP_BLOCKED`, before rate limits or credentials are examined; root administrators set `platform` blocks on the root tenant for the whole installation, organizations block for themselves, a `durationMs` (one minute to a year) makes a block lapse, `security.unblockNetwork` lifts it, and `security.listBlocks` (`iam:security:read`) reports them with `active`. The caller's own address is refused. Console: "Block for a day" per source address and a platform block list on the admin Sign-in failures page, and a "Blocked networks" card on organization settings.

* Brute-force defenses: `authentication.rateLimits.ipAttempts` (off by default) caps attempts per client IP and tenant across every authentication flow, on top of the per-account limits, so credential stuffing and password spraying from one address run out no matter how many accounts they name (needs a recorded IP; `identities.unlock` never clears it). `authentication.failedSignInAlerts` (off by default; needs `sendEmail`) queues one `sign-in-failures` email (`attempts`, `time`, `ip`, `userAgent`; rendered by `renderDeliveryMessage`) the moment a person's failed attempts since their last sign-in reach that number, once per streak. The console enables the alert at five attempts whenever it has a mail transport, and the admin panel gains a Sign-in failures page: failed attempts across organizations for the last hour, day, or week, grouped by source address and by targeted account (with the account's current streak and an unlock), plus the latest attempts. Console session, device, and activity lists now name clients ("Chrome 128 on Windows · 203.0.113.7") instead of showing raw user-agent strings.

* Idle-timeout warning: `auth.getSession` returns `limits` (`lifetimeMs`, `idleTimeoutMs`, and `idleExpiresAt`, honouring the tenant policy), and both console panels warn two minutes before a session lapses for inactivity (or reaches its maximum length) with a countdown, "Stay signed in", and "Sign out now", returning to the login page once it has lapsed (which then explains the inactivity or the reached lifetime via `?reason=idle|expired`); activity in the tab keeps the session alive without a prompt.

* Sign-in record and failed attempts: a wrong password, authenticator or emailed code, or recovery code presented for a real, active account is recorded as an `auth:signin:fail` audit event (`metadata.reason`, `ip`, `userAgent`) in its own transaction and counted per person; `auth:session:create` events carry the method and client too. Every new session from a sign-in flow carries `session.previousSignIn` (previous sign-in time and client, `failedAttempts` since, latest failed attempt's time and client; absent on a first sign-in) and the count restarts. `auth.listSecurityEvents` returns event `metadata`. Console: a dismissable notice above every page when attempts failed since the previous sign-in, and "Previous sign-in" / "Failed attempts since" on the account page, whose security activity now names the reason and client of each event.

* `docs/enterprise.md` walks one customer organization through verified domains, SAML or Entra ID single sign-on, sign-in policy, SCIM provisioning in and out, end-to-end offboarding, and audit. The console runs a periodic outbound provisioning sync so that expiries, which emit no event, still reach connected applications.

* HTTP hygiene and account status: every JSON response carries `X-Content-Type-Options: nosniff` and `Referrer-Policy: no-referrer` beside `Cache-Control: no-store`; a plain `X-Request-Id` is echoed on responses and recorded as `requestId` on `http` spans. `auth.listSessions` marks the calling session with `current`, and `auth.mfaStatus` reports the authenticator state, unused recovery codes, passkeys, remembered devices, and whether the session passed MFA (the console account page warns when recovery codes run low). The admin panel gains a Live sessions page: every unexpired session across organizations, filtered by organization, kind, name, email, or IP, with impersonators marked and a per-person sign-out.

* Console sign-in links and codes: the login page offers "Email me a sign-in link or code" (passwordless email is enabled whenever the console server has a mail transport); a link is redeemed at `/cloud/magic` on a click, a six-digit code is typed on the login page itself (`startPasswordless` with `kind: 'code'`, `finishPasswordless` with the code, resend and back), and the shared `MfaChallenge` step (authenticator, enrollment, recovery code, emailed code, passkey, remember-device) now serves password and link sign-ins alike; `mfaChallengeState` prepares it from any `mfaRequired` outcome. The deliveries page opens `magic-link` links in development. `tenants.usage` reports `mfaEnrolled` (people with an authenticator or a passkey) and the settings usage card shows MFA adoption.

* Managers: `Identity.managerId` (`identities.create/update({ managerId | null })`: another active identity of the tenant, never oneself or a report, so reporting lines stay acyclic), `identities.listReports`, and offboarding hands a manager's reports to the successor (`reportsReassigned`) while deleting an identity clears them. `managerApproval` on an eligible binding or an access package lets the requester's manager decide on activation and package requests (alongside an approver group, if one is named) and emails them each request. The configuration document carries the flag; the console shows the manager and reports on the member page and offers the option on the assign-a-role and package forms.

* Expiry reminders and assignment extension: `iam.sendExpiryReminders({ tenantId?, withinMs? })` (CLI `remind`, a deployment operation like `digest`) emails each person whose account, direct role bindings, group memberships, or package assignments end within the window (seven days by default) one `expiry-reminder` message listing them (`items` as JSON with kind, name, and end), once per item and end date, recorded as `identity:expiry-reminder`; moving an end brings a fresh reminder. `packages.extend({ packageId, identityId, expiresAt | null })` moves the end of an assignment and of every binding and membership it created together (audited as `package:extend`; console card on the Access packages page).

* Self-service package requests: a `requestable` package (optionally with an `approverGroupId`) can be asked for with `packages.request({ packageId, expiresAt?, justification? })` under `iam:packages:request`; the request waits for the tenant's `approvalLifetimeMs`, approver-group members are emailed (`package-request`), and `packages.approveRequest` / `denyRequest` (`iam:packages:approve` on the package, group membership when one is set, never one's own) decide it, assigning under the approver's authority and emailing the requester (`package-decided`). `packages.cancelRequest`, `listRequests({ packageId?, identityId?, status? })`, `listApprovals`, and `listMine` (requestable packages with the caller's status on each) complete the flow; lapsed requests are swept by the purge worker (`expiredRequests`), offboarding cancels pending ones, the configuration document carries `requestable` / `approverGroup`, and everything is audited as `package:request`, `package:request-approved|denied|cancelled`. `packages.revoke` now needs only `iam:packages:assign` on the package (the assignment owns its records, whichever authority issued them). Console: request/approve cards on the Elevate page, request settings and a requests table on the Access packages page.

* Access packages: `packages.create/update/delete/get/list` bundle roles and groups (`AccessPackage`, actions `iam:packages:create|read|update|delete|assign`). `packages.assign({ packageId, identityId, expiresAt?, justification? })` grants the whole bundle in one transaction as ordinary identity bindings and group memberships tagged with the assignment, skipping what the person already holds, honoring the package's `maxDurationMs` and `requireJustification`, and requiring `iam:bindings:create` on each role and `iam:groups:update` on each group besides `iam:packages:assign`; `packages.revoke` removes exactly what the assignment added; `packages.listAssignments` lists holders. Assignments end with their expiry (`PurgeResult.expiredAssignments`), go with offboarding and deletion, block deleting a packaged role or group or a held package (`RESOURCE_IN_USE`), travel with the configuration document (`packages` by name), and are audited as `package:assign` / `package:revoke`. Console: Access packages page.

* Access digest: `iam.sendAccessDigest({ tenantId?, withinMs?, unusedForMs?, minimumIntervalMs? })` (CLI `digest`, a deployment operation like `purge`) emails the owners of every active organization whose access report has findings an `access-digest` message (counts plus the full report as JSON), at most once per interval (20 hours by default) per organization, recorded as `tenant:access-digest`. `groups.addMembers` adds up to 100 members in one transaction with an optional shared expiry; the console group page adds several at once.

* Temporary group memberships and future-dated bindings: `groups.addMember({ expiresAt })` and `groups.updateMember({ expiresAt | null })` make a membership end by itself (with every grant and activation it carried); `listMembers` and `identities.listGroups` report `membershipExpiresAt`, re-adding a lapsed member renews it, and the purge worker removes lapsed records (`expiredMemberships`). Bindings accept `startsAt` (`bindings.create`/`update`, `null` clears): the grant is listed with its start but applies only from then. The access report gains `starting` bindings and `expiringMemberships`. Console: "Member until" on the group page, "Starts on" / "Ends on" when assigning a role, and membership expiry on the member page.

* Tenant access policy: `tenants.setAccessPolicy({ tenantId, accessPolicy | null })` (`iam:tenants:update`, recent authentication, audited as `tenant:access-policy`) sets organization-wide floors for just-in-time activation: `maxActivationMs` caps every binding, `requireJustification` / `requireMfa` / `requireApproval` apply to every eligible binding, and `approvalLifetimeMs` sets how long requests wait. Bindings can only be stricter. Configuration sync carries `accessPolicy` (`{}` clears it). Console: an "Elevation defaults" card on the Configuration page.

* Access report: `reports.access({ tenantId, withinMs?, unusedForMs? })` (`iam:identities:read`; the binding and credential sections need `iam:bindings:read` and `iam:credentials:read` and are named in `omitted` otherwise) returns identities and temporary bindings ending within the window, live activations, the number of pending activation requests, and API keys nobody used or that end soon. CLI `report --tenant ID [--within-days N] [--unused-days N]` prints it as the `BETTER_IAM_TOKEN` holder. Console: a Reports page with clear-deadline, make-permanent, end-activation, revoke, and rotate actions, and an activation history card on the member page.

* Approval-gated activation: eligible bindings accept `requireApproval` and `approverGroupId`; `bindings.activate` then records a pending request (lapsing after 24 hours) and emails the approver group (`activation-request`). `bindings.approveActivation` / `bindings.denyActivation` (`iam:bindings:approve` on the role; approver-group members or root when a group is set; never one's own request) make the role live for the requested or a shorter duration, or refuse it, and email the requester (`activation-decided`). `bindings.listApprovals` lists the requests a person may decide on, `listActivations` accepts `status`, `listMine` reports `pendingActivation`, and requesters cancel with `deactivate`. Audited as `binding:activation-requested|approved|denied`. Configuration sync carries `requireApproval` and `approverGroup` by name. Console: the Elevate page shows pending requests, lets approvers decide, and the member page sets the approval rules.

* `credentials.create({ scopes })` restricts an API key to an action allowlist (compiled to a session policy; `CredentialSummary.scopes` reports it), and `bindings.list({ expiresBefore })` reports temporary bindings ending before a time. Console: a scopes field when issuing keys.

* Tenant policy `requireMfaForOwners` requires a second factor from owners only (their non-MFA sessions stop at the next use; they enroll on the next sign-in). Console: the toggle in settings, a "Change email" card on the account page (`auth.requestEmailChange`), and `/cloud/confirm-email` for the confirmation link; the deliveries page opens `email-change` links in development.

* Microsoft Entra ID sign-in: `createOAuthLogin` connections accept `kind: 'microsoft'` with `microsoftTenant` (`organizations` by default, `common`, `consumers`, or one tenant) and an optional sovereign-cloud `issuer`. Multi-tenant connections require `allowedMicrosoftTenants` (Entra `tid` values), and each ID token is validated against the concrete issuer of its own directory. Emails count as verified only with Entra's `xms_edov` claim (protection against "nOAuth" email spoofing). `begin(connectionId, credential, { loginHint, domainHint, prompt })` and the `login_hint` / `domain_hint` / `prompt` query parameters of the start route forward validated sign-in hints (Microsoft `domain_hint`, Google `hd`, GitHub `login`).

* Delivery templates and back-off: `renderDeliveryMessage` from `@better-iam/auth/templates` (also `better-iam/auth/templates`, a subpath without native dependencies; re-exported from the main auth entrypoint) renders every built-in outbox template (`verify-email`, `password-reset`, `email-change`, `magic-link`, `code`, `mfa-code`, `new-sign-in`, `owner-invitation`, `member-invitation`) into `{ subject, text, html }` with your own link builders. `RATE_LIMITED` errors carry `retryAfterMs`; HTTP responses add `Retry-After`, and `IamClientError.retryAfterMs` exposes it. The console's deliveries page shows each message's rendered subject.

* Role inheritance: `roles.create`/`roles.update` accept `inherits` (role IDs; at most 20, no cycles, no protected roles, empty list clears); a role grants its own policies plus, recursively, the grants of the roles it inherits, each bounded by the inheriting role's authority ceilings as well as the inherited role's. Deleting an inherited role is refused with `RESOURCE_IN_USE`. Configuration sync exports and applies `inherits` by name. Console: an Inherits card on the role page.

* Access windows: bindings accept `window: { from, to, timeZone, days? }` (`bindings.create`/`update`, `null` clears); outside the recurring window the binding grants nothing. `identities.listBindings` reports `inWindow`; configuration sync carries windows on group bindings. Console: the window on the member page's role table and assignment form.

* Offboarding: `identities.offboard({ tenantId, identityId, reason, successorId? })` (recent authentication, `iam:identities:update`) disables an identity and, in one transaction, revokes its sessions and keys, removes its role bindings (under the caller's authority), group memberships, activations, relationships, and pending access requests, revokes the grant authorities it holds, removes ownership like `setOwner`, and reassigns the managed resources it owns to a successor or reports them. Audited as `identity:offboard` with the reason and counts. Console: an Offboard card on the member page.

* CLI `config-plan --fail-on-drift` exits non-zero (`CONFIG_DRIFT`) after printing the plan when the tenant differs from the file, for CI checks.

* Outbound SCIM provisioning: `createScimProvisioner({ ...iam.protocolHost, encryptionKey })` from `better-iam/scim` keeps downstream SCIM 2.0 applications in step with a tenant's active members, or with members of chosen groups. It creates users (adopting existing ones by `externalId` or `userName`), pushes changes, and deactivates or deletes (`deprovision`) people who are disabled, deleted, expired, or leave scope. Identity attributes map to `title` and the enterprise extension (`attributeMapping`). Targets are managed through `createTarget`, `listTargets`, `getTarget`, `updateTarget`, and `deleteTarget` (built-in actions `iam:scim:targets:create|read|update|delete|sync`; audited), with encrypted write-only bearer tokens. `syncTarget` runs on demand, `syncAll` suits schedulers, and `subscribe(iam.events)` syncs after member changes. Each run reports created/updated/deactivated/deleted/unchanged/failed counts and failure details in `lastRun`. New collections `provisioningTargets`, `provisioningLinks`, and `provisioningGroupLinks` are removed with their tenant. `pushGroups` also maintains the scoped groups downstream (display name and provisioned members; adopted, updated, or deleted as scope changes). `provisioner.handler` serves the operations as a JSON API (`basePath`, IAM CSRF rule and envelope) for `iam.useProtocol`. Console: an App provisioning page connects applications, scopes them to groups, and syncs, pauses, or removes them. It shows per-run results and failures, and syncs follow member changes automatically. `previewTarget` (and a Preview button) reports what the next sync would change using read-only lookups only. Memberships past their `expiresAt` no longer count toward a target's scope or pushed groups, and access-package assignments (`iam:packages:*`, `package:*` events) trigger a sync.

* Passkeys as the second factor: a sign-in that returns `mfaRequired` now reports `passkeyAvailable` when the person has a passkey registered; `auth.beginPasskeyMfa({ tenantId, challenge })` and `auth.finishPasskeyMfa({ tenantId, challengeId, response, rememberDevice? })` complete MFA with a user-verified WebAuthn assertion bound to that login challenge (both challenges are consumed; remembered devices supported). Console: passkey registration and removal on the account page, "Sign in with a passkey" on the login page, and "Use a passkey" at the MFA step (all through `better-iam/client/passkeys`). `tests/support/webauthn.ts` provides a virtual P-256 authenticator for tests.

* Tenant-managed SAML connections: with `serviceProvider` (one deployment-wide SP key pair and base URL) and the host's `authorize`, `createSamlService` adds `createConnection`, `listConnections`, `getConnection`, `updateConnection`, and `deleteConnection` (built-in actions `iam:saml:connections:create|read|update|delete` on the new internal resource type `saml`; audited). Organizations enter their IdP's metadata XML, or its sign-on URL, issuer, and certificates, and get fixed `{basePath}/{id}/metadata|acs|login` URLs. The declarative `attributeMapping` feeds identity attributes, summaries report certificate fingerprints and expiry for rollover, and `enabled: false` stops sign-ins. `parseIdpMetadata`, `normalizeCertificate`, and `certificateInfo` are exported, `getMetadata(id)` serves both configured and managed connections, and `samlConnections` is removed with its tenant. IdP-initiated SAML sign-in is available per connection (`allowIdpInitiated`, configured or managed): responses answering no request are fully validated and each assertion ID is accepted once (`samlAssertions`); `idpInitiated(connectionId, samlResponse)` is the direct call and `validateSamlEnvelope` accepts `null` as the expected request.

* Nuxt and Vue. New package `@better-iam/vue` (also `better-iam/vue`) for Vue 3.3+. `createIam({ client })` is the plugin, and `useSession`, `useAuthorize`, `useCan`, and `useAccessible` take refs or getters and re-run when the input or the signed-in identity changes. `<IamCan>` has `default`/`fallback`/`loading` slots. Server rendering awaits queries through `onServerPrefetch` and hands the results to the browser through an `IamHydration` store (`createHydration`), so hydration neither refetches nor mismatches. New package `@better-iam/nuxt` is a Nuxt 3.14+/4 module (`betterIam` config key). It mounts the IAM API at `/api/iam/**` in Nitro from the file named by `instance` (default `~~/server/iam`), initializes the instance on first use, and loads the session during SSR through an in-process client bound to each request (`event.context.betterIam`). Page access comes from `definePageMeta({ iam: true | false | { action, resource?, tenantId?, redirectTo? } })` with optional `requireAuth`: 302 to `loginPath?next=` when signed out, and a 403 error page on the server and on client navigation. The module auto-imports `useIamSession`/`useIamClient`/`useIamAuthorize`/`useIamCan`/`useIamAccessible` and `<IamCan>` (sessions typed from the registered instance) and the server utilities `getIamSession`, `requireIamSession`, `requireIamAccess`, `iamCan`, `issueIamAssertion`, `iamCredential`, and `useIam`. `@better-iam/nuxt/h3` exports `createIamH3` for any h3 v1/v2 or Nitro app (per-event session memo, h3-compatible `IamH3Error` with `data.code`, Web or Node request bodies). The framework-agnostic session store moved to `@better-iam/client/session` (`better-iam/client/session`); `@better-iam/react` re-exports it unchanged, and `isUnauthenticated` also recognizes in-process `IamError`s with status 401/403. `examples/nuxt` is a runnable Nuxt 4 app with a production smoke test, and `docs/nuxt.md` is the guide.

* Separation of duties: `sod.create/list/update/delete/violations` (built-in actions `iam:sod:read|manage`) declare roles nobody may hold together. Prevent rules refuse, with `SOD_CONFLICT`, any binding, group membership, bulk onboarding, access-request approval, configuration apply, or invitation acceptance that would create a new conflict, while pre-existing conflicts are reported instead of blocking; detect rules only report. The access analysis gains a high-severity `separation-of-duties` finding. Console: a Separation of duties page.

* `docs/authentication.md` is a full guide to sign-in methods, MFA (authenticator, recovery codes, emailed codes, remembered devices), sessions and recent authentication, account recovery, tenant authentication policies, impersonation, HTTP cookie behaviour, and the deployment options and email templates. The console gains self-service password recovery: "Forgot your password?" on the login page, `/cloud/reset` to request the email and to choose a new password from its link, and the deliveries page opens `password-reset` links in development.

* Configuration as code: the `config` API group (`iam:config:read`, `iam:config:apply`) exports a tenant's roles, policies, groups (with member emails), tenant-defined resource types, and group role bindings as one JSON document keyed by name (`config.export`), computes the creates, updates, and deletes a document implies (`config.plan`, with `prune` for items a listed kind omits), and applies it in a single transaction where every change is authorized like the direct call and one refusal rolls everything back (`config.apply`, audited as `config:apply`). CLI: `config-export`, `config-plan`, `config-apply [--prune]` as the `BETTER_IAM_TOKEN` holder. `validateTenantConfig` and the `TenantConfig`/`ConfigPlan` types are exported; the mutation helpers behind `roles`, `policies`, `groups`, `resourceTypes`, and `bindings` are shared with the API. Console: a Configuration page.

* Just-in-time roles: `bindings.create`/`update` accept `eligible`, `maxActivationMs` (default one hour, up to seven days), `requireJustification`, and `requireMfa`; an eligible binding grants nothing until its subject (directly or through a group) calls `bindings.activate` (`iam:bindings:activate` on `iam/{roleId}`), for a bounded time, from an ordinary session, never while impersonating. `bindings.deactivate` ends one's own activation, `bindings.revokeActivation` ends someone else's (like deleting the binding), `bindings.listActivations` lists them, `bindings.listMine` shows members their own roles and what they may activate, and `identities.listBindings` reports `activation`. Activations end with the membership, the binding, or the role, and the purge worker sweeps expired ones (`expiredActivations`). Audited as `binding:activate` (with the justification) and `binding:deactivate`. Console: an Elevate page, eligibility on the member and role pages.

* Identity expiry: `Identity.expiresAt` schedules deactivation for contractors and temporary service accounts (`identities.create/createMany/update`, `serviceAccounts.create/update`; `null` clears it). Past the deadline every credential of the identity is refused (`UNAUTHENTICATED`), `identities.list({ expiresBefore })` reports upcoming expiries, and the purge worker disables such identities, revokes their sessions, and records `identity:expire` (`expiredIdentities`); re-enabling requires clearing or extending the deadline first. The last owner cannot be scheduled away. Console: the deadline on the member page and when creating service accounts.

* API key hygiene: keys carry a `name` and `description` (`credentials.create`, `credentials.update`, which can also move the expiry within a year under recent authentication), `credentials.get` and `credentials.list` return `lastUsedAt` (recorded at most once a minute when the key authenticates a request) and `credentials.list({ unusedForMs })` finds keys nobody has used, including never-used ones; rotation keeps the label and starts the usage history over. Console: labeled key issuance and an "unused 30d" flag.

* `@better-iam/adapter-sqlite`: closing a store before its first query now releases the database file (Kysely opens the driver lazily, so the eagerly opened connection was left to garbage collection).

* Emailed MFA codes: `authentication.mfaEmailCodes` and the tenant policy field `mfaEmailCodes` let people without an authenticator satisfy an MFA requirement with a one-time code (`auth.requestMfaCode` for a login challenge whose sign-in reported `emailCodeAvailable`, then `verifyMfa`); codes are hashed, single-use, bound to the challenge, and expire with it. Never offered to root administrators or to people with an authenticator. Console: "Email me a code instead" on the MFA step and the policy toggle in settings. The console's IAM route now answers `GET`, so `/api/iam/health` and `/api/iam/metrics` work behind Next.js.

* CLI `analyze --tenant ID [--dormant-days N] [--fail-on high|medium|low]` prints access-analysis findings as the `BETTER_IAM_TOKEN` holder and exits non-zero at the chosen severity, for nightly jobs and deployment gates.

* Access certification campaigns: `certifications.create/list/get/decide/close/delete` (built-in actions `iam:certifications:read|review|manage`) snapshot a tenant's role bindings for designated reviewers, forbid self-review, and on close remove revoked (optionally undecided) bindings under the closer's authority, recording each item's outcome. Console: a Certifications page with per-binding keep/revoke buttons.

* Network allowlists: the tenant authentication policy gains `allowedIpRanges` (IPv4/IPv6 addresses or CIDR blocks); sessions, including impersonation, are refused with `IP_NOT_ALLOWED` when the recorded client IP is outside them, and existing sessions from outside stop working at their next use. `@better-iam/core` exports `isIpRange` and `ipMatches`. The console settings page edits the list, and the admin panel gains an Operations page (health, outbox backlog, active sessions, and the process's Prometheus counters and latency histograms; the console enables `observability.metrics` and exposes `GET /api/iam/metrics` when `METRICS_TOKEN` is set).

* `@better-iam/next` App Router integration: `iamNext.client()` is the typed API bound to the current request, calling the IAM handler in process and writing the cookies it issues through `cookies()`, so server actions sign people in and out (and through MFA) without client JavaScript. `page(render, spec)`, `route(handler, spec)`, and `action(fn, spec)` wrap pages and layouts, route handlers (JSON error envelope with the server's status; cookie or bearer API key), and server actions (`ActionResult` for `useActionState`; Next control flow propagates) with authentication and optional `authorize: { action, resource, tenantId }`. The ambient `getSession()` is memoized per request with React `cache` (`cache` option), reads cookies a server action just set, and `sessionForClient()` returns JSON for `IamProvider initialSession`. `interrupts: true` uses Next's `unauthorized()` / `forbidden()`; `requireTenantSession({ slug })` / `tenant(slug)` serve `/[org]/...` routes (unknown aliases call `notFound()`, other organizations go to `/login?org=`); `handlers()` adds `GET` for `/health` and `/metrics`. Middleware gains `publicPaths` globs, `signedInRedirect` (bare login visits only; guards add `?next=` when they reject a stale cookie, so it cannot loop), and `next` (pass `NextResponse.next`) to forward `x-better-iam-pathname`, which becomes the default `?next=`. The new edge-safe `@better-iam/next/edge` subpath (`better-iam/next/edge`) holds the middleware plus Web Crypto `verifyAssertionToken` / `withAssertion` (offline assertion checks for downstream services), `verifyWebhook` / `createWebhookHandler` (signed webhook receiver with secret rotation, freshness, size limits, and retry-friendly failures), and `safeRedirectPath` against open redirects. The server instance reports its HTTP `endpoint` (`origin`, `basePath`, `secure`). `docs/nextjs.md` is a full guide, and `examples/nextjs` is a runnable App Router application covering each helper.

* `@better-iam/next` Pages Router and client components: `iamNext.pages.withSession(gssp, spec)` (`getServerSideProps` with login redirects, `notFound` or redirect on denial, and the session as JSON props), `pages.api(handler, spec)` (API routes with the JSON error envelope), `pages.client(req, res)` (in-process client appending cookies to the response), `pages.getSession(req)`, and `pages.handler()` for `pages/api/iam/[...path].ts` (accepts bodies Next already parsed). `interrupts: 'forbidden'` interrupts denials only and keeps login redirects. The new `'use client'` entry `@better-iam/next/client` (`better-iam/next/client`) adds `IamNextProvider` / `useRouterSync()` (calls `router.refresh()` when the signed-in identity changes in the browser) and `useSignOut({ redirectTo })`. `iamNext.allowed(action, resource?)` and the async server component `<iamNext.Can action resource fallback>` batch every check made during a render into one deduplicated `authorizeMany` per tenant (50 per call) and reuse answers for the rest of the request.

* New package `@better-iam/nestjs` (also `better-iam/nestjs`) for NestJS 11 and 12 on Express or Fastify. `IamModule.forRoot` / `forRootAsync` provides `IamService` (`principal`, `authorize`, `require`, `can`, `listAccessible`, `assertion`, all per request) and `IamGuard`. Options install the guard globally (`guard`), serve the IAM HTTP API from the Nest app (`mount`, which also handles bodies Nest has already parsed), and register `IamExceptionFilter` so `IamError`s keep their status and `{ error: { code, message } }` body. Decorators: `@Public`, `@Authorize(action, { resource, tenant })` (rules add up across class and method; values come from params, query, body, headers, GraphQL arguments or WebSocket message fields, or functions), `@RequireMfa`, `@Credentials('api-key')`, `@CurrentPrincipal` / `@CurrentIdentity` / `@CurrentSession` / `@TenantId`, and `@OnIamEvent(pattern)` for provider methods (with optional `dispatchIntervalMs`). The guard rejects cookie-authenticated unsafe requests from foreign origins (`csrf`) and supports HTTP, GraphQL and WebSocket contexts. Downstream services use `IamAssertionModule.forRoot({ key, audience })` with `IamAssertionGuard`, `@AssertionClaims()` and `@RequireClaims({ roles, groups, mfa, kinds })`. That path loads only the new `@better-iam/server/assertions` subpath, so a verifying service doesn't need the server's native dependencies. `@FilterAccessible(action, { type, id?, path? })` trims list responses to the resources the caller may act on through the `listAccessible` reverse query, so filtering doesn't write an audit denial per item. `forRootAsync` also takes `useClass` / `useExisting` (`IamOptionsFactory`). `IamService.health()` probes the IAM database in process, and `IamService.credential(request)` returns the caller's credential. The mount path defaults to the server's reported `endpoint.basePath`. `examples/nestjs` is a runnable Express app with a smoke test (`pnpm --filter @better-iam/example-nestjs smoke`). `@better-iam/nestjs/testing` (`better-iam/nestjs/testing`) exports `createTestingIam`, an in-memory stand-in for tests: principals chosen by bearer token, decisions made by a callback, registered resources for `@FilterAccessible`, a `decisions` log, and `emit()` for `@OnIamEvent` handlers.

* OAuth authorization server: client management (`listClients`, `getClient`, `updateClient`, `rotateClientSecret`; new built-in action `iam:oauth:clients:update`). Narrowing a client's grant types, scopes, or resources, or requiring DPoP, revokes everything issued to it. Connected apps: `listGrants`, `revokeGrant`, `revokeGrants` (self-service, or `iam:oauth:grants:read|revoke` for other accounts; audited `iam:oauth:RevokeGrant`), and repeated consent in one provider session extends a single grant. Resource servers: the `resourceServers` option (RFC 8707 resource indicators) issues audience-restricted JWT access tokens (RFC 9068) to clients registered with `resources`. The new `createAccessTokenVerifier` verifies them offline, including DPoP proofs (RFC 9449) with replay detection. Clients can set `requireDpop` / `requirePushedAuthorization`, the provider takes `requirePushedAuthorizationRequests` / `dpopNonceSecret`, and `interactionDetails` adds `clientName` and `resources`. Confidential clients can authenticate with `client_secret_post` or `private_key_jwt` (`tokenEndpointAuthMethod`, with `jwks` / `jwksUri`; single-use assertions, key rotation through `updateClient`). OpenID back-channel logout: clients register `backchannelLogoutUri`, and `logoutEndedSessions()` notifies them and revokes consents whose IAM session ended (audited `iam:oauth:SessionLogout`). Outbound provider requests allow loopback targets only under `allowInsecureLocalhost`. Per-client `accessTokenTtl` / `refreshTokenTtl` can shorten token lifetimes below the provider and resource server defaults. Clients carry consent-screen branding (`logoUri`, `clientUri`, `policyUri`, `tosUri`) and a `firstParty` flag, reported by `interactionDetails(...).client`. Token exchange (RFC 8693): confidential clients registered for `urn:ietf:params:oauth:grant-type:token-exchange` trade an account's access token for a token to one of their resources, with the client as `act` and a lifetime capped by the subject token. `authorizeTokenExchange` adds policy, exchanges are audited as `iam:oauth:TokenExchange`, and the verifier exposes `actor`. Consent now grants requested resource scopes, so browser flows with `resource` receive resource-server tokens.

* Access analysis: `analysis.findings` (`iam:analysis:read`) reports unrestricted administrator policies, service-wide action wildcards, administrators without a second factor, service accounts with full administration, dormant members who still hold access, trusts without MFA, and unattached policies, unused or empty roles, and member-less groups with bindings, ordered by severity with deterministic IDs; `analysis.suppress` / `unsuppress` (`iam:analysis:update`) record accepted risks. Console: a Security findings page.

* Verified domains and home-realm discovery: a new `domains` API group (`add`, `list`, `verify`, `delete`; built-in actions `iam:domains:create|read|update|delete`) lets an organization prove control of an email domain with a DNS TXT record; each domain is verified by at most one tenant and consumer mailbox providers are refused. Public `domains.discover({ email })` returns the owning tenant with its alias, accepted sign-in methods, and MFA requirement. The `domains` option injects the TXT resolver, record label, and blocked list. Console: a Domains page and "use your work email" on the organization picker.

* Security activity and sign-in alerts: `auth.listSecurityEvents` returns the caller's own `auth:*` audit trail (impersonators named) for account pages; `authentication.signInNotifications` and the tenant policy field `notifyNewSignIn` queue a `new-sign-in` email when a session starts from a client (user agent + IP) that none of the person's live sessions or remembered devices has used. Console: "Recent security activity" on the account page and the alert toggle in settings.

* Password policy: tenant authentication policies gain `passwordHistory` (refuse the last 1–24 passwords; `PASSWORD_REUSED`), `passwordMaxAgeDays` (a verified but expired password is refused with `PASSWORD_EXPIRED` until reset), `passwordMinClasses` (2–4 character classes), and `passwordRejectPersonalInfo`. `authentication.passwordPolicy` adds deployment-wide screening: a built-in common/sequential/low-variety screen (on by default), `isBreached` (`BREACHED_PASSWORD`; `pwnedPasswords()` is a k-anonymity Have I Been Pwned client), and a custom `check`. Identities record `passwordChangedAt`; previous hashes live in the new `passwordHistory` collection (newest 24, removed with the identity or tenant). `@better-iam/auth` exports `pwnedPasswords`, `isCommonPassword`, and `characterClasses`. Console settings edit the new rules.

* Trusted devices ("remember this device"): `verifyMfa` and `confirmMfa` accept `rememberDevice` and return a `deviceToken` (with `deviceExpiresAt`) that lets the same browser skip MFA on later `signIn` / `finishPasswordless` calls; `authentication.trustedDeviceLifetimeMs` caps the deployment (30 days by default, 0 disables) and the tenant policy gains `trustedDeviceDays`. Root administrators are never remembered; password, email, and factor changes forget every device; sessions record `trustedDeviceId`; `auth.listTrustedDevices`, `revokeTrustedDevice`, and `revokeTrustedDevices` manage them (audited as `auth:device:trust` / `auth:device:revoke`). The HTTP handler keeps the token in a `better-iam.device` cookie and injects it into sign-in bodies. Console: a "remember this device" checkbox at MFA, a remembered-devices card on the account page, and the policy field in settings.

* SCIM protocol coverage: RFC 7644 filters (`and`/`or`/`not`, grouping, value paths such as `emails[type eq "work"]`, sub-attributes, schema-qualified enterprise attributes, `gt/ge/lt/le`) evaluated against rendered resources; `sortBy`/`sortOrder`; `attributes`/`excludedAttributes` projection; `POST {Users|Groups}/.search`; `POST /Bulk` (100 operations, per-operation transactions, `bulkId` forward references, `failOnErrors`); `If-None-Match`. PATCH gains sub-attribute and value-path targets (`name.givenName`, `emails[type eq "work"].value`, `urn:…:enterprise:2.0:User:department`), listed-member removal and Entra-style `"True"`/`"False"` booleans, and no longer drops `title` or the enterprise extension on unrelated changes. `scim.listConnections` reports usage (`lastUsedAt`, user/group counts) and `scim.rotateToken` replaces a connection's token without losing provisioned state. Discovery advertises bulk, sorting and the enterprise extension schema.

* Operations endpoints: `observability.metrics` keeps Prometheus-style counters and histograms from spans (`iam.metrics.render()` / `snapshot()`, exported `createMetrics`), served at `GET {basePath}/metrics` when a `bearerToken` is configured; `GET {basePath}/health` reports database reachability. Caller-controlled names collapse and series are capped, so scrapes stay bounded; `gauges: true` adds outbox-backlog and live-session gauges read from storage on each scrape. The console audit log gains a search form (action glob, actor, resource, outcome, time range) with pagination and actor names.

* Impersonation ("view as"): `identities.impersonate` opens a member session for support when the tenant's authentication policy sets `allowImpersonation` (new built-in action `iam:identities:impersonate`, recent authentication, a recorded reason, at most eight hours and never beyond the administrator's own session). Owners, root administrators, service accounts, and the caller are never eligible. Such sessions carry `impersonatorId`, cannot perform recent-authentication operations, re-authenticate, assume roles, grant OAuth consent, or impersonate further, end with the administrator's session (`auth.endSession`), and are excluded from `maxSessions`. Audit events and webhook bodies gain `impersonatorId`, policies gain `principal.impersonated` / `principal.impersonatorId`, assertions gain `impersonatorId`, `AuthMethod` gains `impersonation`, and the HTTP layer never sets a cookie for the impersonation token. Console: settings toggle, "View as" on the member page, a banner with a stop button, and attribution in the audit log and session lists.

* Tenant authentication policy gains `maxSessions` (concurrent sessions per person; the oldest ends). `identities.requestPasswordReset` queues a reset email for a member on an administrator's behalf. `docs/api-reference.md` is generated from a live instance by `pnpm docs:api`; the HTTP route tables (`routeGroups`, `publicApiMethods`, `publicAuthMethods`, `authenticatedAuthMethods`) are exported from the server package.

* `tenantDefaults` option: plan limits and an authentication policy applied to every tenant created with `tenants.create`, validated at construction. `tenants.resendInvitation` and `identities.resendInvitation` renew an invitation's token and lifetime and queue the email again; the console offers both.

* Tenant authentication policy gains `maxAttempts` (tighter rate limits for the tenant's authentication flows) and `minPasswordLength` (enforced on creation, reset, and change). `identities.update` accepts `email` for administrator-driven address changes (recent authentication, unverified, sessions revoked, audited as `identity:email-change`).

* Federated attributes: OAuth/OIDC sign-in connections and SAML connections accept `mapAttributes`; mapped values are validated against `permissions.identityAttributes` and stored on the identity at every sign-in (`FederatedLogin.attributes`).

* Audit retention: `iam.pruneAudit({ tenantId, retentionMs })` and CLI `audit-prune` delete a tenant's oldest events behind an `audit:prune` checkpoint that keeps the chain verifiable.

* Access reviews accept platform resources (`iam/...`) as the reviewed resource.

* OAuth/OIDC provider: the built-in `iam` scope adds `roles`, `groups`, and `attributes` claims (live when read, expired bindings excluded) to userinfo.

* `apps/console`: member search and pagination on the members page.

* Directory: `identities.list` accepts `query` (name or email, case-insensitive), `limit`, and `offset`, ordered by name; `identities.listSessions` returns a member's active sessions for administrators (`iam:identities:read`), without token material.

* `docs/recipes.md` collects copy-ready examples for sharing, reviews, policy testing, tenant auth policies, audit archives, assertions, observability, incident response, exports, limits, webhooks, bulk onboarding, and libSQL.

* Policy tooling: `policies.restoreVersion` rolls a policy back to an earlier version (re-validated, kept in history) and `policies.test` evaluates a candidate document against an action, resource, and supplied context without storing it; the console policy page offers both.

* Account unlock: `identities.unlock` clears the sign-in, recovery, and MFA rate-limit counters of an identity (recent authentication, `iam:identities:update`, audited as `identity:unlock`). `RateLimiter.reset` is optional for custom limiters; the built-in limiters implement it.

* SCIM: users carry `title` and the enterprise extension (`department`, `division`, `manager`, …); `mapAttributes` turns them into declared identity attributes, validated through `protocolHost.validateIdentityAttributes`.

* Plan limits and usage: root sets `tenants.setLimits` (members, service accounts, groups, roles, policies, resources, webhooks; audited as `tenant:limits`); every creation path, including invitation acceptance, self-registration, federation, SCIM, and bulk creation, fails with `LIMIT_EXCEEDED` past a limit. `tenants.usage` reports counts, active sessions, and limits. The admin panel edits limits and both consoles show usage.

* Webhook filters: subscriptions accept `outcomes` and `resources` (glob patterns over the resource ID) in addition to action patterns.

* Bulk onboarding: `identities.createMany` creates up to 100 identities atomically with optional passwords, declared attributes, roles (authorized like invitations, bound under the caller's grant authority), and groups.

* Webhook redelivery: outbox messages remember the audit event behind a delivery (`reference`; `listDeliveries` exposes `eventId`) and `webhooks.redeliver` queues that event again, rebuilt from the audit record and signed with the current secret. The console offers it per delivery.

* `@better-iam/next`: `iamNext.assertion({ tenantId, audience, ttlSeconds?, claims? })` issues a stateless assertion for the current request's session.

* `apps/console`: member attributes (department, title) editable on the member page; the admin audit page reports chain integrity.

* Data-subject export and incident response: `identities.export` returns everything a tenant stores about one identity (no secrets; audit trail when the caller may read it), audited as `identity:export`; `identities.revokeSessions` ends one identity's sessions without disabling it; `tenants.revokeSessions` ends every session in a tenant (`includeSelf` optional). All three require recent authentication. The console offers them on the member page and in organization settings.

* New package `@better-iam/adapter-libsql` (`better-iam/adapter-libsql`): libSQL persistence through `@libsql/client` for local files, encrypted files, embedded replicas, and remote Turso or sqld databases, with the same schema, transaction guarantees, and error mapping as the SQLite adapter. `better-iam init --database libsql` scaffolds it.

* Session metadata: sessions issued through the HTTP handler record `client.userAgent`; `http.clientInfo(request)` derives `ip`, `userAgent`, and a device `label` behind a trusted proxy; `iam.auth.withClient(info, fn)` scopes details for direct calls. `auth.revokeOtherSessions` ends every other session of the caller (recent authentication required, audited as `auth:session:revoke-others`); the console lists devices and offers "Sign out other sessions".

* CLI: `audit-verify --tenant ID` recomputes a tenant's audit chain from storage (non-zero exit when broken) and `audit-export --tenant ID --output FILE` writes it as JSON Lines; `doctor` reports chained tenants and events.

* `apps/console`: workspace sharing through relationships (workspace page and member page), an access-reviews page (who can, effective actions), audit chain verification and JSONL export, the organization authentication policy in settings, relations on resource types, and the sign-in method on sessions.

* Observability: `observability.onSpan` receives a timed span for every provisioning operation, `authorize`/`authorizeMany`/`listAccessible` query, authentication call, and HTTP request, with tenant, outcome (`ok`, `denied`, `error`), error code, status, and duration. Handler failures are ignored.

* Stateless assertions (`assertions` API group): `assertions.issue({ tenantId, audience, ttlSeconds?, claims? })` returns a short-lived HS256 JWT describing the caller for a service, authorized as `iam:assertions:create` on `iam/{audience}`. `iam.assertionKey()` derives the verification key from the deployment secret and `verifyAssertion(token, { key, audience, issuer? })` checks tokens offline.

* Tenant authentication policies: `tenants.setAuthPolicy` sets or clears `requireMfa`, `allowedMethods`, `sessionLifetimeMs`, and `sessionIdleTimeoutMs` per tenant (recent authentication and `iam:tenants:update`; audited as `tenant:auth-policy`). Policies only tighten the deployment's configuration; method restrictions are enforced before credentials are examined (`METHOD_NOT_ALLOWED`), sessions are re-validated on use, and MFA cannot be disabled while required. User sessions record their sign-in `method`, exposed to policies as `principal.authMethod`. `iam.auth.mfaRequired`, `tenantRequiresMfa`, and `sessionLimits` are public for trusted integrations.

* Audit hash chain: every audit event carries `sequence`, `previousHash`, and `hash` (SHA-256 over canonical JSON) linked per tenant through `auditChains`; all writers (server, authentication, SCIM, OAuth provider) append through `appendAuditEvent`. `audit.verify` checks a tenant's chain or a window of it, `audit.export` pages events as JSON Lines, and `verifyAuditChain`/`auditEventHash`/`canonicalJson` (core and `better-iam`) verify archives offline. `initialize()` backfills unchained events once. Webhook bodies include `sequence` and `hash`.

* Access reviews under `iam:policies:simulate`: `policies.whoCan` lists the identities that could perform an action on a resource (with `kind`, `assumeMfa`, and pagination), `policies.effectiveActions` lists the actions an identity holds on a resource across the catalog or a chosen list, and `policies.simulate` accepts `assumeMfa`.

* Relationships (`relationships` API group): resource types declare `relations` (in `permissions.resourceTypes`, plugin `resourceTypes`, and `resourceTypes.register/update`); `relationships.create/list/delete` bind identities or groups to one resource under a declared relation with optional expiry, authorized by `iam:relationships:create/read/delete` on `iam/{type}/{id}`. Evaluation exposes the principal's live relations on the resource as `resource.relations` and on its registered parent as `resource.parentRelations`; `iam/{type}/{id}` administrative checks on registered managed resources now carry that resource's attributes, owner, parent, and relations. Tuples are removed with their identity, group, or resource, and a relation still in use cannot be dropped from its type.

* Condition operators: `StringNotEquals`, `StringEqualsIgnoreCase`, `StringNotEqualsIgnoreCase`, `StringNotLike`, `StringLikeIgnoreCase`, `NumericNotEquals`, `NumericLessThanEquals`, `NumericGreaterThanEquals`, `NotIpAddress`, `ArrayContains`, and `ArrayContainsAll`. Negated operators and `ArrayContainsAll` require every listed value; missing or wrongly typed context never satisfies any operator.

* Principal context: `principal.kind`, `principal.owner`, `principal.rootAdmin`, `principal.sessionKind`, `principal.groups`, and `principal.roles` are available to conditions and variables. `permissions.identityAttributes` declares typed attributes that `identities.update` and `serviceAccounts.update` set and policies read as `principal.{name}`; `Identity.attributes` and `Identity.description` are part of the public identity.

* Plugin contract: plugins may contribute `resourceTypes`, `hooks.beforeOperation`/`afterOperation` (run inside the operation transaction; throwing aborts it), and `resolveContext`; plugin endpoints receive `deliver` to queue email/SMS through the host outbox.

* Policy variables: `${principal.id}` and any other trusted context key can appear in resource patterns (after the type segment) and in `StringEquals`/`StringLike` values. Substituted values match literally, unresolved variables never match, and malformed references are rejected at validation.

* Reverse queries: `iam.listAccessible` / `client.listAccessible` return the registered resources of a managed type that the caller may perform an action on, evaluating grants once per query. `resources.registerMany` registers up to 100 resources atomically with per-item authorization.

* Temporary bindings: `bindings.create` accepts `expiresAt`; expired bindings grant nothing and are hidden from effective views (`bindings.list` has `includeExpired`); `bindings.update` extends, shortens, or clears the expiry. `purgeDeleted` now also removes expired bindings and expires stale access requests, and reports `expiredBindings`/`expiredRequests`.

* Access requests (`accessRequests` API group): members with `iam:access-requests:create` request roles with a justification and optional duration; reviewers with `iam:access-requests:review` approve (creating bindings under their own grant authority, subject to `iam:bindings:create` on each role) or deny; requesters cancel; pending requests expire after `accessRequests.lifetimeMs` (default 7 days).

* Events and webhooks: every audit event is now an event. `iam.events.subscribe(pattern, handler)` and `events.onEvent` receive committed events from the dispatcher (`iam.events.dispatch`, alias of `dispatchAuditHooks`, now returns `{ dispatched }`). The `webhooks` API group subscribes HTTPS endpoints per tenant (root may subscribe a subtree) to event patterns; deliveries are queued in the same transaction as the audit record, signed with a per-subscription secret (`X-Better-IAM-Signature`, verify with `verifyWebhookSignature`), retried with exponential backoff, and listed through `webhooks.listDeliveries`. `events.deliverWebhook` replaces the built-in HTTPS transport.

* Outbox: `dispatchOutbox` returns `{ delivered, failed, abandoned }`, retries with backoff (30 seconds doubling to one hour), abandons messages after `authentication.maxDeliveryAttempts` (default 25) with `failedAt`/`lastError`, and dispatches in creation order.

* Configurable rate limits: `authentication.rateLimits` sets `attempts` (default 10), `sensitiveAttempts` (default 5), `windowMs` (default 15 minutes), and a pluggable `limiter`; `createMemoryRateLimiter()` is provided for single-process deployments and tests.

* Administration fills: `identities.delete` (tombstones the identity, revokes credentials, factors, bindings, memberships, authorities, and links; `identities.list` gains `kind`, `status`, and `includeDeleted` filters), `serviceAccounts.list/get/update/setStatus/delete` with an optional `description`, `credentials.list` (`iam:credentials:read`), `trust.list` (`iam:trust:read`), `root.listAdministrators`, and `audit.list` filters (`actorId`, glob `action`, `resourceId`, `outcome`, `from`, `to`) with newest-first ordering. `Identity.status` gains `deleted`.

* Internal restructure with no public API change: the server is split into focused modules (`options`, `catalog`, `context`, `events`, `decisions`, `principals`, `operations`, `flows`, `lifecycle`, `federation`, `http`, and one file per API group), the authentication service is assembled from feature classes over a shared base with `outbox` and `rate-limit` modules, SCIM separates its filter, discovery documents, provisioning, and handler, the OAuth provider adapter lives in its own module, `tenantTreeActive` in core replaces four private tenant-tree walkers, and the whole repository is formatted with Prettier.

* New packages: `@better-iam/react` (`IamProvider`, `useSession`, `useAuthorize`, `useAccessible`, `Can`, and the framework-agnostic `createSessionStore`) and `@better-iam/next` (`createIamNext` with `getSession`, `requireSession`, `require`, `can`, `handlers`, plus `createIamMiddleware`), exposed as `better-iam/react` and `better-iam/next`.

* `apps/console`: a Next.js administration panel (`/admin`, root administrators with MFA: organizations, root administrators, catalog, audit, deliveries) and multi-tenant cloud console (`/cloud`, alias sign-in, invitations, workspaces as managed resources, members, roles, groups, policies, resource types, resource registry, service accounts and API keys, account settings with sessions/MFA/linked accounts). Built on `iam.api.*` from server components, the typed client through `/api/iam`, and `iam.require` for enforced pages.

* Fixed `SafeIdentity`/`SafeSession` (returned by `getSession`, `listSessions`, `groups.listMembers`) losing their known properties: `Omit` over the record index signature erased them; they are now mapped types.

* Resource catalog: `permissions.resourceTypes` declares application-owned and IAM-managed resource types with actions, typed attributes, and parents; managed resources are registered through the new `resources` API and resolved without an application callback. Tenants in `tenant-defined` mode register their own managed types and `{type}:{verb}` actions through the `resourceTypes` and `actions` APIs. Policies, inline role documents, boundaries, ceilings, and session policies are validated against the catalog (`INVALID_ACTION`, `INVALID_RESOURCE_TYPE`).

* Breaking: tenant-defined actions no longer use the `tenant/{tenantId}/` prefix; `actions.list` returns `{ name, source, resourceType, description }` objects. Added `actions.unregister`.

* Roles: `roles.create`/`update` accept `description`, an inline `document`, or a `permissions` list; added `roles.get`, `roles.listBindings`, `bindings.list`, `bindings.read` action, `identities.listBindings` (effective roles through groups), `identities.listGroups`, `identities.get`, `identities.update`, `groups.get`, `groups.update`, `groups.listMembers`, `policies.get`, `policies.listVersions`, and policy/group descriptions. `policies.update` accepts name/description changes.

* Organizations as accounts: optional globally unique tenant `slug` on `tenants.create`, `bootstrap`, and `tenants.setSlug`; public `tenants.lookup` resolves an active tenant for alias-based sign-in. Member invitations (`identities.invite`, `listInvitations`, `revokeInvitation`, public `acceptInvitation`) onboard people into an existing tenant with roles and groups applied under the inviter's authority. `identities.create` no longer requires a password. `links.list` returns linked accounts for account switchers.

* `iam.authorizeMany` and `client.authorizeMany` evaluate up to 50 checks in one transaction for UI state. Invitation responses no longer include token hashes.

* Application-level integration test (`tests/application.test.ts`) exercising the HTTP handler and typed client end to end, with compile-time inference checks; the example application demonstrates aliases, invitations, permission roles, resource types, and managed workspaces.

* Tenant lifecycle administration: rename and move tenants with hierarchy, depth, cycle, and grant-authority validation; list and revoke owner invitations; delete pending tenants; retention purging through `iam.purgeDeleted` and the CLI `purge` command, with audit records preserved.

* `@better-iam/projects` reference plugin: tenant-scoped project records through `projects:read`/`projects:write` plugin endpoints (create, list, get, update, archive, restore), including purge cleanup of purged tenants' records.

* Foundations: the initial independent authentication and IAM implementation, with a tenant hierarchy, isolated identities, policies and delegation, root authority, service credentials, role assumption, password/MFA/passkey flows, federation/provisioning packages, a typed client, a CLI, SQL adapters, tests, examples, and synchronized packaging.
