@jantstack/adonis-authz 2.5.0-alpha.4 → 2.5.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -157,7 +157,7 @@ The relay is resumable and never silent: the report says *which* changes were ap
157
157
 
158
158
  **Closing the incident in one call: `requeueScopeChanges(ids, { apply: true })` / `node ace authz:scopes:requeue --id=<id> --apply` (2.4.0-alpha.8).** Measured in a consumer (panel relay 2, tester P14): after `requeue` the entry comes back with its *original* id — smaller than everything enqueued since — so the tenant's next inline `relayScopeChanges({ only })` found it *in front* and came out `deferred · depends-on:<id>`; the incident needed a full pass behind the command. With `apply: true` the entry is requeued **and relayed in the same call**, and the relay's report travels in `report.relay`. Four rules, each pinned by a case: **one lease** for the whole call, taken *first* — if another pass holds it the result is `relay.busy === true` with **zero rows requeued** and the command exits ≠ 0 (requeueing and then saying "busy" would leave the entry pending with `attempts = maxAttempts − 1`, to be parked again by the next pass and poison its key once more; `waitForLeaseMs` composes with it); it **passes the freeze barrier** before touching anything (with `apply` the command writes the tree; without `apply`, `requeue` still does not, it only touches the queue); it **does not compose with `force`** (500 `E_AUTHZ_CONFIG` — with both, reverting the tree over what was applied later would be a single call); and the internal `only` is **exactly the requeued ids**: what depended on the parked entry is *not* dragged along (it stays `deferred`/pending, the report says so — `remaining`, and the command prints it), because `only` is *your receipt* and nothing else. The anti-reversion probe still runs before anything is requeued (a refusal is 500 with zero driver calls, and the lease is released). If the cause is still broken, the same call parks the entry again (`attempts` goes from `maxAttempts − 1` to `maxAttempts`, `relay.failed` says why); a 503 from the store leaves it pending with `attempts = maxAttempts − 1`, as always. `--apply` is explicit, never the default: without it the command behaves exactly as before. Platform API, like the relay: never reachable over HTTP.
159
159
 
160
- **The relay is a single writer.** `pending()` reserves nothing, so two passes at once (a Kubernetes `CronJob` with the default `concurrencyPolicy: Allow`, two replicas, a pass that lasts longer than its interval) work on the same batch: the straggler re-applies an old `attached` after the other applied the new `moved`, and the store is left with the **old parent and a single edge, so nothing denounces it** — the old tenant keeps access to a subtree that is no longer theirs (measured). `sqlScopeOutbox.acquire()` takes a lease for the pass — a server-side lock on PostgreSQL and MySQL, a process-wide one on SQLite — and a second simultaneous pass does nothing and says so (`busy`). Since 2.4.0-alpha.5 that is **observed between real processes** (`tests/scope_outbox_multiprocess.spec.ts`: a second process relays the same queue while the first holds the lease ⇒ `busy`, zero writes, on PostgreSQL and MySQL), and the SQLite limitation is pinned the same way — the second process *does* apply over the first: **SQLite is not the engine for two replicas of the relay**. If you implement the port yourself and skip `acquire`, run the relay one pass at a time.
160
+ **The relay is a single writer.** `pending()` reserves nothing, so two passes at once (a Kubernetes `CronJob` with the default `concurrencyPolicy: Allow`, two replicas, a pass that lasts longer than its interval) work on the same batch: the straggler re-applies an old `attached` after the other applied the new `moved`, and the store is left with the **old parent and a single edge, so nothing denounces it** — the old tenant keeps access to a subtree that is no longer theirs (measured). `sqlScopeOutbox.acquire()` takes a lease for the pass — a server-side lock on PostgreSQL and MySQL, a process-wide one on SQLite — and a second simultaneous pass does nothing and says so (`busy`). On MySQL `GET_LOCK` is per **server**, so since the rotation 2 closure the lease name carries the **database** the holding session reads with `DATABASE()`; during 2.5.x every pass also takes the previous name (without the database) first, so a rolling deploy from 2.5.0-alpha.4 keeps a single writer — and two deployments on different databases of one server still exclude each other until 2.6 (declared in [Limits](#limits)). PostgreSQL's advisory lock is per database and does not change. Since 2.4.0-alpha.5 that is **observed between real processes** (`tests/scope_outbox_multiprocess.spec.ts`: a second process relays the same queue while the first holds the lease ⇒ `busy`, zero writes, on PostgreSQL and MySQL), and the SQLite limitation is pinned the same way — the second process *does* apply over the first: **SQLite is not the engine for two replicas of the relay**. If you implement the port yourself and skip `acquire`, run the relay one pass at a time.
161
161
 
162
162
  **Waiting for the lease instead of giving up: `relayScopeChanges({ waitForLeaseMs })` / `--wait-lease-ms` (2.4.0-alpha.8).** By default (`waitForLeaseMs: 0`) a pass that finds the lease taken reports `busy` at once — one `acquire()` attempt, the behaviour above, unchanged. With a positive value the pass retries `acquire()` every `RELAY_LEASE_POLL_MS` (25 ms; measured 0.29 ms per busy poll in PostgreSQL, and a failed poll holds no connection between attempts) until the lease is free or the budget is spent, and then reports `busy` — bounded, never a hang. It is opt-in and it **needs a pool of at least 2** connections, like `{ transaction }`: with a pool of 1 the waiting pass may be holding the very connection the pass that has the lease needs. Do not size it to a full backlog: a pass over 2,000 entries with real backend writes takes tens of seconds; the option exists so that an operator's `authz:scopes:requeue --apply --wait-lease-ms` does not lose systematically against the cron, not to queue relays behind each other.
163
163
 
@@ -309,7 +309,7 @@ await authorization.listDenies(subject, scope) // direct d
309
309
 
310
310
  Everything below is composition in the manager over the driver port; a driver keeps its 2.0 shape. The port only gained two *optional* methods, `listDenies?` and `authorizeMany?` — a driver without them still passes `level: '2.0'`, and a primitive that needs one it lacks says so (500 `E_AUTHZ_UNSUPPORTED`, never a simulated `[]`).
311
311
 
312
- **Containment.** **All nine writes** — `grant`, `revoke`, `deny`, `removeDeny`, `scopes.attached/moved/detached` and, since 2.2, `defineScopedRole`/`updateScopedRole`/`deleteScopedRole` — accept `within`: the scope being written must be inside it (`within ∈ chain(scope)`, inclusive; `APP_SCOPE` contains everything), checked against your tree *fresh* — the per-request memo is never used to decide a write. What is checked: the target scope for `grant`/`revoke`/`deny`/`removeDeny`; **both origin and destination** for `scopes.moved` — the new parent *and* the child's current chain — and the same for `scopes.attached` when the child already exists in the tree (attaching an existing node *is* a move; a new node only checks the parent); the child itself for `scopes.detached`; the role's **owner** for `defineScopedRole`/`updateScopedRole`/`deleteScopedRole`. Outside ⇒ 422 `E_AUTHZ_NOT_WITHIN`, nothing written, nothing purged, the driver not called. It is what stops "the admin of organization A grants in a unit of B by passing its uuid" — and, just as much, "removes B's deny" (removing a deny *is* granting), "revokes B's role", "purges B's unit" or **"moves B's unit under A"** (annexing a subtree inherits everything in it: worse than purging it) (*"within en las otras cuatro escrituras"*, *"within contrasta también el ORIGEN de scopes.moved/attached"*). Because the origin is read from your tree, notify `scopes.moved` **before** you re-parent the row, exactly as `scopes.detached` goes before the delete.
312
+ **Containment.** **All nine writes** — `grant`, `revoke`, `deny`, `removeDeny`, `scopes.attached/moved/detached` and, since 2.2, `defineScopedRole`/`updateScopedRole`/`deleteScopedRole` — accept `within`: the scope being written must be inside it (`within ∈ chain(scope)`, inclusive; `APP_SCOPE` contains everything), checked against your tree *fresh* — the per-request memo is never used to decide a write. What is checked: the target scope for `grant`/`revoke`/`deny`/`removeDeny`; **both origin and destination** for `scopes.moved` — the new parent *and* the child's current chain — and the same for `scopes.attached` when the child already exists in the tree (attaching an existing node *is* a move; a new node only checks the parent); the child itself for `scopes.detached`; the role's **owner** for `defineScopedRole`/`updateScopedRole`/`deleteScopedRole`. Outside ⇒ 422 `E_AUTHZ_NOT_WITHIN`, nothing written, nothing purged, the driver not called; its `message` names only the scope and the `within` you passed — the scope's chain, which is another tenant's tree, goes to `error.details.chain`, not enumerable (rotation 2 closure); for `updateScopedRole`/`deleteScopedRole`, where the scope checked is the role's owner and you only passed the role's uuid, the message names **the role**, never its owner (rotation 2 closure 2). And for `scopes.moved`/`scopes.attached` containment — destination **and** origin — is decided **before** the cycle check and `scopes.maxDepth`: a `within` that fails is `E_AUTHZ_NOT_WITHIN` whatever the edge would have been, so a cycle or a depth answer never tells you where another tenant's scope hangs (rotation 2 closure 2; without `within`, or with one that passes, the order of the other errors does not change). It is what stops "the admin of organization A grants in a unit of B by passing its uuid" — and, just as much, "removes B's deny" (removing a deny *is* granting), "revokes B's role", "purges B's unit" or **"moves B's unit under A"** (annexing a subtree inherits everything in it: worse than purging it) (*"within en las otras cuatro escrituras"*, *"within contrasta también el ORIGEN de scopes.moved/attached"*). Because the origin is read from your tree, notify `scopes.moved` **before** you re-parent the row, exactly as `scopes.detached` goes before the delete.
313
313
 
314
314
  ```ts
315
315
  await authorization.grant(user, 'unit-editor', unit, { within: currentOrg })
@@ -321,7 +321,7 @@ await authorization.isWithin(unit, currentOrg) // the same question on its own
321
321
 
322
322
  > **`within` must come from the session, never from the request body.** `within = scope` (or the scope's own parent) satisfies the rule *by definition* — the chain always contains the scope itself — so a `within` taken from the same input as the scope is no containment at all; `'non-root'` closes the `app` wildcard, not that one. Take it from the authenticated tenant (`currentOrg` above: the organization the session belongs to), and let the request only name *what* inside it to write.
323
323
 
324
- `requireWithin: true` in the config makes any of the nine writes without `within` a 422 `E_AUTHZ_WITHIN_REQUIRED`. `requireWithin: 'non-root'` additionally rejects `within: APP_SCOPE` with 422 `E_AUTHZ_WITHIN_ROOT_FORBIDDEN`: the root contains everything, so as a containment it says nothing — it was the wildcard a tenant call-site could pass to satisfy the rule without naming its tenant. Platform code that really writes at the root uses `manager.driver()` (below) or a config without the flag. **The default is `false` — containment is opt-in in 2.1** and the manager warns once per config at construction (`warnOnOptInSecurity: false` silences it once you have decided). Same for `requireActor`.
324
+ `requireWithin: true` in the config makes any of the nine writes without `within` a 422 `E_AUTHZ_WITHIN_REQUIRED`. `requireWithin: 'non-root'` additionally rejects `within: APP_SCOPE` with 422 `E_AUTHZ_WITHIN_ROOT_FORBIDDEN`: the root contains everything, so as a containment it says nothing — it was the wildcard a tenant call-site could pass to satisfy the rule without naming its tenant. Platform code that really writes at the root uses `manager.driver()` (below) or a config without the flag. **A multi-tenant deployment must set `requireWithin`** (rotation 2 re-attack, owner's decision): containment is the only barrier that is judged *before* the delegation policy, so without `within` the 422 a tenant admin gets for touching another tenant's role (`updateScopedRole`/`deleteScopedRole` by uuid) can name that role's slug, rank or owner (`E_AUTHZ_ROLE_LEVEL_ABOVE_OWNER`, `E_AUTHZ_RANK_EXCEEDED`). Delegation without `within` is supported for a single tenant, not as a multi-tenant configuration. **The default is `false` — containment is opt-in in 2.1** and the manager warns once per config at construction (`warnOnOptInSecurity: false` silences it once you have decided). Same for `requireActor`.
325
325
 
326
326
  **Actor.** Every write (`grant`, `revoke`, `deny`, `removeDeny`, `scopes.*`) takes `{ actor }` — a `SubjectRef`, validated like any identity — which `onWrite` receives as `event.actor`. `requireActor: true` ⇒ a write without it is 422 `E_AUTHZ_ACTOR_REQUIRED` before the driver and before the hook. No `AsyncLocalStorage`: the actor is an explicit argument. For these six writes the engine never evaluates it (who may grant what is your policy) — **but the delegation API of 2.2 does**: there the `actor` is the whole policy, so it must come from the session and never from the request body. See [Scoped roles](#scoped-roles-22).
327
327
 
@@ -472,11 +472,12 @@ A role has an **owner**: `global` — declared in config and synced with `syncAu
472
472
  node ace authz:catalog:prune-orphans # dry run: lists the local roles whose owner no longer resolves
473
473
  node ace authz:catalog:prune-orphans --force # purges them (purgeRole each: assignments + links + row), notifies role_purged
474
474
  node ace authz:catalog:prune-orphans --force --allow-mass-purge # ... even if that is *every* owner (read the next paragraph first)
475
+ node ace authz:catalog:prune-orphans --max-local-roles=50000 # a local catalog larger than the default cap (10 000)
475
476
  ```
476
477
 
477
- It is a **platform** operation — a maintenance command with access to the catalog, like `authz:catalog:sync` — so it takes no actor and measures no rank, exactly like the `purgeRole` of last resort. `manager.pruneOrphanRoles({ force })` is, next to `manager.driver()`, **platform API**: it deliberately bypasses `requireActor` and `requireWithin`, so it belongs in a command or a maintenance job, never behind an HTTP controller. `--dry-run` is the default: nothing is written until a human has read the list. Because a dormant role may still be granting, every orphan is reported with `assignments` (live facts) and `stillGranting`, and the command lists those **apart, with a warning** — purging them revokes permissions that work today. The flag is conservative: it counts live facts and does not check whether each fact's scope still resolves, so `false` means "grants nothing, for sure" and `true` means "look before you force". **Those facts are counted by the driver** (`countRoleAssignments`, 2.3), because facts belong to the driver: counting rows of `authz_assignments` from the sweep meant that with `openfga` — where facts live in the store — `stillGranting` was *always* `false`, which is the one answer that must never be wrong here. A driver that does not implement it leaves both fields **`undefined`, never `false`**, and the command lists those orphans apart too, with their own warning: "I don't know" is not "it does not grant" when the next step is a delete. The roles are read **from the database**, not from the catalog memo (with `catalogRevalidate: { everyMs }` a role another process has just committed is not in your snapshot), in a stable order by uuid, so the list and the `role_purged` events reproduce identically on PostgreSQL, MySQL and SQLite, and no more than `maxLocalRoles` (10 000) of them (500 `E_AUTHZ_TOO_MANY_LOCAL_ROLES`, never a partial list). A second pass is a no-op. A driver without `purgeRole` says 500 `E_AUTHZ_UNSUPPORTED` before reading anything.
478
+ It is a **platform** operation — a maintenance command with access to the catalog, like `authz:catalog:sync` — so it takes no actor and measures no rank, exactly like the `purgeRole` of last resort. `manager.pruneOrphanRoles({ force })` is, next to `manager.driver()`, **platform API**: it deliberately bypasses `requireActor` and `requireWithin`, so it belongs in a command or a maintenance job, never behind an HTTP controller. `--dry-run` is the default: nothing is written until a human has read the list. Because a dormant role may still be granting, every orphan is reported with `assignments` (live facts) and `stillGranting`, and the command lists those **apart, with a warning** — purging them revokes permissions that work today. The flag is conservative: it counts live facts and does not check whether each fact's scope still resolves, so `false` means "grants nothing, for sure" and `true` means "look before you force". **Those facts are counted by the driver** (`countRoleAssignments`, 2.3), because facts belong to the driver: counting rows of `authz_assignments` from the sweep meant that with `openfga` — where facts live in the store — `stillGranting` was *always* `false`, which is the one answer that must never be wrong here. A driver that does not implement it leaves both fields **`undefined`, never `false`**, and the command lists those orphans apart too, with their own warning: "I don't know" is not "it does not grant" when the next step is a delete. The roles are read **from the database**, not from the catalog memo (with `catalogRevalidate: { everyMs }` a role another process has just committed is not in your snapshot), in a stable order by uuid, so the list and the `role_purged` events reproduce identically on PostgreSQL, MySQL and SQLite, and no more than `maxLocalRoles` (10 000) of them (500 `E_AUTHZ_TOO_MANY_LOCAL_ROLES`, never a partial list). **The cap is an option since the rotation 2 closure**: `pruneOrphanRoles({ maxLocalRoles })` / `--max-local-roles=<n>` (an integer ≥ 1, otherwise 500 `E_AUTHZ_CONFIG` before reading anything). Until then the error said "raise the cap" and nothing could: a tenant that defined 10 001 local roles left `prune-orphans` — the dry run included — dead for every tenant. A second pass is a no-op. A driver without `purgeRole` says 500 `E_AUTHZ_UNSUPPORTED` before reading anything.
478
479
 
479
- **Two safeties, because the dangerous input is your own resolver.** The realistic accident is not someone calling the method by hand: it is a `scopes.resolveChain` **filtered by the request's tenant** — a normal multi-tenant pattern — or running with no context at all (a command, a lagging replica). It answers `null` for everything, so *every* local role looks orphaned and one `--force` pass deletes the local catalog of every tenant (measured: 2 of 2 live roles). So (a) if **all** distinct owners come out orphaned, or the orphans are more than **50 %** of the local roles, `force` is 500 `E_AUTHZ_MASS_PURGE_REFUSED` **before deleting anything**, naming the ratio; a real large prune passes `allowMassPurge: true` (`--allow-mass-purge`), which is a human decision, not a default (the dry run does not throw — it is the diagnostic you need to be able to read — it reports `massPurge: true`). And (b) each owner is re-resolved **fresh immediately before its own `purgeRole`**: the window between reading and deleting is the whole pass, not an instant, so a concurrent `scopes.attached` or restore would otherwise delete a role whose owner is back. A role whose owner came back is skipped and reported (`skipped: [{ role, reason: 'owner-came-back' }]`). The set is not atomic — it does not need to be — so the report says **which** roles were purged (`purged: CatalogRoleRef[]`), not how many: if one `purgeRole` fails halfway, the previous ones are already gone, and with the above that can be a partial revocation of live permissions.
480
+ **Two safeties, because the dangerous input is your own resolver.** The realistic accident is not someone calling the method by hand: it is a `scopes.resolveChain` **filtered by the request's tenant** — a normal multi-tenant pattern — or running with no context at all (a command, a lagging replica). It answers `null` for everything, so *every* local role looks orphaned and one `--force` pass deletes the local catalog of every tenant (measured: 2 of 2 live roles). So (a) if **all** distinct owners come out orphaned, or the orphans are more than **50 %** of the local roles, **or the orphans belong to more than one owner and any of them still grants (`stillGranting: true`) or nobody knows (`undefined`)**, `force` is 500 `E_AUTHZ_MASS_PURGE_REFUSED` **before deleting anything**, naming the counts (never another owner's identifiers); a real large prune passes `allowMassPurge: true` (`--allow-mass-purge`), which is a human decision, not a default (the dry run does not throw — it is the diagnostic you need to be able to read — it reports `massPurge: true` and why, in `massPurgeReasons`: `'all-owners'`, `'proportion'`, `'granting-across-owners'`). **Why the third trigger** (rotation 2 closure): the ratio is something a tenant controls — with the legitimate delegation API it inflates its own local catalog until the other tenants' orphans drop below 50 % — and the realistic blind resolver is not only "`null` for everything" but a job that inherited **one** tenant's context: that tenant stays alive and every other tenant's local roles, with their live assignments, come out orphaned. A single orphaned owner with live assignments still passes (a tenant that leaves is the normal case, and the listing marks it apart) — **so the third trigger does not cover a single victim**: a job whose resolver leaked tenant A's context, on a database where only one other owner has local roles, orphans that one owner and nothing fires (declared in [Limits](#limits)). The recipe: always read the `--dry-run` before `--force` (it marks those roles `stillGranting: true`), and run the pass with a resolver that has **no** tenant context. And (b) each owner is re-resolved **fresh immediately before its own `purgeRole`**: the window between reading and deleting is the whole pass, not an instant, so a concurrent `scopes.attached` or restore would otherwise delete a role whose owner is back. A role whose owner came back is skipped and reported (`skipped: [{ role, reason: 'owner-came-back' }]`). The set is not atomic — it does not need to be — so the report says **which** roles were purged (`purged: CatalogRoleRef[]`), not how many: if one `purgeRole` fails halfway, the previous ones are already gone, and with the above that can be a partial revocation of live permissions.
480
481
 
481
482
  **Facts of live descendants survive `detached`, and wake up with the scope.** `scopes.detached` purges the facts of the **exact** scope; an assignment held in a *descendant* whose path went through it is not touched (invariant 11 — you purge each node of the branch you delete). While the branch is gone those facts grant nothing, because the descendant does not resolve either; but if the scope is **restored with the same uuid** (an undelete, a restore from the bin, re-creating the unit) the facts grant again, with no write of any kind. Between 2.2's first cut and 2.3 the role took its assignments with it, so this is a change of behaviour, and it is deliberate: the tree of *today* decides (invariant 18). If you want those facts gone, delete them by notifying `detached` for every node of the branch, or run `authz:reconcile --to=<driver> --prune` (2.3), which reports — and, with `--prune`, deletes — the facts whose scope no longer resolves.
482
483
 
@@ -1214,7 +1215,7 @@ Every error the package raises carries `status` and `code`. A standard AdonisJS
1214
1215
  | `E_AUTHZ_INVALID_IDENTITY` | 422 | malformed holder/scope, `{app, uuid}`, root sentinel outside `app` |
1215
1216
  | `E_AUTHZ_INVALID_SLUG` | 422 | role/permission slug: grammar, length, reserved name or prefix, collision |
1216
1217
  | `E_AUTHZ_UNKNOWN_ROLE` / `E_AUTHZ_UNKNOWN_PERMISSION` | 422 | not in the catalog (for that scope type), in `grant`/`deny`/`revoke`/`removeDeny`; by uuid in `purgeRole`/`updateScopedRole`/`deleteScopedRole` (2.2) |
1217
- | `E_AUTHZ_CATALOG_CONFLICT` | 422 | two catalogs in `config.catalogs` declare the same role `(slug, scopeType)` or permission; a local role whose `(slug, scopeType)` is already visible from its owner through a **more authoritative** definition (a global, or a local of an ancestor), or that appeared while it was being validated (2.2). **Not** a homonym local to a *descendant* (the new one shadows it, `shadowedByAncestor`), and **not** a global role of the spec colliding with a local one: the sync writes the global and reports it as `shadowedByGlobal` |
1218
+ | `E_AUTHZ_CATALOG_CONFLICT` | 422 | two catalogs in `config.catalogs` declare the same role `(slug, scopeType)` or permission; a local role whose `(slug, scopeType)` is already visible from its owner through a **more authoritative** definition (a global, or a local of an ancestor) — also when that definition was committed by another process while this one was being validated (2.2; since the rotation 2 closure a homonym committed in that window by an owner that is neither global nor an ancestor-or-self — a sibling tenant, a descendant — is **not** a collision and the message never names another tenant's owner; a *descendant*'s homonym committed in that window is shadowed **without** the rank check of shadowing and is not in the event's `shadowedByAncestor` — `authz:catalog:diff` lists it: accepted and declared, rotation 2 closure 2). **Not** a homonym local to a *descendant* (the new one shadows it, `shadowedByAncestor`), and **not** a global role of the spec colliding with a local one: the sync writes the global and reports it as `shadowedByGlobal` |
1218
1219
  | `E_AUTHZ_ROLE_NOT_VISIBLE` | 422 | `grant` of a local role outside its owner's subtree; a `{ uuid }` `RoleQuery` whose role is declared for another level or whose owner is not in the scope's chain (2.2) |
1219
1220
  | `E_AUTHZ_AMBIGUOUS_ROLE` | 422 | `grant`/`revoke`/`hasRole`/`listSubjects` **by slug** where more than one role with that `(slug, level)` is visible in the chain — ask by `{ uuid }` (2.2) |
1220
1221
  | `E_AUTHZ_ROLE_IMMUTABLE` | 422 | `updateScopedRole`/`deleteScopedRole` on a global role (2.2) |
@@ -1222,10 +1223,10 @@ Every error the package raises carries `status` and `code`. A standard AdonisJS
1222
1223
  | `E_AUTHZ_ROLE_NOT_ASSIGNABLE_AT` | 422 | a role of level L carrying — or granted while carrying — a permission whose `assignableAt` excludes L: sync, `defineScopedRole`/`updateScopedRole`, `grant` (2.2) |
1223
1224
  | `E_AUTHZ_PERMISSION_NOT_DELEGABLE` | 422 | `defineScopedRole`/`updateScopedRole`: a permission not in `delegablePermissions`, or not effective for the actor in the owner (not granted, or denied) (2.2) |
1224
1225
  | `E_AUTHZ_RANK_EXCEEDED` | 422 | a local role's rank outside `0 < rank < min(actor's rank, highest global rank)`, or touching (or shadowing) a role of rank ≥ the actor's — `defineScopedRole`, `updateScopedRole`, `deleteScopedRole` |
1225
- | `E_AUTHZ_UNKNOWN_SCOPE` | 422 | write on a scope the resolver does not know; unknown parent in `scopes.*` |
1226
+ | `E_AUTHZ_UNKNOWN_SCOPE` | 422 | write on a scope the resolver does not know; unknown parent in `scopes.*`; for `updateScopedRole`/`deleteScopedRole`, a role whose owner no longer resolves (dormant) — there the `message` names the role you passed, and the owner (which you did not pass, and may be another tenant's) is in `error.details.owner`, **not enumerable** (rotation 2 closure 2) |
1226
1227
  | `E_AUTHZ_NO_SCOPE_RESOLVER` | 422 | driver without `resolveChain` asked about a non-`app` scope |
1227
1228
  | `E_AUTHZ_SCOPE_CYCLE` | 422 | `scopes.attached/moved` would close a cycle; `hierarchicalScopeResolver` met a cycle |
1228
- | `E_AUTHZ_NOT_WITHIN` | 422 | any of the nine writes with `within` not in the chain of the scope it writes to (the new parent **and** the child's current chain for `scopes.moved`, and for `scopes.attached` of an existing child; the role's owner for the delegation API since 2.2) (2.1) |
1229
+ | `E_AUTHZ_NOT_WITHIN` | 422 | any of the nine writes with `within` not in the chain of the scope it writes to (the new parent **and** the child's current chain for `scopes.moved`, and for `scopes.attached` of an existing child; the role's owner for the delegation API since 2.2) (2.1). The `message` names only what the caller passed — the scope and its `within` —; the scope's chain (another tenant's identifiers) is in `error.details.chain`, **not enumerable**, so `JSON.stringify(error)` does not carry it (rotation 2 closure; until then the message printed the whole chain). For `updateScopedRole`/`deleteScopedRole` the message names the role you passed, not its owner (rotation 2 closure 2). In `scopes.moved`/`scopes.attached` it comes **before** `E_AUTHZ_SCOPE_CYCLE` and `E_AUTHZ_SCOPE_DEPTH_LIMIT` (rotation 2 closure 2). The difference with `E_AUTHZ_UNKNOWN_SCOPE` is an existence oracle, declared in [Limits](#limits) |
1229
1230
  | `E_AUTHZ_WITHIN_REQUIRED` | 422 | `requireWithin` set and a write without `within` (2.1) |
1230
1231
  | `E_AUTHZ_WITHIN_ROOT_FORBIDDEN` | 422 | `requireWithin: 'non-root'` and `within: APP_SCOPE` (2.1) |
1231
1232
  | `E_AUTHZ_ACTOR_REQUIRED` | 422 | `requireActor: true` and a write without `actor` (2.1); on the relations port (`relations.requireActor ?? requireActor`) the four writes, `purgeObject`/`purgeSubject` included since 2.4.0-alpha.3 |
@@ -1251,9 +1252,9 @@ Every error the package raises carries `status` and `code`. A standard AdonisJS
1251
1252
  | `E_AUTHZ_RENEWAL_UNSAFE` | 409 | `relate` on `openfga` (closure 2 of sublot D2) renewing — another expiry on an existing tuple — a `group:*#member` membership whose group, or a group containing it, is the subject of a live deny of the partition: the renewal is two `Write`s there and between them the subject would be granted. **Always zero writes**; the message carries the honest recipe (closure 5 of D2): there is no window-free way on `openfga` (openfga/openfga#2151); memberships without expiry in groups with a deny, or `unrelate` + `relate` accepting the window. A driver declaring `atomicRenewal: false` must answer it the same way. An already EXPIRED membership is not refused (closure 3 of D2). **Since 2.5.0-alpha.4 (L11) also** `relate` (relations) and `grant` (roles) on `openfga` when the key already exists with a condition the package does not write or cannot read (`foreign`/`unparseable`, see «The pinned model is the one your config generates»): zero writes, the message says "condición que este paquete no escribe"; its recipe is to inspect that tuple and delete it by hand, NOT `unrelate` + `relate` with a window |
1252
1253
  | `E_AUTHZ_RENEWAL_RACED` | 409 | `RenewalRacedError` (closure 4 of D2; until then the same `E_AUTHZ_RENEWAL_UNSAFE`): an allowed two-`Write` renewal on `openfga` WENT THROUGH, and the re-check after it finds a deny that arrived concurrently and did not reach the subject between the two `Write`s. Marked partial (`indeterminate: true`), `renewalApplied: true`, `carriedDeny`. The final state is right: do NOT apply the `E_AUTHZ_RENEWAL_UNSAFE` recipe (it would reopen the window with the deny inside); review the subject's decisions in the window |
1253
1254
  | `E_AUTHZ_DENY_MIGRATION_REFUSED` | 500 | `authz:relations:reconcile` / `runRelationsReconcile` (2.5.0-alpha.3): the source carries a live deny the **destination driver** cannot take — no `relationDenies`, no `denyPhase`, the type not in `deny: 'write'` in ITS config, the pair undeclared there, its pinned model without `denied_*`, or a source that does not mark `kind`. Decided in the plan phase: **zero writes** in every partition, never `skipped` (the allows alone would grant what the source denies). `error.refusals` lists them; `--dry-run` flags `report.denyRefused` instead of throwing (`deny NO migrable (<reason>)`, exit ≠ 0) |
1254
- | `E_AUTHZ_MASS_PURGE_REFUSED` | 500 | `pruneOrphanRoles({ force: true })` would purge every distinct owner (or more than half the local roles): the signature of a blind `resolveChain`. Nothing was deleted; pass `allowMassPurge: true` (`--allow-mass-purge`) if the prune is real |
1255
+ | `E_AUTHZ_MASS_PURGE_REFUSED` | 500 | `pruneOrphanRoles({ force: true })` would purge every distinct owner, or more than half the local roles, or orphans of more than one owner of which any still grants or cannot be counted (rotation 2 closure): the signature of a blind `resolveChain`. Nothing was deleted; pass `allowMassPurge: true` (`--allow-mass-purge`) if the prune is real |
1255
1256
  | `E_AUTHZ_MEMBERS_DENY_LIMIT` | 500 | `membersOf` on `database` over an object with a live deny and more than `RELATIONS_MEMBERS_DENY_CHECK_MAX` (1,000) candidates; never a partial list. 500 and not 422: it is not an invalid question from the caller but a bound of the deployment (precedent `E_AUTHZ_TOO_MANY_LOCAL_ROLES`) (2.5.0) |
1256
- | `E_AUTHZ_TOO_MANY_LOCAL_ROLES` | 500 | more local roles than `maxLocalRoles` (10 000) in a `prune-orphans` pass; never a partial list |
1257
+ | `E_AUTHZ_TOO_MANY_LOCAL_ROLES` | 500 | more local roles than `maxLocalRoles` (10 000) in a `prune-orphans` pass; never a partial list. Raise it with `pruneOrphanRoles({ maxLocalRoles })` / `--max-local-roles` |
1257
1258
  | `E_AUTHZ_UNSUPPORTED` | 500 | a primitive needs an optional port method the active driver lacks: `listDenies` (2.1; also behind `defineScopedRole`), `purgeRole` (2.2 — behind `deleteScopedRole`, `authz:catalog:prune-orphans` and, before writing anything, `defineScopedRole`; the `openfga` driver until 2.3); `reconcile` (2.3 — `authz:reconcile --to=<driver>`); `enumerateFacts` (2.3 — being the *source* of `authz:reconcile`; the `database` driver does not implement it: its facts are `authz_*`. Also raised when `--to` is the **active** driver and declares `hierarchyFacts`: its facts are its own, so the pass has to be able to read them instead of rebuilding it from `authz_*`). Also `{ transaction }` on `grant`/`revoke`/`deny`/`removeDeny` or `relate`/`unrelate`/`purge*` when the active driver declares `transactionalWrites: false` (2.4 — the message names the driver and the operation, nothing was called; the way out is the `database` driver or `requireTransactionalWrites`; the `openfga` drivers raise it themselves too, so `manager.driver()` is not a way out — see [the recipe by direction](#openfga-the-recipe-by-direction)), and `{ transaction }` on the delegation API (`defineScopedRole`/`updateScopedRole`/`deleteScopedRole`: the catalog is written through `withAuthzCatalogWrite`, the cross-process serializer, which a caller's transaction would defeat). And (2.5.0-alpha.4 · 7.4) a page argument passed to a relations listing — `listObjects`/`listSubjects`/`listDenies`/`membersOf` take none: anything but `undefined` is refused with zero backend calls, in the manager and in both drivers —, or an `after` handed to `enumerateRelations(Many)` of `openfga` (it never emits a cursor) |
1258
1259
  | `E_AUTHZ_MODEL_TOO_LARGE` | 500 | the catalog does not fit in an OpenFGA authorization model (262,144 **bytes** — around **450 permissions with realistic slugs**; see [the model ceiling](#the-model-ceiling-is-bytes-not-permissions)): checked in `syncAuthzCatalog` **before** writing, with a warning past 80 % |
1259
1260
  | `E_AUTHZ_RELATION_CONFIG` | 422 | a `relations/` object type or relation cannot be **fused** into the shared `facts` model: it duplicates a reserved `facts` type (`scope`/`role`/`role_binding`/`deny_binding`/`group`) or relation family (`can_<P>`/`denied_<P>`/`permits_<P>`/`parent`/`rooted`/`assignee`…), or a relation name collides with a catalog permission (F-04). The generator raises it — not OpenFGA's opaque 400 — because in the shared store the id-spaces overlap |
@@ -1336,6 +1337,7 @@ Every driver **declares** this through `driver.capabilities`, and each declared
1336
1337
  | `canonicalScopeReads` | `true` | **`false`** | whether a read canonicalises the caller's scope spelling against your tree before looking for facts (see below) |
1337
1338
  | `enumerateFacts` | `false` | **`true`** | the driver can be the **source** of a migration: `enumerateFacts` hands its live facts back page by page, unfiltered and with their expiry (2.3). `database` declares `false` on purpose — its facts *are* `authz_assignments`/`authz_denies`, this package's published schema, so the destination reads them straight from there |
1338
1339
  | `experimental` (2.5.0-alpha.4; optional, normalised to `false`) | `false` | **`true`** in 2.5.x | the driver is outside the guarantee of the release: it is not built without `experimental: 'openfga@2.5'` and its open limits are in [Limits](#limits); the suite still runs whole against it (no case is skipped) |
1340
+ | `snapshotDecisions` (rotation 2 closure 3; optional, normalised to `false`) | **`true`** | `false` | every **decision** that combines grants and denies reads them from **one state** of the facts: `authorize` (and each position of `authorizeMany`) and `listScopes` in one SQL statement, and `readSnapshot` — the optional port method the manager uses for what it composes from two reads (`effectivePermissions`, `authorizedScopes`, the delegation policy of `defineScopedRole`/`updateScopedRole`). **The capability decides, not the method**: the manager calls `readSnapshot` only when the driver declares `true` (declaring `true` without the method is 500 `E_AUTHZ_CONFIG` before any read), and a method of that name on a driver that declares `false` is never called. Without it the manager composes as before — two reads with no common snapshot, declared in the [torn read](#limits) row. Your tree (`resolveChain`) and the catalog memo are read outside that snapshot. (The `database` **relations** driver has no such capability to declare: each of its decisions — `check`, `checkMany`, `decideMany`, `listObjects` — is one SQL statement, and so is `membersOf` while the object has no live deny (its candidates and its deny check together); with a deny on the object `membersOf` is 1 + N statements — the candidates, then one `check` per candidate, one snapshot per position like `authorizeMany` —, see the torn-read row) |
1339
1341
  | `transactionalWrites` | **`true`** (default; declare `transactionalWrites: false` in the driver options on a pool of 1) | **`false`** | `grant`/`revoke`/`deny`/`removeDeny` with **`{ transaction }`** write the fact **inside the caller's transaction** — *both or neither*, never *"not lost"* (2.4, the `{trx}` panel). `database` does it since 2.4.0-alpha.2 (lot L-3): the write goes through your open Lucid transaction of the driver's connection (`assertCallerTransaction`: another connection, a `QueryClient` or the whole `db` are 500 `E_AUTHZ_CONFIG` before any statement), the authority never does, and the published suite pins **rollback ⇒ zero rows by census** for the four writes — see [writing inside your transaction](#writing-inside-your-transaction-240-alpha2). `openfga` cannot be anything but `false`: a tuple does not enter a SQL transaction and there is no 2PC; what it offers instead is [the recipe by direction](#openfga-the-recipe-by-direction). With `false`, `{ transaction }` is **500 `E_AUTHZ_UNSUPPORTED`** naming driver and operation, with **zero driver calls** (the manager's gate *and* the driver's own) — never ignored, never a warning; `requireTransactionalWrites: true` in the config makes it **500 `E_AUTHZ_CONFIG` when the driver is resolved** (the deployment does not start). Same name and same two gates on `RelationsDriverCapabilities` (`relate`/`unrelate`/`purgeObject`/`purgeSubject`; `relations.requireTransactionalWrites`, inherited from the root) — and since lot L-4 the `database` **relations** driver declares **`true`** too (`new DatabaseRelationsDriver(config, { transactionalWrites: false })` on a pool of 1; see [writing relations inside your transaction](#relations-rebac-24)). The only pair whose **two** faces are mandatory in the suite. Not to be confused with `scopes.*`'s `{ transaction }`, which **enqueues** in the outbox — enqueueing ≠ writing |
1340
1342
 
1341
1343
  **Two answers `facts` does not share with `database`, declared — not scheduled fixes (2.3).** `authorize` decides with the tree that lives in the store; it never calls your `resolveChain`. That is the property you are buying: a PDP that answers when your database does not. It has two consequences, and each one is a **capability pair with a negative case** in the published contract suite — the package does not skip them, it judges both faces.
@@ -1424,6 +1426,7 @@ runAuthorizationDriverContract({
1424
1426
  canonicalScopeReads: true, // reads canonicalise the caller's scope spelling against your tree (2.3); false ⇒ an alias finds no facts (fail-closed)
1425
1427
  enumerateFacts: false, // the port's optional enumerateFacts (2.3): can this driver be the SOURCE of authz:reconcile?
1426
1428
  experimental: false, // optional (2.5.0-alpha.4): true ⇒ the driver declares itself outside the release's guarantee (the suite still runs whole)
1429
+ snapshotDecisions: false, // optional (rotation 2 closure 3): true ⇒ the port's readSnapshot reads grants and denies from ONE state; judged at '2.1' and above
1427
1430
  },
1428
1431
  // The suite builds the scope tree case by case; hand it to your driver.
1429
1432
  makeDriver: (tree) => new MyDriver({ resolveChain: resolveChainFrom(tree) }), // tree.chainOf(scope) = the canonical chain
@@ -1440,6 +1443,8 @@ runAuthorizationDriverContract({
1440
1443
 
1441
1444
  Declaring a capability `true` that the suite has no case for makes registration throw — a promise without a judge does not pass. The port also has optional methods a driver may implement to do better than the manager's composition — `onScopeAttached/Moved/Detached` (tree as facts) and, since 2.1, `withChainResolver(resolver)` (a view of the driver bound to another resolver, what `forRequest()` uses to memoise reads), `listDenies(subject, scope?)` (direct denies; what `effectivePermissions` and `authorizedScopes` subtract), `authorizeMany(subject, permission, scopes)` (one round-trip for N decisions; its `boolean[]` is validated) and `rolesInChain(subject, chain)` (the holder's direct roles along a resolved chain in one read; what `effectivePermissions` uses); a driver without them keeps passing the same suite at `'2.0'`. At `'2.1'`, **`listDenies` is a capability pair**: `listDenies: true` judges `listDenies`, `effectivePermissions`, `authorizedScopes` and the shared catalog version through them; `listDenies: false` judges instead that those primitives *say so* — 500 `E_AUTHZ_UNSUPPORTED` naming the method, never a simulated `[]` (the package runs that face itself, over a `database` view without the method: *"sin listDenies en el puerto: … 500 E_AUTHZ_UNSUPPORTED"*). Declaring `listDenies: true` below `'2.1'` throws: nothing observes it there. `exhaustiveLists: false` asks for the backend's cap and proves only the exact boundary.
1442
1445
 
1446
+ Since the rotation 2 closure the port also has **`readSnapshot(subject, reads)`** (optional, with the capability **`snapshotDecisions`**, normalised to `false`): several port reads answered from **one state** of your facts — `reads` names any of `rolesInChain` (a resolved chain), `listScopes` (a permission) and `listDenies: true`, and each key comes back exactly as the method of the same name would answer it; what was not asked does not come back, and a malformed `reads` is 422 `E_AUTHZ_INVALID_IDENTITY`. The manager uses it for the decisions it *composes* from a grant read and a deny read — `effectivePermissions`, `authorizedScopes` and the delegation policy of `defineScopedRole`/`updateScopedRole` —, because two reads with no common snapshot can answer what no real state answers (a user offboarded with a `revoke` and then a `removeDeny` between the two reads came out of `effectivePermissions` with the permission the deny was there to remove). A driver without it keeps working exactly as before: the manager composes the two reads, and that gap is yours, declared in the [torn read](#limits) row. At `'2.1'` the pair judges either face: `true` — the method answers like its namesakes and the manager calls it **once** and never `rolesInChain`/`listScopes`/`listDenies` (*"snapshotDecisions:true · …"*; the judge counts only the manager's calls, so a `readSnapshot` that calls your own methods inside — a `REPEATABLE READ` transaction, say — passes); `false` — the manager never calls `readSnapshot`, even if your driver has a method with that name (the capability decides), and composes subtracting the denies (*"snapshotDecisions:false · …"*; without `listDenies` there is nothing composed to judge and that face only checks the declaration, as its title says). The delegation-policy part of each face (a `defineScopedRole` that must answer 422 because the actor has the permission denied) runs only when the harness declares `purgeRole: true` — a `'2.2'` pair: without `purgeRole` the manager does not delegate at all (500 before the policy) —, so a `'2.1'` driver with `listDenies` and without `purgeRole` passes the whole judge, and the title of each face says which variant was registered. The package runs the `false` face itself over a `database` view without the method (*"database (sin readSnapshot)"*), and both faces at `'2.1'` over `database` views without `purgeRole` (that one and *"database (2.1 sin purgeRole)"*). Whether your snapshot is really one is not something a generic judge can see from outside your backend: `database` pins it with forced interleavings and the surface guard `database.decision.snapshot`.
1447
+
1443
1448
  Since 2.2 the port has **`purgeRole(roleUuid)`** (optional): revoke every assignment of the role in every scope, delete its links and the role row, atomically, bumping the shared catalog version (`withAuthzCatalogWrite`); a malformed uuid is 422 `E_AUTHZ_INVALID_IDENTITY`, an unknown one 422 `E_AUTHZ_UNKNOWN_ROLE`. **The atomicity of the link deletion is guaranteed by the SCHEMA, not by the driver code**: `authz_role_permissions.role_uuid` is `ON DELETE CASCADE` and `authz_assignments.role_uuid` is `ON DELETE RESTRICT` in the published migration (and in the test mirror, compared action by action by the stub-vs-mirror guard). The judge counts the links after the purge, but on this schema a driver that "forgot" to delete them would pass anyway — the engine deletes them: measured, and it is an equivalent mutant in SQLite, PostgreSQL and MySQL (tester 3E · R7). **If you run your own schema without those actions, or with foreign keys disabled, deleting the links is your driver's job and no test of this package will catch you.** It does not distinguish global from local (that barrier is the manager's). A driver that cannot purge declares **`purgeRole: false`** and must throw 500 `E_AUTHZ_UNSUPPORTED` without touching anything — the pair at `'2.2'` judges either the purge (*"purgeRole(uuid) revoca todas las asignaciones del rol en TODOS los scopes"*) or the refusal (*"sin purgeRole de verdad: el driver lo dice con 500"*); `true` below `'2.2'` throws. The `'2.2'` cases also judge local roles on the driver itself — visibility by owner in every read and write (*"un rol local de la organization A concede en A y sus units"*, *"dos tenants definen el mismo slug"*, the reserved `global` key, deny × local role, `assignableAt` not evaluated) — using roles written straight into `authz_*` as another process would, and, under `listDenies: true`, the delegation API through a manager over your driver (*"defineScopedRole: el rol que el administrador de A delega concede en A y sus descendientes"*); under `listDenies: false`, that `defineScopedRole` says so with 500. A driver reads a role's owner from the catalog memo (`CatalogView.roleVisible(slug, scopeType, chainKeys)` — which **throws** 422 `E_AUTHZ_AMBIGUOUS_ROLE` when more than one role is visible: never resolve the ambiguity yourself —, `rolesNamed`, `roleByUuid(...).owner`, `isRoleVisibleWith`, and the shared `declaredRoleAt(catalog, uuid, scopeType, chainKeys)`) and applies the rule per level of the chain: an assignment in scope S counts only if the role is global or its owner is in chain(S).
1444
1449
 
1445
1450
  Since 2.3 the port also has **`countRoleAssignments(roleUuids)`** (optional, and **breaking if you wrote a 2.2 driver**): how many live facts each role has, across every scope, answered **by position** like `authorizeMany`. A live fact is an assignment that has not expired, judged with the driver's own clock; a role with none — or one the backend does not know — is `0`; a malformed uuid is 422 `E_AUTHZ_INVALID_IDENTITY`. It is what `pruneOrphanRoles` (`authz:catalog:prune-orphans`) asks before saying whether an orphan role is still granting, and it is a question for the *driver* because the facts are the driver's: until 2.3 the sweep counted rows of `authz_assignments` — the `database` driver's table — so with `openfga` it always answered "this role grants nothing", right before a destructive delete. It is deliberately conservative: it counts facts, it does not re-resolve each fact's scope. A driver that does not implement it declares **`countRoleAssignments: false`** and the sweep reports `assignments` and `stillGranting` as **`undefined`** — never `false` — and the command lists those roles apart; the pair at `'2.2'` judges either the counting (*"countRoleAssignments(uuids) cuenta los hechos VIGENTES"*) or that "I don't know" never degrades to "it does not grant" (*"sin countRoleAssignments: el puerto NO lo trae"*).
@@ -1591,7 +1596,7 @@ What one pass does, in order: the **root marker** (`scope:app#rooted`, without w
1591
1596
 
1592
1597
  **The store is SHARED, and this pass only governs its own four object types (2.4.0-alpha.9).** With `openfga`, the facts of this engine and the tuples of [`relations/`](#relations-rebac-24) live in the **same store**, and this pass has to read it whole (that is the only way to see what is left over). Until 2.4.0-alpha.8 everything it did not recognise was classified as a leftover *fact*, so **`--prune` deleted every relation tuple in the store** — 60 out of 60 measured by a consumer, the built-in `group` type included — and the destructive pass reported `clean = true` with exit 0, because it had just deleted the very thing that made it dirty. What this pass governs is now an explicit, frozen list of **object types**: `scope`, `role`, `role_binding` and the obsolete `deny_binding` (the deny of the ≤ 2.2 `resolver` mode, which `enumerateFacts` still reads and emits — so the promise that *one pass cleans a store written by the previous version* is kept whole). **Anything else is foreign**: it is not deleted, not even with `--prune`, it is not drift, and it does not enter `skipped` (which is why `clean` can be `true` again on a shared store). The classification **reads nothing at runtime** — not your relations config, not the published model, not a table: the process that runs a cutover is exactly the one that may not have that config, and a failed read must never turn somebody else's data into something deletable.
1593
1598
 
1594
- The report carries them in a field of their own, `foreign: { tuples, types, relations?, unknown? }` (`phases` does not grow: it is a published type), and the command prints them with their type: `ajenas: 60 tupla(s) de tipos que esta pasada NO gobierna [document (40), group (10), space (10)]`, plus `--prune ha respetado 60 tupla(s) ajenas` on the destructive pass. When `relations.config` is wired, `foreign` is split into `relations` (your declared types, plus `group`) and `unknown` (nobody claims them) — **for the report only**. The way back has no symmetric defect and never had one: the relations reconcile has always enumerated by an allowlist of types and cannot see, let alone delete, a fact (measured). Two limits worth writing down: foreign tuples still count against `--max-tuples` (the snapshot is of the *store*, not of this pass), and **the tuples of a relation type you no longer declare are swept by nobody** — take `document` out of `objectTypes` and its tuples stay in the store (`enumerateRelations` filters by the allowlist, `unrelate`/`purgeObject` are 422 by F-05, and this pass now respects them), so they **grant again if you ever re-declare that type**. The way out today is to declare the type again and run `authz:relations:reconcile --prune` **for that partition** — and two caveats before you do (audit of alpha.9): the pass refuses with `E_AUTHZ_MASS_RECONCILE_REFUSED` when the source (`database`) holds none of those tuples, and the flag that lifts that refusal (`--allow-mass-delete`) will happily empty the **live** relations of the partition you point it at, so check `--partition-type`/`--partition-uuid` twice; and the command reconciles **one partition per pass**, so a multi-tenant cleanup is a loop you write. A `--prune-undeclared` is on the 2.5 list. There is deliberately **no `--prune-foreign`**: a mass deletion of another subsystem's data, triggerable from CI, is not a flag.
1599
+ The report carries them in a field of their own, `foreign: { tuples, types, relations?, unknown? }` (`phases` does not grow: it is a published type), and the command prints them with their type: `ajenas: 60 tupla(s) de tipos que esta pasada NO gobierna [document (40), group (10), space (10)]`, plus `--prune ha respetado 60 tupla(s) ajenas` on the destructive pass. When `relations.config` is wired, `foreign` is split into `relations` (your declared types, plus `group`) and `unknown` (nobody claims them) — **for the report only**. The way back has no symmetric defect and never had one: the relations reconcile has always enumerated by an allowlist of types and cannot see, let alone delete, a fact (measured). Two limits worth writing down: foreign tuples still count against `--max-tuples` (the snapshot is of the *store*, not of this pass), and **the tuples of a relation type you no longer declare are swept by nobody** — take `document` out of `objectTypes` and its tuples stay in the store (`enumerateRelations` filters by the allowlist, `unrelate`/`purgeObject` are 422 by F-05, and this pass now respects them), so they **grant again if you ever re-declare that type**. The way out today is to declare the type again and run `authz:relations:reconcile --prune` **for that partition** — and two caveats before you do (audit of alpha.9): the pass refuses with `E_AUTHZ_MASS_RECONCILE_REFUSED` when the source (`database`) holds none of those tuples, and the flag that lifts that refusal (`--allow-mass-delete`) will happily empty the **live** relations of the partition you point it at, so check `--partition-type`/`--partition-uuid` twice; and the command reconciles **one partition per pass**, so a multi-tenant cleanup is a loop you write. A `--prune-undeclared` is on the 2.6 list. There is deliberately **no `--prune-foreign`**: a mass deletion of another subsystem's data, triggerable from CI, is not a flag.
1595
1600
 
1596
1601
  **`--dry-run` is the verifier, and it is read-only by contract**: same walk, same numbers, zero writes, exit 1 if there is anything to do. Run it in CI or in a cron. **`expired` does not fail the pass** (2.3): it is the migration's one *declared* loss — an expired assignment grants nothing in either driver, nothing sweeps expired rows out of the source (observable expiration without a scheduler, on purpose), and no write to the destination can "fix" it — so counting it as drift made the green verifier unreachable with real data: the first expiry pinned exit 1 forever. It is still reported (`N sin migrar por 'expired'`); it just is not drift. Expired facts *left over in the destination* are still drift (`extra-fact`), and their sweep exists: `--prune`. **There is no `--fix` and there will not be one** — a repair flag on a verifier is a grant mechanism. **And it does not freeze** (2.3): a pass that writes nothing has nothing to protect, so freezing there would buy zero and cost a write-outage mechanism fired by a cron job — which is precisely what "run it in a cron" invites. The pass that *does* write still freezes.
1597
1602
 
@@ -1790,9 +1795,9 @@ Every bound the package enforces or measured, in one place, each with the consta
1790
1795
  | Checks per `batchCheck` | `FGA_MAX_BATCH_CHECK` | 50 | `openfga`, `authorizeMany` | the driver splits into batches of 50 (the SDK's cap), nothing to do |
1791
1796
  | `ListObjects` effective cut of the server (relations `listObjects`) | `listObjectsDeadlineMs` (`DEFAULT_LIST_OBJECTS_DEADLINE_MS`) | 3,000 ms | the `openfga` relations driver (2.5.0 · B4) | a call that takes ≥ `LIST_OBJECTS_DEADLINE_RATIO` (0.9) × this value returns `truncated: true` — the server cuts at its deadline with a 200 and no signal; set it to the **lowest** of your server's `--listObjects-deadline` (when not 0), `--request-timeout` (3 s by default) and, over HTTP, `--http-upstream-timeout` (5 s by default) — declare that lowest one: with `--listObjects-deadline=0` the request timeout still cuts with a mute 200 (measured on OpenFGA v1.19.0), there is no "no deadline", `Infinity` is 500, both at 0 is not supported (the gateway's cut is not measured for `ListObjects`), and the driver's `timeoutMs` (5 s by default) cannot be lower than `listObjectsDeadlineMs` (500 at construction: the signal has to fire before the client cuts; `Number.MAX_SAFE_INTEGER` is caught by that). **The price of the conservative signal**: with the default, every `ListObjects` that takes 2,700 ms or more comes back `truncated` even when complete; a consumer that treats `truncated` as a 503 pays that p99 as user-facing 503s — raise the server's deadline (and this value) or handle `truncated` as a partial list, not as an outage |
1792
1797
  | Objects per `checkMany` / `decideMany` | `RELATIONS_CHECK_MANY_MAX` | 50 | the `RelationsManager` and both relations drivers (2.5.0) | 422 `E_AUTHZ_CHECK_MANY_BATCH` with zero backend calls — split the list yourself; `[]` is 422 too. One batch is one `batchCheck` in `openfga` and one query in `database` |
1793
- | `membersOf` candidates judged one by one when the object has a deny (`database`) | `RELATIONS_MEMBERS_DENY_CHECK_MAX` | 1,000 | the `database` relations driver (2.5.0) | 500 `E_AUTHZ_MEMBERS_DENY_LIMIT` before the first `check`, never a partial list; the whole loop also runs under one total deadline (`timeoutMs`), 503 `E_AUTHZ_BACKEND_TIMEOUT` past it. Without a deny row on the object there is no extra query and no bound |
1798
+ | `membersOf` candidates judged one by one when the object has a deny (`database`) | `RELATIONS_MEMBERS_DENY_CHECK_MAX` | 1,000 | the `database` relations driver (2.5.0) | 500 `E_AUTHZ_MEMBERS_DENY_LIMIT` before the first `check`, never a partial list; the whole loop also runs under one total deadline (`timeoutMs`), 503 `E_AUTHZ_BACKEND_TIMEOUT` past it. Without a deny row on the object there is no extra query and no bound (whether the object has one is read in the same statement as the candidates) |
1794
1799
  | Tuples a relations purge deletes in one `Write` (atomic for the writer; see the torn-read row) | `RELATIONS_PURGE_WRITE_MAX` | 100 | the `openfga` relations driver (2.5.0) | the server's `--max-tuples-per-write` default (101 is a 400, measured on v1.19.0). Up to it a purge is one `Write`; past it, allows → the subject's group memberships in their own `Write` → denies. With more than 100 memberships a cut purge can grant until repeated (declared row, [Relations](#relations-rebac-24), «Deny on relations»). **Requires `--max-tuples-per-write` ≥ 100 on the server**: below it, the `Write` that passes the server's cap — not necessarily the first — is 500 `E_AUTHZ_CONFIG` naming the requirement (never a 503 that looks transient: repeated, the purge would fail the same way forever), marked as a partial write (`indeterminate: true`) if an earlier `Write` already deleted |
1795
- | **Torn read**: a single `authorize`/`check` that runs while a write of the `openfga` drivers is in flight can read part of it — one relation of the model before the write, another after — and answer `true` where neither the state before nor the state after grants, past a deny (2.5.0-alpha.4, declared; invariant 2's declared exception) | — (no constant: a property of OpenFGA, which evaluates the branches of a `Check` as separate reads; it happens under `HIGHER_CONSISTENCY` too) | measured on OpenFGA v1.19 + postgres: **1.2–7.1 %** of the checks concurrent with a role-tree change (`scopes.moved`/`attached`/`detached` with roles; up to 7.1 % on a descendant of the moved scope), 1.4–1.8 % with a purge or a membership swap in one `Write`, 0.02 % across two consecutive `Write`s of the relations reconcile, ~10⁻⁵ for "two writes, any two (tree or facts), spanned by one check" on the scope they touch; the two contiguous `Write`s of F4 (the pre-sweep, then the tree edge) measured with the published model (c2r) and the real driver: **~1.8·10⁻³ on a DESCENDANT of the moved scope when it LEAVES the owner** (180 in 100,000; 49 in 60,000 in a second run, all of them leaving — the deny is evaluated one hop later there) and **~10⁻⁵ on the moved scope itself** (1 in 100,000) — OpenFGA v1.19 + postgres, `HIGHER_CONSISTENCY`, 1 writer + 7 readers, F4 audit 2026-10-19 (the 1 in 50,000 published before was a different model on the test harness) | `openfga` only (roles and relations); `database` has none (one SQL statement per question). **It is caused by whoever writes — in a role-tree change, whoever moves scopes** | one answer of one check, **never persisted**: the next check sees a real state. What the package guarantees is about **prefixes** — a write CUT halfway never leaves a state that grants what neither end granted (the order properties of the surface guard: purges, reconcile, `moved` since F4) —, not about readers; "atomic" in this README means atomic for the writer. A PEP that caches a `true` makes it persistent: do not cache `authorize`. Removing it needs a model or a mechanism change (planned for evaluation in 2.6) |
1800
+ | **Torn read**: a single `authorize`/`check` that runs while a write of the `openfga` drivers is in flight can read part of it — one relation of the model before the write, another after — and answer `true` where neither the state before nor the state after grants, past a deny (2.5.0-alpha.4, declared; invariant 2's declared exception) | — (no constant: a property of OpenFGA, which evaluates the branches of a `Check` as separate reads; it happens under `HIGHER_CONSISTENCY` too) | measured on OpenFGA v1.19 + postgres: **1.2–7.1 %** of the checks concurrent with a role-tree change (`scopes.moved`/`attached`/`detached` with roles; up to 7.1 % on a descendant of the moved scope), 1.4–1.8 % with a purge or a membership swap in one `Write`, 0.02 % across two consecutive `Write`s of the relations reconcile, ~10⁻⁵ for "two writes, any two (tree or facts), spanned by one check" on the scope they touch; the two contiguous `Write`s of F4 (the pre-sweep, then the tree edge) measured with the published model (c2r) and the real driver: **~1.8·10⁻³ on a DESCENDANT of the moved scope when it LEAVES the owner** (180 in 100,000; 49 in 60,000 in a second run, all of them leaving — the deny is evaluated one hop later there) and **~10⁻⁵ on the moved scope itself** (1 in 100,000) — OpenFGA v1.19 + postgres, `HIGHER_CONSISTENCY`, 1 writer + 7 readers, F4 audit 2026-10-19 (the 1 in 50,000 published before was a different model on the test harness) | `openfga` (roles and relations); **`database` — roles AND relations — reads the grants and denies of every decision in ONE SQL statement since the rotation 2 closure** — until then `authorize` read them in two, and a `deny` followed by a `grant` (or a transaction with both) committed between them answered `true` once, as did `listScopes`, `effectivePermissions`, `authorizedScopes` and the delegation policy across an offboarding (the manager composes them through `readSnapshot` now); and the relations driver's `membersOf` read the candidates and then, in a second statement, whether the object had any live deny — an offboarding (`unrelate`, then `removeDeny`) between the two listed a subject that was a member in no real state (the deny check rides in the candidates' statement now; `check`, `checkMany`, `decideMany` and `listObjects` already were one statement). With a deny on the object, `membersOf` judges each candidate with its own `check` — each one the answer of a real state, like each position of `authorizeMany`. What `database` still has is the **chain-vs-facts** shape: your `resolveChain` is read BEFORE the snapshot, so a `scopes.moved` followed by a fact change (e.g. a unit moved out of org A, then the `removeDeny` of a deny in A for a holder with a role in A) can be read as old chain + new facts and grant once in the unit, where no real state granted — it depends on your resolver, not on the package (not measured; deterministic with a forced interleaving). **Recipe**: when a tree change and a fact change go together, write the facts FIRST and move the scope after — then every answer is the one of a real state (the intermediate one, new facts on the old tree, is yours to choose knowingly). The catalog memo is outside the snapshot too — `authorize` and `listScopes` join the role→permission links inside the statement, but the composed decisions take them from the memo, contrasted with the shared version before each question —, so a catalog write and a fact write racing one composed read have the same shape. **It is caused by whoever writes — in a role-tree change, whoever moves scopes** | one answer of one check, **never persisted**: the next check sees a real state. What the package guarantees is about **prefixes** — a write CUT halfway never leaves a state that grants what neither end granted (the order properties of the surface guard: purges, reconcile, `moved` since F4) —, not about readers; "atomic" in this README means atomic for the writer. A PEP that caches a `true` makes it persistent: do not cache `authorize`. Removing it needs a model or a mechanism change (planned for evaluation in 2.6) |
1796
1801
  | **Races between writers of the `openfga` role driver (local roles)** — declared limit `openfga.races.settle-window` (experimental `openfga`, closes in 2.6) | — | persistent fail-open: 0 in ~38,800 interleavings of the in-memory double since the settle; against a real server not measured (M1); transient window: from a writer's `Write` to its own settle's correction | `openfga`, `hierarchy: 'facts'`, local roles only; `database` has none (it decides on every question) | the five interleavings found by the F4 audit (`updateScopedRole`/`projectCatalogRole` ∥ `scopes.moved`; a `grant` that resolved the OLD chain; a `grant` during the relay lag of a unit ENTERING the owner; two INLINE `moved` of a parent and its child) no longer leave a binding edge outside the owner in the double: every writer of a local-role `scope#binding` re-reads with `HIGHER_CONSISTENCY` and corrects to a fixed point (the settle, 2.5.0-alpha.4). What remains declared is the window before that correction — a check in it can grant outside the owner, across tenants and past a deny of the owner — and the closing measurement against a real server. **Recipe**: serialise catalog writes with scope moves, keep the outbox, and run `authz:reconcile --to=openfga` after reorganising the tree. **Detector**: a non-zero exit of `authz:reconcile --to=openfga --dry-run` on a cron (`drift.roleVisibility`). **Closes in 2.6**: M1 (zero persistent fail-opens against a real server, with a positive control that turns red) is the exit criterion of experimental. Case: «límite declarado openfga.races.settle-window: se voltea en 2.6». |
1797
1802
  | **Cost of a `moved`/`attached` of an existing scope with local roles (`openfga`)** — declared limit `openfga.roles.moved-cost` (experimental `openfga`, closes in 2.6) | — | 2 `Read`s per local role of the catalog per `moved`/`attached` (14 → 214 `Read`s with 100 local roles without a single binding, in the double; 2,008 with 1,000 in the F4 audit) | `openfga`, `hierarchy: 'facts'`; `database` has none | a tenant that defines many local roles slows the relay — one global queue — and widens the lag for everyone; zero `Read`s without local roles. **Recipe**: keep the number of local roles bounded and reorganise trees off-peak. **Detector**: the relay's lag: `authz:scopes:relay` reports `remaining: true` pass after pass. **Closes in 2.6**: read the bindings per scope of the moved subtree (`scope:X#binding`) instead of per local role of the catalog. Case: «límite declarado openfga.roles.moved-cost: se voltea en 2.6». |
1798
1803
  | **Two concurrent `scopes.moved` of the SAME scope applied inline (`acceptScopeDriftRisk: true`, no outbox)** — declared limit `openfga.tree.inline-same-node` (experimental `openfga`, closes in 2.6) | — | deterministic in the double (one preemption between the two movers); frequency against a real server not measured | `openfga`, `hierarchy: 'facts'` without `scopes.outbox`; `database` has none | the store keeps the parent of the writer that landed last, your tree the one that committed last: the store's tree diverges from yours and persists — a GLOBAL role skips the deny of the old tenant and an organisation role of the old tenant is inherited inside the new one. **Recipe**: use `scopes.outbox` (the relay applies your fresh parent), or lock the scope's row (`SELECT … FOR UPDATE`) before `scopes.moved`. **Detector**: a non-zero exit of `authz:reconcile --to=openfga --dry-run` (its tree phase counts the divergent edge). **Closes in 2.6**: the `drift.treeMismatch` report and the `FOR UPDATE` recipe measured on PostgreSQL and MySQL. Case: «límite declarado openfga.tree.inline-same-node: se voltea en 2.6». |
@@ -1824,7 +1829,12 @@ Every bound the package enforces or measured, in one place, each with the consta
1824
1829
  | Parked outbox entries the relay can census per pass | `PARKED_CENSUS_MAX` | 50,000 | the manager, every relay pass (read in full, whatever `--limit`) | 500 `E_AUTHZ_CONFIG` before anything is applied — the relay of **every** tenant stops, loudly, never a blind plug. Measured cost of the census under the lease: 1,000 parked ⇒ 16 ms, 10,000 ⇒ 69 ms, 49,000 ⇒ 199 ms per pass. The way out is below (*When the parked set grows*) |
1825
1830
  | Answer size of `authorizedScopes` / `expandExcludedSubtrees` | `maxScopes` | 1,000 | the manager | 422 `E_AUTHZ_TOO_MANY_SCOPES`, never a partial list (per call it can only be lowered) |
1826
1831
  | Nodes one `descendantsOf` call may return | `maxDescendants` | 10,000 | the manager (`maxNodes` handed to your resolver) | 422 `E_AUTHZ_TOO_MANY_SCOPES`; both bounds are capped at `MAX_SCOPE_BOUND` (10,000,000) |
1827
- | Local roles a `prune-orphans` pass may read | `maxLocalRoles` | 10,000 | `pruneOrphanRoles` | 500 `E_AUTHZ_TOO_MANY_LOCAL_ROLES`, never a partial list |
1832
+ | Local roles a `prune-orphans` pass may read | `maxLocalRoles` | 10,000 | `pruneOrphanRoles({ maxLocalRoles })`, `authz:catalog:prune-orphans --max-local-roles` (rotation 2 closure; until then not settable) | 500 `E_AUTHZ_TOO_MANY_LOCAL_ROLES`, never a partial list |
1833
+ | **Two deployments on different databases of the same MySQL server still share the relay lease during 2.5.x (R2-01)** — declared yellow of shared code (rotation 2, closes in 2.6) | `SCOPE_OUTBOX_TAKES_UNSCOPED_LOCK` | deterministic: two `sqlScopeOutbox()` with the default connection name and the stub's table, on two databases of one server | `sqlScopeOutbox` on MySQL (`GET_LOCK` is per server); PostgreSQL's advisory lock is per database and never had it; SQLite's lease is per process | the lease name carries the database since the rotation 2 closure (`DATABASE()` read by the session that holds it), but for a rolling deploy from 2.5.0-alpha.4 every 2.5.x pass also takes the old name without it, **first** — so a neighbour's pass (or a session holding that name) still leaves yours `busy`, which exits 0 with a line that says so. **Recipe**: give each deployment its own outbox table name or connection name (both are in the old name too), or run them on separate servers. **Detector**: `authz:scopes:relay --max-age=<ms>` exits ≠ 0 when the queue stops advancing; the `busy` line repeating on every pass. **Closes in 2.6**: the old name is retired (the suite turns red on the version bump until it is). Case: «límite declarado rotación 2 · R2-01 MySQL: el nombre de transición sigue excluyendo a dos bases del mismo servidor; se voltea en 2.6». |
1834
+ | **`E_AUTHZ_UNKNOWN_SCOPE` vs `E_AUTHZ_NOT_WITHIN` tells whether a scope exists in another tenant (R2-03)** — declared yellow of shared code (rotation 2, closes in 2.6) | — | not measured (deterministic: one write with a foreign uuid and one with a uuid that does not exist answer different codes) | the manager, the nine writes with `within` | the codes do not change (decision of the owner): a caller that knows or guesses a uuid learns that it exists *somewhere else*, not where — the message no longer names the chain, and since the rotation 2 closure 2 `scopes.moved`/`scopes.attached` decide containment before the cycle check and `scopes.maxDepth` (until then the cycle and depth answers told *which* foreign scope another foreign scope hangs from, and how deep: closed, not declared). The same holds for **roles** by uuid in `updateScopedRole`/`deleteScopedRole`: `E_AUTHZ_UNKNOWN_ROLE` (no such role), `E_AUTHZ_NOT_WITHIN` (a live role of another tenant) and `E_AUTHZ_UNKNOWN_SCOPE` (a dormant one) tell them apart; none of the three messages names the owner. **Recipe**: map both codes to the same answer at your HTTP boundary — a uniform 404 for "not yours or not there". **Detector**: count `E_AUTHZ_NOT_WITHIN` per actor in your logs — each one is a write aimed at a scope that exists outside the caller's tenant. **Closes in 2.6**: deciding containment before existence. Case: «límite declarado rotación 2 · R2-03 oráculo de existencia: UNKNOWN_SCOPE y NOT_WITHIN se distinguen por el code; se voltea en 2.6». |
1835
+ | **The third mass-purge trigger does not cover a single victim (RC2-C)** — declared yellow of shared code (rotation 2 closure 2, closes in 2.6) | `allowMassPurge` (`--allow-mass-purge`) | deterministic: a resolver with tenant A's context leaked and ONE other owner with local roles that still grant ⇒ no trigger fires | the manager, `pruneOrphanRoles({ force: true })` / `authz:catalog:prune-orphans --force` | the third trigger asks for orphans of **more than one** owner (owner's decision): with a leaked tenant context and one victim, nothing distinguishes it from a tenant that left, `--force` purges that owner's roles and its users lose live permissions. **Recipe**: read the `--dry-run` before every `--force` (it lists those roles apart, `stillGranting: true`), and run the pass with a resolver that has no tenant context (a platform job, not a request). **Detector**: the dry run's `stillGranting: true` rows; `role_purged` events of an owner your tree still has. **Closes in 2.6** (or earlier if a consumer runs platform jobs with tenant context: question open). Case: «límite declarado rotación 2 · RC2-C una sola víctima: el resolutor con el contexto de A colado y UN owner ajeno ⇒ ningún disparador y --force purga un rol que concede; se voltea si se cierra». |
1836
+ | **Without `within`, a delegation 422 can name another tenant's role (RC2R-1/RC2R-2, lineage L13)** — declared yellow of shared code (rotation 2 re-attack, closes in 2.6) | `requireWithin` | deterministic: `updateScopedRole`/`deleteScopedRole(adminOfA, <uuid of a role of B>)` with no `within` | the manager, delegation API | `E_AUTHZ_ROLE_LEVEL_ABOVE_OWNER` names B's owner and `E_AUTHZ_RANK_EXCEEDED` B's slug and rank; set `requireWithin` (multi-tenant needs it); 2.6 routes every tenant-facing message through one sanitiser |
1837
+ | **Any tenant's catalog write makes every process reload the WHOLE catalog (R2-06)** — declared yellow of shared code (rotation 2, closes in 2.6) | — | not measured | the catalog memo (`CatalogCache`: `authz_permissions`, `authz_roles`, `authz_role_permissions` read entire on a version change) | a tenant admin looping `updateScopedRole` (renaming is a real change ⇒ a version bump) makes the next question of every process, for every tenant, reload the full catalog — with a large local catalog, latency of `authorize` for everyone. **Recipe**: rate-limit the delegation API per owner in your app; `catalogRevalidate: { everyMs }` caps reloads to one per window per process, at the price of that window. **Detector**: the rate of `onCatalogWrite` events per owner (or of `authz_catalog_version` increments). **Closes in 2.6**: incremental reload by version, or a per-owner rate in the package. Case: «límite declarado rotación 2 · R2-06 recarga entera: una escritura de catálogo de un tenant recarga el catálogo de todos; se voltea en 2.6». |
1828
1838
  | Ancestors `hierarchicalScopeResolver` walks | `maxDepth` (resolver option) | 64 in the README example | your `resolveChain` | 500 `E_AUTHZ_SCOPE_TOO_DEEP`, never a truncated chain |
1829
1839
 
1830
1840
  **The rows marked *declared limit* are the open limits of the experimental `openfga` driver (2.5.x).** Each one has an
@@ -34,6 +34,11 @@ export declare function orphanLines(orphans: OrphanRow[], purged: Set<string>, s
34
34
  level: 'log' | 'warning';
35
35
  message: string;
36
36
  }>;
37
+ /**
38
+ * La letra de los disparadores del seguro de purga masiva (3b-0b · AA2 + rotación 2 · R2-02), en el orden en que
39
+ * el manager los reporta. Pura, para que el caso la fije.
40
+ */
41
+ export declare function massPurgeLetter(reasons: ReadonlyArray<string>): string;
37
42
  /**
38
43
  * Lista —y con `--force` borra— los roles LOCALES cuyo owner el árbol del
39
44
  * consumidor YA NO conoce (3b-0 · Z2).
@@ -56,6 +61,7 @@ export declare function orphanLines(orphans: OrphanRow[], purged: Set<string>, s
56
61
  * node ace authz:catalog:prune-orphans # --dry-run: solo lista
57
62
  * node ace authz:catalog:prune-orphans --force # purga de verdad
58
63
  * node ace authz:catalog:prune-orphans --force --allow-mass-purge
64
+ * node ace authz:catalog:prune-orphans --max-local-roles=50000 # catálogo local de más de 10 000 roles
59
65
  *
60
66
  * Es una operación de PLATAFORMA, no una escritura que dispare un tenant: no
61
67
  * lleva actor ni mide rangos. Hasta 3G esta limpieza la arrastraba
@@ -63,8 +69,10 @@ export declare function orphanLines(orphans: OrphanRow[], purged: Set<string>, s
63
69
  * resuelve—, y de ahí salieron tres de las cuatro regresiones de la Fase 3.
64
70
  *
65
71
  * `--allow-mass-purge` (3b-0b · AA2) desbloquea la cota que rechaza una
66
- * pasada con la firma de un resolutor ciego (todos los owners huérfanos, o
67
- * más del 50 % de los roles locales): es una decisión humana, no un default.
72
+ * pasada con la firma de un resolutor ciego (todos los owners huérfanos,
73
+ * más del 50 % de los roles locales, o —rotación 2 · R2-02— huérfanos de más
74
+ * de un owner con alguno que todavía concede): es una decisión humana, no un
75
+ * default. `--max-local-roles` (R2-04) sube la cota de la lectura.
68
76
  */
69
77
  export default class AuthzCatalogPruneOrphans extends BaseCommand {
70
78
  static commandName: string;
@@ -72,6 +80,13 @@ export default class AuthzCatalogPruneOrphans extends BaseCommand {
72
80
  static options: CommandOptions;
73
81
  force: boolean | undefined;
74
82
  allowMassPurge: boolean | undefined;
83
+ /**
84
+ * Rotación 2 · R2-04: la cota de la lectura del catálogo local (default 10 000). Hasta este cierre el mensaje
85
+ * de `E_AUTHZ_TOO_MANY_LOCAL_ROLES` pedía «subir la cota» y no había mando: un tenant que pasara de 10 000 roles
86
+ * locales dejaba este comando muerto para todos. Un valor que no es un entero ≥ 1 lo rechaza el manager (500
87
+ * `E_AUTHZ_CONFIG`) antes de leer nada.
88
+ */
89
+ maxLocalRoles: number | undefined;
75
90
  run(): Promise<void>;
76
91
  }
77
92
  export {};
@@ -54,6 +54,18 @@ export function orphanLines(orphans, purged, skipped) {
54
54
  }
55
55
  return lines;
56
56
  }
57
+ /**
58
+ * La letra de los disparadores del seguro de purga masiva (3b-0b · AA2 + rotación 2 · R2-02), en el orden en que
59
+ * el manager los reporta. Pura, para que el caso la fije.
60
+ */
61
+ export function massPurgeLetter(reasons) {
62
+ const words = {
63
+ 'all-owners': 'todos los owners salen huérfanos',
64
+ 'proportion': 'más del 50 % de los roles locales',
65
+ 'granting-across-owners': 'huérfanos de más de un owner y alguno TODAVÍA concede o no se sabe',
66
+ };
67
+ return reasons.map((reason) => words[reason] ?? reason).join('; ') || 'sin motivo reportado';
68
+ }
57
69
  /**
58
70
  * Lista —y con `--force` borra— los roles LOCALES cuyo owner el árbol del
59
71
  * consumidor YA NO conoce (3b-0 · Z2).
@@ -76,6 +88,7 @@ export function orphanLines(orphans, purged, skipped) {
76
88
  * node ace authz:catalog:prune-orphans # --dry-run: solo lista
77
89
  * node ace authz:catalog:prune-orphans --force # purga de verdad
78
90
  * node ace authz:catalog:prune-orphans --force --allow-mass-purge
91
+ * node ace authz:catalog:prune-orphans --max-local-roles=50000 # catálogo local de más de 10 000 roles
79
92
  *
80
93
  * Es una operación de PLATAFORMA, no una escritura que dispare un tenant: no
81
94
  * lleva actor ni mide rangos. Hasta 3G esta limpieza la arrastraba
@@ -83,8 +96,10 @@ export function orphanLines(orphans, purged, skipped) {
83
96
  * resuelve—, y de ahí salieron tres de las cuatro regresiones de la Fase 3.
84
97
  *
85
98
  * `--allow-mass-purge` (3b-0b · AA2) desbloquea la cota que rechaza una
86
- * pasada con la firma de un resolutor ciego (todos los owners huérfanos, o
87
- * más del 50 % de los roles locales): es una decisión humana, no un default.
99
+ * pasada con la firma de un resolutor ciego (todos los owners huérfanos,
100
+ * más del 50 % de los roles locales, o —rotación 2 · R2-02— huérfanos de más
101
+ * de un owner con alguno que todavía concede): es una decisión humana, no un
102
+ * default. `--max-local-roles` (R2-04) sube la cota de la lectura.
88
103
  */
89
104
  export default class AuthzCatalogPruneOrphans extends BaseCommand {
90
105
  static commandName = 'authz:catalog:prune-orphans';
@@ -94,9 +109,10 @@ export default class AuthzCatalogPruneOrphans extends BaseCommand {
94
109
  };
95
110
  async run() {
96
111
  const { default: authorization } = await import('../services/main.js');
97
- const { orphans, purged, skipped, massPurge, dryRun } = await authorization.pruneOrphanRoles({
112
+ const { orphans, purged, skipped, massPurge, massPurgeReasons, dryRun } = await authorization.pruneOrphanRoles({
98
113
  force: this.force === true,
99
114
  allowMassPurge: this.allowMassPurge === true,
115
+ ...(this.maxLocalRoles !== undefined ? { maxLocalRoles: this.maxLocalRoles } : {}),
100
116
  });
101
117
  // Los que todavía conceden —y los que NADIE puede demostrar que no
102
118
  // concedan— van aparte, con aviso: purgarlos es revocar permisos vivos,
@@ -110,7 +126,8 @@ export default class AuthzCatalogPruneOrphans extends BaseCommand {
110
126
  }
111
127
  if (dryRun) {
112
128
  if (massPurge) {
113
- this.logger.warning(`${orphans.length} de los roles locales quedarían huérfanos: eso es la firma de un 'scopes.resolveChain' ciego. ` +
129
+ this.logger.warning(`${orphans.length} de los roles locales quedarían huérfanos: eso es la firma de un 'scopes.resolveChain' ciego ` +
130
+ `(${massPurgeLetter(massPurgeReasons ?? [])}). ` +
114
131
  `Con --force esto se rechaza (E_AUTHZ_MASS_PURGE_REFUSED); comprueba el resolutor antes de añadir --allow-mass-purge.`);
115
132
  }
116
133
  this.logger.warning(`${orphans.length} rol(es) local(es) con el owner fuera del árbol. No se ha borrado nada: repite con --force.`);
@@ -128,7 +145,13 @@ __decorate([
128
145
  ], AuthzCatalogPruneOrphans.prototype, "force", void 0);
129
146
  __decorate([
130
147
  flags.boolean({
131
- description: 'Allow a pass that would purge every owner (or more than half the local roles) — check your resolveChain first',
148
+ description: 'Allow a pass that would purge every owner, more than half the local roles, or orphans of several owners that still grant (or cannot be counted) — check your resolveChain first',
132
149
  }),
133
150
  __metadata("design:type", Object)
134
151
  ], AuthzCatalogPruneOrphans.prototype, "allowMassPurge", void 0);
152
+ __decorate([
153
+ flags.number({
154
+ description: 'Cap on the local roles this pass reads (default 10000); above it, 500 E_AUTHZ_TOO_MANY_LOCAL_ROLES, never a partial list',
155
+ }),
156
+ __metadata("design:type", Object)
157
+ ], AuthzCatalogPruneOrphans.prototype, "maxLocalRoles", void 0);
@@ -207,7 +207,11 @@ export default class AuthzScopesRelay extends BaseCommand {
207
207
  'Revísala en la cola (el consumidor puede volver a notificar el cambio) o reconcilia.');
208
208
  }
209
209
  if (report.busy) {
210
- this.logger.info('Otra pasada del relay tiene el lease de la cola: esta no ha aplicado nada (el relay es escritor único).');
210
+ // Rotación 2 · R2-01b: la rama sigue saliendo 0 —respuesta del dueño en el cierre 2: la línea y `--max-age` son el
211
+ // detector; el caso fija exit 0 exacto (T-05)—, y la línea dice lo que puede ser.
212
+ this.logger.info('Otra pasada del relay tiene el lease de la cola: esta no ha aplicado nada (el relay es escritor único). ' +
213
+ 'En MySQL, durante 2.5.x, el lease puede tenerlo OTRO despliegue del mismo servidor (el nombre de transición no ' +
214
+ 'lleva la base; se retira en 2.6): si se repite en cada pasada, la cola no avanza — vigílala con --max-age.');
211
215
  if (report.dead.length > 0)
212
216
  this.exitCode = 1;
213
217
  return;
@@ -69,7 +69,8 @@ export function requeueLines(input) {
69
69
  level: 'error',
70
70
  message: 'Otra pasada del relay tiene el lease de la cola: con --apply no se ha reencolado NADA (todo o nada: reencolar y no ' +
71
71
  'aplicar dejaría la entrada con un solo intento, que la siguiente pasada gastaría). Reintenta (--wait-lease-ms espera ' +
72
- 'a que la suelten), o reencola sin --apply y deja que la próxima pasada la aplique.',
72
+ 'a que la suelten), o reencola sin --apply y deja que la próxima pasada la aplique. En MySQL, durante 2.5.x, el ' +
73
+ 'lease puede tenerlo OTRO despliegue del mismo servidor (el nombre de transición no lleva la base; se retira en 2.6).',
73
74
  });
74
75
  return { lines, clean: false };
75
76
  }
package/build/index.d.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  * import { AuthorizationManager, APP_SCOPE } from '@jantstack/adonis-authz'
4
4
  */
5
5
  export { AuthorizationManager, DEFAULT_MAX_SCOPES, DEFAULT_MAX_DESCENDANTS, MAX_SCOPE_BOUND, DEFAULT_VIEW_MAX_AGE_MS, PARKED_CENSUS_MAX, RELAY_ORIGIN_MAX_HOPS, RELAY_LEASE_POLL_MS, expandExcludedSubtrees } from './src/manager.js';
6
- export type { AuthorizationView, ForRequestOptions } from './src/manager.js';
6
+ export type { AuthorizationView, ForRequestOptions, MassPurgeReason } from './src/manager.js';
7
7
  export { defineConfig } from './src/define_config.js';
8
8
  export type { AuthorizationConfig } from './src/define_config.js';
9
9
  export { syncAuthzCatalog, diffAuthzCatalog, catalogInSync, formatCatalogDiff, formatScopedRoles, formatShadowedRoles, runCatalogDiff, syncCatalogs, } from './src/catalog/catalog.js';
@@ -89,7 +89,7 @@ export type { DatabaseDriverOptions } from './src/drivers/database_driver.js';
89
89
  * opcional `@openfga/sdk`. Un consumidor solo-database arranca sin él (D9).
90
90
  */
91
91
  export { APP_SCOPE, APP_SCOPE_TYPE, DEFAULT_RECONCILE_MAX_TUPLES } from './src/types.js';
92
- export type { AuthorizationDriver, AuthorizationDriverFactory, AuthorizedScopes, AuthzWriteEvent, AuthzCatalogWriteEvent, CatalogPermissionSpec, CatalogRole, CatalogRoleSpec, CatalogSpec, ScopedRoleChanges, ScopedRoleSpec, DenyOptions, DenyRef, ExcludedSubtree, GrantOptions, GrantOutcome, HolderTypeMap, ReconcileCounts, ReconcileFact, ReconcileFactPage, ReconcileFactsEnumerator, ReconcileOptions, ReconcileReport, ReconcileSkip, ReconcileSource, RoleQuery, ScopeChainResolver, ScopeDescendantsResolver, ScopeEdge, ScopeEdgePage, ScopeEdgesEnumerator, ScopedWriteOptions, ScopeRef, ScopeType, SubjectRef, WriteOptions, AuthorizationDriverCapabilities, RelationsDriver, RelationsDriverCapabilities, RelObject, RelSubject, RelUserset, RelationRef, RelationWriteEvent, RelationWriteOptions, RelationDenyOptions, RelationDecision, RelationTransactionOptions, RelationPurgeOptions, RelationPage, RelationObjectsPage, RelationSubjectsPage, RelationTuple, RelationTuplePage, ScopeOutbox, ScopeOutboxContext, ScopeOutboxLease, ScopeOutboxPendingFilter, ScopeOutboxFailure, ScopeRequeueConflict, ScopeRequeueReport, ScopeTreeChange, PendingScopeTreeChange, RelayedScopeChange, ScopeRelayReport, ScopeRelayFailure, ScopeRelayEntry, ScopeRelayEntryStatus, ScopeEnqueueReceipt, } from './src/types.js';
92
+ export type { AuthorizationDriver, AuthorizationDriverFactory, AuthorizedScopes, AuthzWriteEvent, AuthzCatalogWriteEvent, CatalogPermissionSpec, CatalogRole, CatalogRoleSpec, CatalogSpec, ScopedRoleChanges, ScopedRoleSpec, DenyOptions, DenyRef, ExcludedSubtree, GrantOptions, GrantOutcome, HolderTypeMap, ReconcileCounts, ReconcileFact, ReconcileFactPage, ReconcileFactsEnumerator, ReconcileOptions, ReconcileReport, ReconcileSkip, ReconcileSource, RoleQuery, ScopeChainResolver, ScopeDescendantsResolver, ScopeEdge, ScopeEdgePage, ScopeEdgesEnumerator, ScopedWriteOptions, ScopeRef, ScopeType, SnapshotAnswer, SnapshotReads, SubjectRef, WriteOptions, AuthorizationDriverCapabilities, RelationsDriver, RelationsDriverCapabilities, RelObject, RelSubject, RelUserset, RelationRef, RelationWriteEvent, RelationWriteOptions, RelationDenyOptions, RelationDecision, RelationTransactionOptions, RelationPurgeOptions, RelationPage, RelationObjectsPage, RelationSubjectsPage, RelationTuple, RelationTuplePage, ScopeOutbox, ScopeOutboxContext, ScopeOutboxLease, ScopeOutboxPendingFilter, ScopeOutboxFailure, ScopeRequeueConflict, ScopeRequeueReport, ScopeTreeChange, PendingScopeTreeChange, RelayedScopeChange, ScopeRelayReport, ScopeRelayFailure, ScopeRelayEntry, ScopeRelayEntryStatus, ScopeEnqueueReceipt, } from './src/types.js';
93
93
  export { isRelUserset } from './src/types.js';
94
94
  /**
95
95
  * **¿De quién es esta transacción?** (L-1 · 🟠 9, regla del puerto `{trx}`):
@@ -240,7 +240,8 @@ export async function readLocalRoles(options = {}) {
240
240
  .select('uuid', 'slug', 'scope_type', 'rank', 'owner_scope_key'));
241
241
  if (rows.length > maxLocalRoles) {
242
242
  throw new TooManyLocalRolesError(`El catálogo tiene más de ${maxLocalRoles} roles locales (cota 'maxLocalRoles'). No se devuelve una lista ` +
243
- `parcial: quien lee esto decide qué se BORRA. Sube la cota a sabiendas si tu catálogo local es así de grande.`);
243
+ `parcial: quien lee esto decide qué se BORRA. Sube la cota a sabiendas si tu catálogo local es así de grande: ` +
244
+ `pruneOrphanRoles({ maxLocalRoles }) o --max-local-roles en authz:catalog:prune-orphans.`);
244
245
  }
245
246
  if (rows.length === 0)
246
247
  return [];
@@ -1,5 +1,5 @@
1
1
  import db from '@adonisjs/lucid/services/db';
2
- import type { AuthorizationDriver, AuthorizationDriverCapabilities, CatalogRoleRef, DenyRef, GrantOptions, GrantOutcome, NormalizedRoleQuery, ReconcileOptions, ReconcileReport, ReconcileSource, RoleQuery, ScopeRef, ScopeType, SubjectRef, WriteOptions } from '../types.js';
2
+ import type { AuthorizationDriver, AuthorizationDriverCapabilities, CatalogRoleRef, DenyRef, GrantOptions, GrantOutcome, NormalizedRoleQuery, ReconcileOptions, ReconcileReport, ReconcileSource, RoleQuery, ScopeRef, ScopeType, SnapshotAnswer, SnapshotReads, SubjectRef, WriteOptions } from '../types.js';
3
3
  import type { ScopeChainResolver } from '../types.js';
4
4
  import { CatalogCache } from '../catalog/catalog_cache.js';
5
5
  import type { CatalogRevalidate, CatalogRole, CatalogView } from '../catalog/catalog_cache.js';
@@ -168,7 +168,7 @@ export declare class DatabaseAuthorizationDriver implements AuthorizationDriver
168
168
  /**
169
169
  * Lo que este driver declara (3b-2e · E2). El árbol es del consumidor
170
170
  * (`resolveChain`), así que no hay hechos de jerarquía ni deriva que
171
- * mitigar; `authorize` son varias consultas SQL; la membresía la resuelve
171
+ * mitigar; `authorize` es UNA consulta SQL de hechos (la foto, rotación 2 · cierre 3) más el catálogo y el árbol, no un `Check`; la membresía la resuelve
172
172
  * el propio SQL contra la cadena que le pasan; los `list*` no enumeran
173
173
  * herencia (invariante 7); y `purgeRole` está implementado en una
174
174
  * transacción.
@@ -248,6 +248,33 @@ export declare class DatabaseAuthorizationDriver implements AuthorizationDriver
248
248
  */
249
249
  private sql;
250
250
  private first;
251
+ /**
252
+ * **La FOTO de una decisión: el punto de paso ÚNICO** (rotación 2 · cierre 3; 4.ª aparición del linaje L12 «lector
253
+ * rasgado», la primera en `database`). Propiedad universal: *toda DECISIÓN de `database` que combina concesión y deny
254
+ * se lee en UNA foto*. Hasta aquí `authorize` leía los denies y las asignaciones en DOS sentencias, y una escritura
255
+ * que caía entre las dos daba una respuesta que NINGÚN estado real daba (un `deny` y después un `grant` ⇒ `true` una
256
+ * vez; repro `.claude/reproducciones/database-authorize-rasgado.spec.ts`).
257
+ *
258
+ * La foto es **UNA sentencia**: las partes van unidas con `UNION ALL` y cada fila dice de qué parte es
259
+ * (`foto_kind`). Una sentencia ve UN estado en los tres motores y en cualquier aislamiento —PostgreSQL toma la foto
260
+ * por SENTENCIA en READ COMMITTED, InnoDB una vista de lectura por sentencia, SQLite una transacción de lectura
261
+ * implícita—, así que no hace falta abrir una transacción REPEATABLE READ como `withSourceSnapshot` del reconcile:
262
+ * eso costaría BEGIN/COMMIT por pregunta en el camino caliente y, con un pool de 1 (`:memory:`), retendría la única
263
+ * conexión mientras el catálogo o un `resolveChain` SQL del consumidor esperan otra. Elegido por eso: una ida y
264
+ * vuelta (antes dos), sin conexión retenida y sin nivel por motor.
265
+ *
266
+ * Lo que va DENTRO de la foto: los HECHOS (asignaciones —con el vínculo rol→permiso por `JOIN` cuando la parte lo
267
+ * filtra— y denies). Lo que va FUERA, antes o después: el catálogo (memo contrastado con la versión compartida,
268
+ * invariante 14) y la cadena del consumidor (`resolveChain`). «Cadena frente a hechos» (un `scopes.moved` entre el
269
+ * `resolveChain` y la foto) depende del resolutor del consumidor y está DECLARADO en README «Limits» (fila «Torn
270
+ * read»), no se arregla aquí.
271
+ *
272
+ * Columnas comunes de cada parte: `foto_kind`, `foto_scope_type`, `foto_scope_uuid`, `foto_role` y
273
+ * `foto_permission` (`null` en la parte que no la tiene). Solo SQL estándar del query builder (M6). Una sola parte
274
+ * es una sola `SELECT`, sin `UNION`. La guardia `database.decision.snapshot` cuenta las sentencias que tocan
275
+ * `authz_assignments`/`authz_denies` por decisión: con dos, rojo.
276
+ */
277
+ private readFoto;
251
278
  /**
252
279
  * Por dónde escribe `operation` (L-3): la transacción ABIERTA del llamante
253
280
  * si llegó en `options.transaction` —validada contra la conexión de este
@@ -316,16 +343,41 @@ export declare class DatabaseAuthorizationDriver implements AuthorizationDriver
316
343
  scope: ScopeRef;
317
344
  role: CatalogRoleRef;
318
345
  }>>;
346
+ /** La parte de la foto de `rolesInChain`: asignaciones vigentes del holder en los scopes de la cadena (`null` sin cadena). */
347
+ private rolesInChainPart;
348
+ /** De las filas de la foto a la respuesta de `rolesInChain` (catálogo DESPUÉS de la foto: es memo, no hechos). */
349
+ private rolesInChainFrom;
319
350
  listRoleScopes(subject: SubjectRef, scopeType: ScopeType): Promise<ScopeRef[]>;
320
351
  /** ¿El rol (por uuid) existe en el scope cuya cadena tiene esas claves? (3B · B2, `declaredRoleAt`) */
321
352
  private visibleAt;
322
353
  listScopes(subject: SubjectRef, permission: string): Promise<ScopeRef[]>;
354
+ /** La parte «asignaciones vigentes del holder cuyo rol concede el permiso», en todo scope (la de `listScopes`). */
355
+ private grantsOfPart;
356
+ /** La parte «denies del holder» (de UN permiso si se da; si no, todos), en todo scope. */
357
+ private deniesPart;
358
+ /**
359
+ * De la foto a la respuesta de `listScopes` (árbol y catálogo DESPUÉS de la foto). `denies` son los del permiso: por
360
+ * scope, el conjunto de roles que conceden; el scope se lista si el árbol lo conoce, alguno de esos roles existe ahí
361
+ * (3B · B2) y ningún deny de la cadena lo bloquea — exactamente lo que `authorize` diría.
362
+ */
363
+ private listScopesFrom;
323
364
  /**
324
365
  * Denies directos del holder (2.1, B5): del scope exacto o todos. Solo los
325
366
  * de permisos que el catálogo declara (memo); los de scopes que el árbol
326
367
  * no conoce no se listan (D8), como en `listRoleScopes`.
327
368
  */
328
369
  listDenies(subject: SubjectRef, scope?: ScopeRef): Promise<DenyRef[]>;
370
+ /** De las filas de denies a `DenyRef[]` (catálogo y, sin `scope` pedido, árbol DESPUÉS de la foto). */
371
+ private deniesFrom;
372
+ /**
373
+ * **Varias lecturas del puerto en UNA foto** (rotación 2 · cierre 3, L12; método OPCIONAL del puerto,
374
+ * `AuthorizationDriver.readSnapshot`, capacidad `snapshotDecisions`). Es lo que el manager usa para las decisiones
375
+ * que COMPONE con dos métodos del puerto —`effectivePermissions` y la policy de delegación (`rolesInChain` +
376
+ * `listDenies`), `authorizedScopes` (`listScopes` + `listDenies`)—: cada respuesta pedida es EXACTAMENTE la del método
377
+ * homónimo, y todas salen de la MISMA foto de los hechos (`readFoto`: una sentencia). Lo no pedido no viene.
378
+ * Árbol y catálogo se leen después de la foto, como en cada método suelto.
379
+ */
380
+ readSnapshot(subject: SubjectRef, reads: SnapshotReads): Promise<SnapshotAnswer>;
329
381
  /**
330
382
  * Borra asignaciones y denies del scope exacto, en una transacción. Si el
331
383
  * árbol aún lo conoce se purga su identidad canónica (K1); si ya no (el