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

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
 
@@ -1106,13 +1107,13 @@ alias `fga: () => new OpenFgaRelationsDriver(…)` with the same `OPENFGA_STORE_
1106
1107
  written in a config — are **not** detected: `--to=fga --from=database` passes the guard and resurrects what was revoked.
1107
1108
  Nothing is built before the guard, so there is no backend to compare. Register an alias with the *same* reference
1108
1109
  (`const fga = () => …` and `{ openfga: fga, alias: fga }`) or do not register it; a backend identity declared by the
1109
- driver is on the 2.5 list. Until the closing lot of 2.4.0-alpha.10 the primitive
1110
+ driver is on the 2.6 list. Until the closing lot of 2.4.0-alpha.10 the primitive
1110
1111
  `reconcileRelations` was exported from the package root and this paragraph recommended it for K partitions: it
1111
1112
  never saw the active driver nor took the window, so a loop written after the cutover resurrected revoked shares
1112
1113
  with the fleet still writing. It is no longer public. **The guard reads the config of the process that runs it**:
1113
1114
  `relations.default ?? config.default` *there* — a job or pod started with another environment (say, without
1114
1115
  `AUTHZ_DRIVER`) believes another driver is active and the guard is off. Run it with the fleet's config; a durable
1115
- seal of the active driver is on the 2.5 list. **The pass that writes runs under the durable freeze** (2.4.0-alpha.2,
1116
+ seal of the active driver is on the 2.6 list. **The pass that writes runs under the durable freeze** (2.4.0-alpha.2,
1116
1117
  lot L-1), exactly like `authz:reconcile`: every write of the package — roles *and* relations — answers a
1117
1118
  retryable 503 `E_AUTHZ_FROZEN` fleet-wide while it runs; a live **operator** window (`authz:freeze`) is its
1118
1119
  own context (it runs inside, does not renew or lift it); a live freeze of another pass is 423; and the
@@ -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.
@@ -1423,7 +1425,8 @@ runAuthorizationDriverContract({
1423
1425
  countRoleAssignments: true, // the port's optional countRoleAssignments (2.3); false ⇒ pruneOrphanRoles reports `undefined`, never `false`
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
- experimental: false, // optional (2.5.0-alpha.4): true ⇒ the driver declares itself outside the release's guarantee (the suite still runs whole)
1428
+ experimental: false, // optional (2.5.0-alpha.4): true ⇒ the driver declares itself outside the release's guarantee (the suite still runs whole); omitted ⇒ your driver's declaration decides the face
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; omitted ⇒ your driver's declaration decides the face
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
@@ -1438,7 +1441,9 @@ runAuthorizationDriverContract({
1438
1441
  })
1439
1442
  ```
1440
1443
 
1441
- 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.
1444
+ Declaring a capability `true` that the suite has no case for makes registration throw — a promise without a judge does not pass. An **optional** capability (`?:`) you leave out is judged by your driver's own normalised declaration (one case runs that face, and a `true` without a case at your level is red); one you declare decides the face, and contradicting the driver is red. 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.
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. That holds for a driver that does not declare the capability — not for an object that wraps or subclasses `DatabaseAuthorizationDriver` and so copies or inherits its `true`: see point 13 of [Upgrading to 2.5.0](#upgrading-to-250-non-canonical-partition-spellings). 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`.
1442
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
 
@@ -1574,7 +1579,7 @@ This is the **only** migration and verification primitive of the package, and th
1574
1579
  **Whoever owns the facts is where they are read from (2.3, and it is the whole safety of this command).** `--to=openfga` used to read `authz_assignments` / `authz_denies` *always*. In a deployment that has already cut over to `facts` those tables are **not** the source of truth of the facts — the store is, and nothing keeps them in sync after the cutover — so a pass that rebuilt from them re-wrote grants you had revoked (no flag needed) and, with `--prune`, deleted the denies that only lived in the store. So the pass now asks first, and **says the answer in its first line** (`report.factsFrom`):
1575
1580
 
1576
1581
  - **`--to` is the *active* driver and its facts live in its own backend** (`capabilities.hierarchyFacts`, which is what `openfga` declares): then its facts are its own and it is read from *itself*. This is the **maintenance pass**: it rebuilds everything **derived** — root marker, catalog projection, tree — and applies the visibility sweep of invariant 18 with the tree and the catalog of *today*, and **it writes no fact; with `--prune` it deletes the facts of scopes that no longer resolve**, the expired ones and those of roles or permissions the catalog or the model no longer has (and the leftovers) — without `--prune` it deletes no assignment or deny (only the role-visibility edges invariant 18 forbids) and reports them as `extra-fact`. (Until 2.4.0-alpha.9 this line, and the command's first line, claimed the pass deleted nothing while `--prune` deleted them — measured by a consumer.) It is also what makes `--dry-run` usable as the CI verifier of a `facts` deployment: a correct store now comes out **clean** instead of reporting every live fact as `extra-fact`. (A driver in that position that cannot enumerate its own facts is 500 `E_AUTHZ_UNSUPPORTED` naming `enumerateFacts` — reading `authz_*` for it would be the bug.)
1577
- - **`--from=<driver>` is given**: you decide. If that driver's facts are `authz_*` (the package's `database`), `--to=openfga` is the **one-way migration** it always was, and it will happily overwrite the destination's facts with what those tables hold. That is what you want while migrating — and it is a loaded gun pointed at a store that is already live, which is why it now takes an explicit `--from` to fire it. **Except towards the active driver (2.4.0-alpha.10, breaking):** if `--to` is `config.default`, the only legitimate source is `--to` itself (the maintenance pass above); any other **resolved** source — named with `--from`, picked by elimination without `--from` (with `database` active, `--to=database` alone picks `openfga`), or the `authz_*` tables — makes the manager refuse with 500 `E_AUTHZ_STALE_SOURCE_REFUSED` before resolving the source, reading or freezing anything. That is the **stale direction**: once the engine serves from `--to`, its facts are written there and the source stopped seeing them, so the pass would re-write what was revoked since the cutover (with or without `--prune`; and the mass-deletion guard does not fire, because the source is stale, not empty). The guard looks at `--to` and at the source it resolves, never at the flag — and at the *resolved* driver, not the key: the same factory registered under two keys is the same driver, so an alias of `config.default` is refused too (**declared limit**: identity is the factory *reference*, so two different factories that build the same backend are not detected and such an alias passes; register an alias with the same reference, and a backend identity declared by the driver is on the 2.5 list) — so the four rows are: maintenance pass — runs; initial cutover (`--to=<new>` while the engine still serves from the old one) — runs; going back **before** switching `default` — runs; re-running either of the two **after** switching `default` — refused. `authz:relations:reconcile` applies the same rule with the active relations driver (below). `--allow-stale-source` (`allowStaleSource: true`) is the human decision; `--dry-run` never throws, it flags `report.staleSource = { to, from, allowed }`, prints a warning and exits non-zero. **The guard is only as reliable as the `config.default` of the process that runs the command**: it has no durable record of what the fleet serves from, so a migration job or pod started with a different environment (`default: env.get('AUTHZ_DRIVER', 'database')` without that variable) believes another driver is active and the guard is silently off. Run it with the fleet's config; a durable seal of the active driver is on the 2.5 list.
1582
+ - **`--from=<driver>` is given**: you decide. If that driver's facts are `authz_*` (the package's `database`), `--to=openfga` is the **one-way migration** it always was, and it will happily overwrite the destination's facts with what those tables hold. That is what you want while migrating — and it is a loaded gun pointed at a store that is already live, which is why it now takes an explicit `--from` to fire it. **Except towards the active driver (2.4.0-alpha.10, breaking):** if `--to` is `config.default`, the only legitimate source is `--to` itself (the maintenance pass above); any other **resolved** source — named with `--from`, picked by elimination without `--from` (with `database` active, `--to=database` alone picks `openfga`), or the `authz_*` tables — makes the manager refuse with 500 `E_AUTHZ_STALE_SOURCE_REFUSED` before resolving the source, reading or freezing anything. That is the **stale direction**: once the engine serves from `--to`, its facts are written there and the source stopped seeing them, so the pass would re-write what was revoked since the cutover (with or without `--prune`; and the mass-deletion guard does not fire, because the source is stale, not empty). The guard looks at `--to` and at the source it resolves, never at the flag — and at the *resolved* driver, not the key: the same factory registered under two keys is the same driver, so an alias of `config.default` is refused too (**declared limit**: identity is the factory *reference*, so two different factories that build the same backend are not detected and such an alias passes; register an alias with the same reference, and a backend identity declared by the driver is on the 2.6 list) — so the four rows are: maintenance pass — runs; initial cutover (`--to=<new>` while the engine still serves from the old one) — runs; going back **before** switching `default` — runs; re-running either of the two **after** switching `default` — refused. `authz:relations:reconcile` applies the same rule with the active relations driver (below). `--allow-stale-source` (`allowStaleSource: true`) is the human decision; `--dry-run` never throws, it flags `report.staleSource = { to, from, allowed }`, prints a warning and exits non-zero. **The guard is only as reliable as the `config.default` of the process that runs the command**: it has no durable record of what the fleet serves from, so a migration job or pod started with a different environment (`default: env.get('AUTHZ_DRIVER', 'database')` without that variable) believes another driver is active and the guard is silently off. Run it with the fleet's config; a durable seal of the active driver is on the 2.6 list.
1578
1583
  - **Otherwise** (the destination is not the active driver): the migration of always, facts read from `authz_*`.
1579
1584
 
1580
1585
  **It needs `scopes.enumerateEdges`** in your config: the whole tree, paginated with a cursor. `sqlScopeEdges({ table, uuidColumn, parentColumn, typeColumn })` implements it over a table with a parent column, exactly like `sqlDescendantsOf`. Without it the command refuses (500 `E_AUTHZ_CONFIG`) instead of assuming a flat tree — a flat tree would be an invented hierarchy, and an invented hierarchy grants.
@@ -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,14 @@ 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». |
1838
+ | **A `+infinity` (PostgreSQL) or a text (SQLite) in `expires_at` grants in the decision (C1)** — declared yellow of shared code (rotation 3 re-attack 2, closes in 2.6) | — | deterministic, for a row whose `expires_at` the package did not write (a seed, an import, a hand edit) | `database` roles and relations: the SQL decision `expires_at > now` | the decision does not go through the expiry codec. MySQL's zero date and PostgreSQL's `-infinity` do not grant; but SQLite compares `TEXT > INTEGER` as true and PostgreSQL's `'infinity' > now()` is true, so such a row **grants for ever** — even a past ISO date written as text — while the readers (a re-grant, `reconcile`, the relations export) answer 500 `E_AUTHZ_INTERNAL` or, for the SQLite text, read it as the past date it spells. The package never writes these values. **Recipe**: never write `expires_at` by hand; find them — SQLite `SELECT uuid FROM authz_assignments WHERE typeof(expires_at) NOT IN ('integer', 'real', 'null')`, PostgreSQL `… WHERE expires_at = 'infinity'` (the same on `authz_relations`) — and `revoke` + `grant` each holder with the right `expiresAt`. **Detector**: those queries on a cron; a 500 naming `expires_at ilegible`. **Closes in 2.6**: the decision filters by type (SQLite) and finiteness (PostgreSQL), or a `CHECK`. Case: «límite declarado rotación 3 · C1: una caducidad PASADA en texto (SQLite) o +infinity (PostgreSQL) concede en la decisión de SQL; se voltea en 2.6». |
1839
+ | **`authz:reconcile --to=database` no longer repairs a row with an unreadable expiry (E1)** — declared, operability, fail-closed (rotation 3 re-attack 2) | — | deterministic | `database` as the destination of `authz:reconcile` (and `authz:relations:reconcile`) | since RR-2 a present `expires_at` that is not an instant is 500 `E_AUTHZ_INTERNAL` where it is read, and the destination is read whole before the first write: a pass whose destination holds such a row stops, exit 1, with nothing written. Until then it compared «no expiry» with the source and rewrote the row with the source's expiry — repairing it by accident. **Recipe**: find the row (MySQL `SELECT uuid FROM authz_assignments WHERE expires_at IS NOT NULL AND CONVERT_TZ(expires_at, '+00:00', '+00:00') IS NULL`; SQLite/PostgreSQL as in the row above), `revoke` + `grant` its holder with the right `expiresAt`, then run the pass again. **Detector**: the 500 itself. Not planned to change: an unreadable expiry is a fact only the operator can correct, and silently overwriting it is what RR-2 removed. Case: «RR-2 · … el re-grant es 500 sin tocar la fila» (`tests/expiry_timezone.spec.ts`: the same read; `revoke` + `grant` repairs). |
1828
1840
  | Ancestors `hierarchicalScopeResolver` walks | `maxDepth` (resolver option) | 64 in the README example | your `resolveChain` | 500 `E_AUTHZ_SCOPE_TOO_DEEP`, never a truncated chain |
1829
1841
 
1830
1842
  **The rows marked *declared limit* are the open limits of the experimental `openfga` driver (2.5.x).** Each one has an
@@ -1847,7 +1859,7 @@ The published migration (`stubs/migration.stub`) carries three decisions that we
1847
1859
 
1848
1860
  Also on MySQL: `sqlDescendantsOf` quotes identifiers with backticks and sends `/*+ SET_VAR(cte_max_recursion_depth = …) */` with each walk — MySQL aborts a recursive CTE after 1000 iterations (`cte_max_recursion_depth`, error 3636), which turned a cycle under a bound above 1000 (the manager's default is 10 000) into a 503 instead of the contract's 422 "posible ciclo". The bound is the same one the query already imposes with `depth < maxNodes + 1`; nothing from your input reaches the hint, and `maxScopes`/`maxDescendants` are capped at 10 000 000 (`MAX_SCOPE_BOUND`; above it the hint leaves MySQL's range and the 422 degrades to a 503 — 500 `E_AUTHZ_CONFIG` instead).
1849
1861
 
1850
- **Expiry is an instant, and the package stores it as UTC itself.** On MySQL `expires_at` is `DATETIME(3)`, which has no time zone, and `mysql2` serialises and parses `Date` values with the **process's** `TZ` (`timezone: 'local'`, its default): a process in UTC wrote `12:00:00` for `12:00Z` and a process in Caracas read it as `16:00Z` — the assignment expired four hours late for it (and nine hours early for one in Tokyo). The `database` driver does not depend on your connection options: on MySQL it writes `expires_at` as an explicit UTC string (`YYYY-MM-DD HH:mm:ss.SSS`), compares with `now` formatted the same way and reads it back through `DATE_FORMAT` (a string, parsed as UTC), so `timezone`, `dateStrings` and `TZ` do not enter the decision; the model trait compares the same way. PostgreSQL stores `timestamptz(3)` (an absolute instant) and SQLite a number. The suite spawns real child processes in `UTC`, `Asia/Tokyo` and `America/Caracas` over the same database, with the default connection options, writing and reading in both directions (*"expires_at es un instante: procesos con TZ distinta sobre la misma base ven la misma caducidad"*). Keep the process on NTP; do not set MySQL's `timezone` option for the package's sake — it is not needed, and it must not be relied on.
1862
+ **Expiry is an instant, and the package stores it as UTC itself.** On MySQL `expires_at` is `DATETIME(3)`, which has no time zone, and `mysql2` serialises and parses `Date` values with the **process's** `TZ` (`timezone: 'local'`, its default): a process in UTC wrote `12:00:00` for `12:00Z` and a process in Caracas read it as `16:00Z` — the assignment expired four hours late for it (and nine hours early for one in Tokyo). The `database` driver does not depend on your connection options: on MySQL it writes `expires_at` as an explicit UTC string (`YYYY-MM-DD HH:mm:ss.SSS`), compares with `now` formatted the same way and reads it back through `DATE_FORMAT` (a string, parsed as UTC), so `timezone`, `dateStrings` and `TZ` do not enter the decision; the model trait compares the same way. PostgreSQL stores `timestamptz(3)` (an absolute instant) and SQLite a number. The suite spawns real child processes in `UTC`, `Asia/Tokyo` and `America/Caracas` over the same database, with the default connection options, writing and reading in both directions (*"expires_at es un instante: procesos con TZ distinta sobre la misma base ven la misma caducidad"*). Keep the process on NTP; do not set MySQL's `timezone` option for the package's sake — it is not needed, and it must not be relied on. 1.x did not do this: its MySQL expiries are the wall clock of the process that wrote them, and [`authz:upgrade-1x`](#expiry-written-by-1x-on-mysql) converts them with one value you supply. A present `expires_at` that is not a real instant — the zero date `0000-00-00` a non-strict `sql_mode` leaves, a PostgreSQL `±infinity`, text in SQLite — is **500 `E_AUTHZ_INTERNAL`** wherever the package reads it (a re-grant, `reconcile`, the relations export), never «no expiry»: nothing is written from it — `authz:reconcile --to=database` included, which until then rewrote such a row with the source's expiry and now stops before its first write —, and `revoke` + `grant` repairs the row. The decision itself is SQL's `expires_at > now`, which does not read through the codec: closed for MySQL's zero date and PostgreSQL's `-infinity`, but a `+infinity`, or text in SQLite (which sorts after every number), compares as later than any instant and **grants** — the package never writes them, do not either; both are declared in [Limits](#limits) with their recipe (C1, E1) (*"RR-2 · una caducidad presente e ilegible es un error…"*).
1851
1863
 
1852
1864
  **`withAuthzCatalogWrite` and a swallowed SQL error.** Do not `try/catch` a SQL failure inside the `fn(trx)` you pass and carry on: on PostgreSQL the transaction is aborted (`25P02`) and every following statement fails — the package classifies that as 503 `E_AUTHZ_BACKEND_UNAVAILABLE` with the `pg` error as `cause` (never the raw error with your SQL in it); on MySQL and SQLite the engine does **not** abort the transaction and what follows **is committed**. The divergence is the engines', pinned by the suite on the three (*"un error SQL tragado dentro de fn envenena la transacción en PostgreSQL ⇒ 503 …; en MySQL y SQLite la transacción sigue y se confirma"*).
1853
1865
 
@@ -1891,7 +1903,7 @@ tuples grant nothing and are no membership. The way across is **`authz:reconcile
1891
1903
  leftovers in one pass — there is no `openfga:import` (removed in 2.3). See [Migrating and
1892
1904
  verifying](#migrating-and-verifying-authzreconcile-23).
1893
1905
 
1894
- **The SQL recipe.** Upgrading a 1.x installation (which used `uuid` columns, `timestamp` for `expires_at`, the default collation, had no `authz_catalog_version`, and — before 2.2 — no `owner_scope_key` on roles nor `assignable_at` on permissions): run the statements below for your engine in a migration of your own. They are **executed by the suite** (`tests/upgrade_recipe.spec.ts`): the 1.1.0 migration is created on a scratch database with a role already in it, these exact statements are applied, the resulting schema is compared column by column with the published migration, and the 2.x engine is exercised on top (non-UUID ids, millisecond expiry, dates past 2038, byte-wise identity, the catalog version, and the pre-existing role left **global** and recognised by the next sync as the same role). Existing UUID values are valid strings; nothing needs rewriting.
1906
+ **The upgrade, per engine.** A 1.x installation used `uuid` columns, `timestamp` for `expires_at`, the default collation, had no `authz_catalog_version`, and — before 2.2 — no `owner_scope_key` on roles nor `assignable_at` on permissions. Existing UUID values are valid strings and need no rewriting. **PostgreSQL**: run the statements below in a migration of your own (its DDL is transactional, so a failed migration rolls back whole); 1.x's `timestamp` was already `timestamptz` and nothing is converted. **MySQL**: run **`node ace authz:upgrade-1x`** — there is no SQL block to copy any more (see below for why). **SQLite** has nothing to upgrade. Both paths are **executed by the suite** (`tests/upgrade_recipe.spec.ts`): the 1.1.0 migration is created on a scratch database with a role already in it, the PostgreSQL statements or the command are applied, the resulting schema is compared column by column with the published migration, and the 2.x engine is exercised on top (non-UUID ids, millisecond expiry, dates past 2038, byte-wise identity, the catalog version, 1.x expiries written by processes outside UTC keeping their instant, and the pre-existing role left **global** and recognised by the next sync as the same role).
1895
1907
 
1896
1908
  ```sql
1897
1909
  -- PostgreSQL: upgrading a 1.x schema to 2.x
@@ -1920,43 +1932,64 @@ CREATE INDEX authz_roles_owner_idx ON authz_roles (owner_scope_key);
1920
1932
  ALTER TABLE authz_permissions ADD COLUMN assignable_at varchar(500);
1921
1933
  ```
1922
1934
 
1923
- ```sql
1924
- -- MySQL: upgrading a 1.x schema to 2.x
1925
- ALTER TABLE authz_roles
1926
- MODIFY slug varchar(100) COLLATE utf8mb4_bin NOT NULL,
1927
- MODIFY scope_type varchar(20) COLLATE utf8mb4_bin NOT NULL;
1928
- ALTER TABLE authz_permissions
1929
- MODIFY slug varchar(100) COLLATE utf8mb4_bin NOT NULL;
1930
- ALTER TABLE authz_assignments
1931
- MODIFY holder_type varchar(50) COLLATE utf8mb4_bin NOT NULL,
1932
- MODIFY holder_uuid varchar(64) COLLATE utf8mb4_bin NOT NULL,
1933
- MODIFY scope_type varchar(20) COLLATE utf8mb4_bin NOT NULL,
1934
- MODIFY scope_uuid varchar(64) COLLATE utf8mb4_bin NOT NULL,
1935
- MODIFY expires_at datetime(3) NULL;
1936
- ALTER TABLE authz_denies
1937
- MODIFY holder_type varchar(50) COLLATE utf8mb4_bin NOT NULL,
1938
- MODIFY holder_uuid varchar(64) COLLATE utf8mb4_bin NOT NULL,
1939
- MODIFY scope_type varchar(20) COLLATE utf8mb4_bin NOT NULL,
1940
- MODIFY scope_uuid varchar(64) COLLATE utf8mb4_bin NOT NULL;
1941
- CREATE TABLE authz_catalog_version (
1942
- id int NOT NULL PRIMARY KEY,
1943
- version bigint NOT NULL DEFAULT 0,
1944
- updated_at timestamp NOT NULL,
1945
- freeze_reason varchar(255) NULL,
1946
- freeze_holder varchar(120) NULL,
1947
- freeze_until_ms bigint NULL,
1948
- freeze_fence bigint NOT NULL DEFAULT 0
1949
- );
1950
- INSERT INTO authz_catalog_version (id, version, updated_at) VALUES (1, 0, CURRENT_TIMESTAMP);
1951
- INSERT INTO authz_catalog_version (id, version, updated_at) VALUES (2, 0, CURRENT_TIMESTAMP);
1952
- ALTER TABLE authz_roles
1953
- ADD COLUMN owner_scope_key varchar(80) COLLATE utf8mb4_bin NOT NULL DEFAULT 'global',
1954
- DROP INDEX authz_roles_slug_scope_uq,
1955
- ADD UNIQUE INDEX authz_roles_slug_scope_owner_uq (slug, scope_type, owner_scope_key),
1956
- ADD INDEX authz_roles_owner_idx (owner_scope_key);
1957
- ALTER TABLE authz_permissions ADD COLUMN assignable_at varchar(500) NULL;
1935
+ #### MySQL: `node ace authz:upgrade-1x` (2.5.0)
1936
+
1937
+ ```bash
1938
+ node ace authz:upgrade-1x --zone=Europe/Madrid # dry run: the detected state and the plan, nothing written
1939
+ node ace authz:upgrade-1x --zone=Europe/Madrid --confirm-legacy-stopped --force # upgrade; re-run the SAME line after any failure
1940
+ node ace authz:upgrade-1x --fix-previous-upgrade --zone=Europe/Madrid --upgraded-at=2026-09-01T10:00:00Z --confirm-legacy-stopped --force
1941
+ node ace authz:upgrade-1x --declare-born-in-2 --force # a database born on 2.x: writes the marker, no data
1958
1942
  ```
1959
1943
 
1944
+ **Before `--force`: stop every 1.x process — the command requires you to say so.** Do not run a rolling deploy 1.x → 2.x on MySQL. A 1.x process still serving after the conversion keeps writing expiries as the **local wall clock** of its zone into a column 2.x reads as UTC: east of UTC (Madrid) each of its new expiries grants 1–2 h **after** it expired (**fail-open**); west of UTC (Caracas) a 1.x process also reads the converted UTC values as its local time and grants 4 h too long while it serves. Nothing in the schema stops 1.x from writing (it does not need `authz_catalog_version`), and **the command cannot detect it afterwards**: the marker already says done, and a re-run answers `hecho`. So every `--force` that converts — the whole upgrade (`1x-sin-subir`), a resumed run that has not converted yet (`a-medias`), and `--fix-previous-upgrade` — is **refused (exit 1, nothing written) without `--confirm-legacy-stopped`**, which is your confirmation that no 1.x process is left; the dry run prints this precondition in its plan. The order is: stop all of 1.x → run the command with `--force` → start 2.x.
1945
+
1946
+ **Why a command and not SQL.** On MySQL 1.x's expiries are not instants (next subsection), so the upgrade has to *convert* `expires_at` — and a conversion is the one statement that must never run twice, nor on rows 2.x already wrote. Three versions of a copy-paste block failed exactly there: the first did not convert (the published block of 2.4.0-alpha.1 … 2.5.0-rc.1), the second converted again when re-run after a failure (MySQL's DDL commits implicitly, so neither a transaction nor a migration undoes what ran before a failure), the third converted 2.x's rows when run over a database already upgraded with the first, and turned a 1.x zero date into `NULL` — no expiry, a **permanent** grant. The command reads the state **before touching anything** and decides from it.
1947
+
1948
+ **What it reads, and what it does with it.** The first line of its output is always `estado: <state>`; every refusal exits 1 and writes nothing.
1949
+
1950
+ | Detected state | How it is recognised | What the command does |
1951
+ |---|---|---|
1952
+ | `no-mysql` | the connection is PostgreSQL or SQLite | nothing: exit 0, zero statements, and it names the flags it ignored |
1953
+ | `1x-sin-subir` | `expires_at` is 1.x's `timestamp`, no marker | the whole upgrade; **`--zone` is required** (no default) |
1954
+ | `a-medias` | a marker armed by an earlier run of the command (or done with DDL left) | resumes where it stopped, **with the marker's zone** (another `--zone` is refused) |
1955
+ | `receta-vieja` | `expires_at` is already `datetime(3)` and there is no marker: upgraded with the old block — or born on 2.x; the command cannot tell which | **refuses** and offers both ways out: `--declare-born-in-2` if it was born on 2.x, `--fix-previous-upgrade` if it came from 1.x (below) |
1956
+ | `hecho` | marker done and the 2.x schema (an upgrade, a correction, or a database declared born on 2.x) — or no marker and not one row with an expiry | nothing: exit 0 (another `--zone` than the marker's is refused: it never converts twice) |
1957
+ | `motor-no-transaccional` | `authz_assignments`, or the marker table, is not InnoDB (`information_schema.tables.engine`) | **refuses before anything else**, names the tables and prints `ALTER TABLE … ENGINE=InnoDB` |
1958
+ | `incoherente` | e.g. marker done but `expires_at` is `timestamp` again (a 1.x dump of `authz_*` restored after upgrading), `authz_catalog_version` present before the conversion (2.x could have written), a marker or indexes this command never writes | **refuses**, explains, changes nothing |
1959
+ | `sin-tablas` | a 1.x table is missing | refuses: a fresh install uses the published migration |
1960
+
1961
+ And, in every state that converts: a zone MySQL cannot convert (an unknown name, or a name without the server's time-zone tables — `mysql_tzinfo_to_sql`; `'Z'` is written `'+00:00'`) is refused **before anything changes**; expiries MySQL cannot read (the zero date `0000-00-00`, or a zero month/day, which a non-strict `sql_mode` left when 1.x wrote an expiry past 2038 into a `TIMESTAMP`) are refused, counted, unless **`--zero-dates=expire`**, which sets them to a **past** instant (they never granted in 1.x either) — never `NULL`. Writing needs **`--force`**; without it (or with `--dry-run`, which is the default and is refused together with `--force`) it prints the plan and exits 0. A second `--force` run on the same database waits for nobody: it is refused (`estado: ocupado`) while another holds the lock. `--connection=<name>` picks a Lucid connection other than the primary one.
1962
+
1963
+ **How a cut is made safe.** The command runs everything over one session, which it sets to the server's zone (the one 1.x's connections had: `@@global.time_zone`) and to a strict `sql_mode`, whatever the pool left there. The marker row (`authz_upgrade_1x`, step `expires_at`) is **armed, with the zone, only while `expires_at` is still `timestamp`** — before the `MODIFY` that turns it into `datetime(3)`; the conversion and the «done» go in **one transaction** (DML, all or nothing — which only holds on InnoDB: on MyISAM a `ROLLBACK` undoes nothing, a failure between the two writes would leave the rows converted with the marker still armed, and the re-run would convert them again; so the command reads the engine of `authz_assignments` and of the marker from `information_schema.tables` and refuses anything but InnoDB, `motor-no-transaccional`, and creates its own tables with an explicit `ENGINE=InnoDB` whatever `default_storage_engine` says), and only from an armed marker; `authz_catalog_version` is created **after** the conversion, so no 2.x process can have written a row the conversion would move (without that table every 2.x write is a 503). Each step is detected on its own from `information_schema`, so a re-run after a failure at any point — the suite cuts the command after each of its ten steps and runs it again — resumes, converts once, and ends with the published schema. **Keep the marker table**: it is what tells a re-run, and a restored 1.x dump, apart. If 1.x's connections ran their own `SET time_zone` (not the server default), the command does not cover it: that is the one input it cannot read.
1964
+
1965
+ #### Expiry written by 1.x on MySQL
1966
+
1967
+ **What 1.x stored.** 1.x wrote `expires_at` through `mysql2` with its default `timezone: 'local'`: the `TIMESTAMP` column received the **wall clock of the Node process** (`2030-07-01 02:00:00` for `2030-07-01T00:00Z` in a Madrid process), and MySQL turned that wall clock into its internal instant with the **session's** zone. 1.x read and compared it the same way, so it was consistent with itself — but the column only held the true instant when process and session were in the same zone (measured on MySQL 8.4: a Madrid process on a server in UTC stores that expiry as `02:00Z`). `MODIFY … datetime(3)`, run in the session zone 1.x had, leaves exactly that wall clock, and 2.x reads `DATETIME` as UTC ([Expiry is an instant](#operational-notes-for-the-sql-engines)). Without the conversion every 1.x expiry moves by the process's offset: a Madrid fleet (+02:00 in summer) keeps granting **two hours after the expiry** (fail-open), a Caracas fleet (−04:00) loses access four hours early. The command renders the wall clock in the session zone 1.x had and converts it from the 1.x process zone (`--zone`) to UTC.
1968
+
1969
+ **What to put in `--zone`.** If the 1.x connection config set mysql2's `timezone`, that value (`'Z'` is `+00:00`, `'+HH:MM'` is itself; `'local'` means the process zone). Otherwise the zone of the 1.x Node process: its `TZ`, or the host's zone (`node -p "Intl.DateTimeFormat().resolvedOptions().timeZone"` on that host). A process in UTC: `--zone=+00:00` (the conversion changes nothing and marks itself done). A zone **with daylight saving goes by name** (`Europe/Madrid`), not by offset — a fixed offset is an hour wrong for the expiries on the other side of the change, and the command warns when you give one —; names need the server's time-zone tables (the official Docker image loads them). `SELECT * FROM authz_upgrade_1x` tells you afterwards which zone was used and when.
1970
+
1971
+ **If the zone was wrong.** Every expiry moves by the difference between the real offset and the one you gave. A zone **west** of the real one (a smaller offset — e.g. `+00:00` for a Madrid fleet) makes expiries later: assignments keep granting after they expired (**fail-open**). A zone east of it makes them earlier (fail-closed). The command never converts twice, so re-running it with another zone is refused: apply the difference yourself to the rows 1.x wrote (the next subsection shows how they are told apart) and update the marker's `zone`. Before 2.x serves traffic, take an assignment whose expiry you know and compare `SELECT expires_at` (now UTC) with it — the dry run prints how many rows it will convert.
1972
+
1973
+ **What no command recovers** (each with the direction of the error, measured on MySQL 8.4):
1974
+ - *1.x processes in different zones, or a server zone changed after the rows were written*: 1.x itself stored inconsistent values (the defect 2.x fixed by writing UTC itself); the conversion uses one zone. Rows written from a zone east of the one you give end up later (fail-open), from one west of it earlier.
1975
+ - *The repeated hour of a daylight-saving fall-back, in the 1.x process zone*: a wall clock inside it (`02:30` on the last Sunday of October in Madrid) is ambiguous in 1.x's own data, and `CONVERT_TZ` takes the **daylight-saving** reading — the earlier instant (`2030-10-27 02:30` Madrid → `00:30Z`, `2030-11-03 01:30` New York → `05:30Z`). An expiry that 1.x meant for the second reading ends up one hour **early** (fail-closed); never late.
1976
+ - *The spring-forward gap, in the SESSION zone*: when the server (or the session) zone has daylight saving and differs from the process zone — e.g. `SYSTEM` is `Europe/Madrid` and 1.x ran in UTC — a wall clock 1.x wrote inside the gap (`2030-03-31 02:30`) is not a valid `TIMESTAMP` there. Under a strict `sql_mode` 1.x's own `grant` failed, so no such row exists; under a non-strict one MySQL stored the end of the gap (`03:00`), and the conversion converts that: up to one hour **late** (fail-open) — a defect 1.x already had (it compared the same shifted value). The repeated hour in the session zone round-trips (the `MODIFY` renders back the wall clock 1.x wrote).
1977
+ - *Stale time-zone tables*: `CONVERT_TZ` uses the server's tables, Node used its own ICU data. If a zone changed its rules after the tables were loaded (a country that dropped daylight saving), the expiries in the affected months move by that hour, in the direction of the change, silently — reload the tables (`mysql_tzinfo_to_sql`) before running the command if they are older than your Node's tzdata.
1978
+
1979
+ #### If you already upgraded from 1.x with the previous MySQL block
1980
+
1981
+ The MySQL block published from **2.4.0-alpha.1 to 2.5.0-rc.1** changed the type and left the wall clock. **You are affected** if all of this holds: MySQL; the data came from 1.x; you ran that block; and your 1.x processes did not serialise dates in UTC. An installation that started on 2.x, or whose 1.x processes were in UTC, is not affected; neither is PostgreSQL. **What it did**: every expiry 1.x wrote moved by the 1.x zone's offset — a fleet **east** of UTC (Madrid: +1 h in winter, +2 h in summer) keeps granting that long after each expiry (**fail-open**), one west of UTC (Caracas: −4 h) loses access that much early. Rows written by 2.x since then are already UTC, and **must not** be converted — which is why `authz:upgrade-1x` on such a database stops at `estado: receta-vieja` and converts nothing by default.
1982
+
1983
+ **The command looks for traces of 1.x**: `expires_at` still `timestamp`, a marker already there, a 1.x schema step still pending (`authz_catalog_version` missing or without its two rows, `authz_roles` without `owner_scope_key`, `authz_permissions` without `assignable_at`, a column not yet `utf8mb4_bin`) or unreadable expiries (zero dates, which 2.x never writes). No published 2.x migration leaves any of them. With a trace, `--declare-born-in-2` is **refused** (exit 1, nothing written: «hay rastro de 1.x, born no aplica») and `receta-vieja` offers only the correction. **Without any trace, a database born on 2.x and one fully upgraded with the old block have the same shape** (`datetime(3)`, no marker, nothing pending), and the command cannot tell them apart. If yours never held 1.x data, **declare it**: `--declare-born-in-2 --force` writes the marker (state done, mode `born`) and touches no row; from then on the command answers `hecho`. It is **your declaration, not something the command proves** — and it is accepted only there. Flags of the conversion (`--zero-dates`, `--confirm-legacy-stopped`, `--zone`, `--fix-previous-upgrade`, `--upgraded-at`) are refused with it, not ignored. If the declaration is false, the 1.x expiries stay shifted by their zone's offset for good (east of UTC: fail-open) and the command says `hecho`. Do not use `--fix-previous-upgrade` to make a born-on-2.x database "green": it would move its expiries earlier (fail-closed, every row).
1984
+
1985
+ **The correction: `--fix-previous-upgrade --zone=<1.x zone> --upgraded-at=<ISO> --confirm-legacy-stopped`** (all required, no defaults). **`--upgraded-at` is the instant the last 1.x process stopped writing** — not when you ran the block: a 1.x process that kept serving after the block wrote more 1.x rows. Wrong one way or the other: too **early**, and the 1.x rows written after it are left unconverted (east of UTC they keep granting after they expire: **fail-open**); too **late**, and the 2.x rows inserted in between are moved earlier (they expire early: fail-closed). Both 1.x and 2.x give each assignment a UUIDv7, whose first 48 bits are the millisecond it was inserted, from the clock of the process — not its zone; so the rows inserted before you ran the block have a `uuid` that sorts before the UUIDv7 prefix of `--upgraded-at`. What cannot be told apart **with certainty** is a 1.x row whose expiry 2.x rewrote afterwards (a re-grant with an expiry, a `reconcile --to=database` update): it keeps its `uuid` and now holds a UTC value. So the correction is the **safe** one: it only touches rows inserted before that instant, and only moves an expiry **earlier** — for a zone east of UTC it converts every such row (a 1.x row rewritten by 2.x ends up expiring early by the offset: fail-closed; re-grant it), and for a zone west of UTC it moves nothing (those rows expire early already — fail-closed; re-grant the holders you need). So give an instant **later** rather than earlier than the real one. `--upgraded-at` needs an explicit zone (`Z` or `±hh:mm`), and the command refuses an instant in the future or one that is not later than the oldest row — it would correct nothing and mark itself done. The dry run prints how many rows are 1.x's and how many it moves. The correction and its marker are one transaction; running it again is `hecho`.
1986
+
1987
+ #### What the command does (an appendix, not a recipe)
1988
+
1989
+ In order, each step only if `information_schema` says it is still pending: the zero dates to a past instant (only with `--zero-dates=expire`); the marker table (`ENGINE=InnoDB`), and the marker armed with the zone (only while `expires_at` is `timestamp`); `authz_roles`, `authz_permissions` and `authz_denies` identity and slug columns to `utf8mb4_bin` (`varchar(64)` for ids); `authz_assignments` likewise plus `expires_at` to `datetime(3)`, in the server's session zone; the conversion `CONVERT_TZ(expires_at, <zone>, '+00:00')` of every present expiry and the marker set done, in one transaction; `authz_catalog_version` (`ENGINE=InnoDB`) with rows 1 and 2 at version 0; `authz_roles.owner_scope_key` (default `global`) with the three-column unique; `authz_permissions.assignable_at`. It then reads the state again and only reports success if it is `hecho`. The statements are in `commands/authz_upgrade_1x.ts`; they are the published migration's shapes (the suite compares the result column by column), not something to run by hand.
1990
+
1991
+ **PostgreSQL and SQLite have no such step.** On PostgreSQL 1.x's `timestamp` was already `timestamptz` (an instant) and the recipe only changes its precision; SQLite has nothing to upgrade, and 1.x stored a number there (knex binds a `Date` as milliseconds). The suite executes both paths (`tests/upgrade_recipe.spec.ts`): child processes in `Europe/Madrid` (summer and winter expiries) and `America/Caracas` write 1.x expiries with 1.x's client and connection defaults; on PostgreSQL the recipe runs, on MySQL the command runs with `--zone` as an operator would — over sessions born in another zone (`+05:00`) and a non-strict `sql_mode`, which it has to undo —, and 2.x grants 1 ms before each expiry and not at it (*"R3-01 · una caducidad que 1.x escribió desde un proceso en … deja de conceder EXACTAMENTE en su instante tras la subida"*). The command's states, its ten cut points, the refusals and the correction are pinned by `tests/command_flags_upgrade_1x.spec.ts`.
1992
+
1960
1993
  ### Upgrading to 2.5.0: non-canonical partition spellings
1961
1994
 
1962
1995
  There is **no `2.4.0` stable**: the 2.4 line ends at `2.4.0-alpha.10` and the next stable is **2.5.0** (the
@@ -2127,6 +2160,68 @@ DELETE FROM authz_relations WHERE subject_relation <> '' AND NOT (subject_type =
2127
2160
  report `permission-not-in-model` for a permission only present in a newer model now projects it under the pinned one
2128
2161
  — and one that used to die with a 503 mid-pass (permission in the latest model but not in the pinned one) now counts
2129
2162
  it. The runbook rule stands: publish and roll out the model **before** the window, never inside it.
2163
+ 12. **An optional capability your harness omits is judged by the driver's own declaration** (rc.1 rehearsal): in
2164
+ `runAuthorizationDriverContract` (`experimental`, `snapshotDecisions`) and `runRelationsDriverContract`
2165
+ (`relationDenies`, `atomicRenewal`, `experimental`), a key left out of `capabilities` takes the driver's normalised
2166
+ declaration and the judge runs that face in one case; a key you declare explicitly still decides, and contradicting the
2167
+ driver is still red. *Adapt*: nothing — a harness written for alpha.4 that registers the package's `database` without
2168
+ `snapshotDecisions` is green again (it is judged on the `true` face).
2169
+ 13. **What you see going from `2.5.0-alpha.4` to `2.5.0-rc.x` with live data** (rotation 2 closures; the
2170
+ [CHANGELOG](./CHANGELOG.md) of rc.1 has the why of each). There is no migration, no stub and no config key to change
2171
+ between alpha.4 and rc.x; these are behaviours, each with what to do:
2172
+ - **`authorizedScopes` can answer 503 where it answered `none`** (`database`). It reads the holder's grants and denies
2173
+ from one snapshot, so it resolves the chain of every deny of the holder even when no scope grants the permission: a
2174
+ holder with no grant and a deny on a scope your `resolveChain` cannot resolve gets **503 `E_AUTHZ_RESOLVER_FAILED`**
2175
+ instead of `{ kind: 'none' }`, and a holder with N denies costs N `resolveChain` calls. *Adapt*: handle it as any
2176
+ resolver failure (fail-closed); remove denies on scopes that no longer exist.
2177
+ - **`authz:catalog:prune-orphans --force` has a third refusal trigger, `granting-across-owners`**: orphans of more
2178
+ than one owner of which any still grants — or cannot be counted — are **500 `E_AUTHZ_MASS_PURGE_REFUSED`** before
2179
+ anything is deleted, so a cron running `--force` that passed on alpha.4 can now exit 1. With a driver without
2180
+ `countRoleAssignments`, every multi-owner `--force` needs `--allow-mass-purge`. *Adapt*: read the `--dry-run` (it
2181
+ still exits 0 and now prints the reasons, `massPurgeReasons`) and add `--allow-mass-purge` only after checking the
2182
+ job's resolver; see the RC2-C row in [Limits](#limits) for the single-victim case the trigger does not cover.
2183
+ - **`scopes.attached`/`scopes.moved` report containment first.** The whole containment block (`within` required,
2184
+ `'non-root'`, destination and origin) now runs before the cycle and depth checks: with `requireWithin` and no
2185
+ `within` you get **`E_AUTHZ_WITHIN_REQUIRED`** where you got `E_AUTHZ_SCOPE_CYCLE` or `E_AUTHZ_SCOPE_DEPTH_LIMIT`, a
2186
+ `within` that fails is `E_AUTHZ_NOT_WITHIN` whatever the edge, and in `scopes.moved` with a `within` and an unknown
2187
+ child the origin's `E_AUTHZ_UNKNOWN_SCOPE` comes before the depth. Without `within`, or with one that passes, the
2188
+ order is unchanged; the relay does not change. *Adapt*: only tests or clients that matched the code of the first
2189
+ error.
2190
+ - **`defineScopedRole`: a homonym committed by another owner during your write now enters.** The re-check inside the
2191
+ transaction judges by authority, like the check before it: a global or ancestor-or-self homonym is still 422, any
2192
+ other owner's (a sibling, a descendant) is not a collision and the role is written — before, a sibling's was a 422
2193
+ «retry» that named its owner. *Adapt*: nothing, unless a test of yours pinned that 422 (the package's own 3F · U3
2194
+ case now pins that it enters).
2195
+ - **MySQL: the relay lease takes two names during 2.5.x.** `GET_LOCK` on alpha.4's name (without the database) first,
2196
+ then on the new one (with `DATABASE()`), and both are released, so a rolling deploy from alpha.4 stays
2197
+ single-writer. While the old name is taken, two deployments on different databases of one server with the same
2198
+ connection and table names still exclude each other (`busy`, exit 0). *Adapt*: nothing for a rolling deploy; on a
2199
+ shared server, give each deployment its own outbox table or connection name (R2-01 row in [Limits](#limits)).
2200
+ - **`readSnapshot` and `snapshotDecisions`.** The `database` driver declares `snapshotDecisions: true`, and the
2201
+ manager reads what it composes — `effectivePermissions`, `authorizedScopes`, the delegation policy of
2202
+ `defineScopedRole`/`updateScopedRole` — through `readSnapshot`, **not** through `rolesInChain`/`listScopes`/
2203
+ `listDenies`. A third-party driver that implements the port and does not declare the capability keeps working as
2204
+ before. **An object that wraps or subclasses `DatabaseAuthorizationDriver`** (not a documented extension point)
2205
+ copies or inherits `snapshotDecisions: true` and `readSnapshot`: a decorator that copies `capabilities` without
2206
+ forwarding `readSnapshot` gets **500 `E_AUTHZ_CONFIG`** at the first composed decision (`authorize` and the
2207
+ `list*` keep answering — fail-closed), and a subclass that overrides `listDenies` (or `rolesInChain`/`listScopes`)
2208
+ to add facts of its own is **no longer consulted** there: `effectivePermissions` can list a permission its own
2209
+ `authorize` refuses, and the delegation policy can let that actor delegate it. *Adapt*: in a decorator, forward
2210
+ `readSnapshot`; in a subclass, override `readSnapshot` so it answers with your extra facts too, or declare
2211
+ `snapshotDecisions: false` on your object — in a subclass's constructor,
2212
+ `Object.defineProperty(this, 'capabilities', { value: Object.freeze({ ...this.capabilities, snapshotDecisions: false }) })`
2213
+ — and the manager composes through your methods again, with the two-read gap of the [torn read](#limits) row.
2214
+ **A decorator must also re-wrap the views it hands out**: the manager reads a `forRequest()` view through
2215
+ `withChainResolver(resolver)` and, with `config.clock`, replaces the driver with `withClock(clock)` once. Forwarded
2216
+ as is (`withChainResolver: (r) => inner.withChainResolver(r)`), they return the **inner** driver's view —
2217
+ undecorated, with its `snapshotDecisions: true` —, so `forRequest()` (and, with `config.clock`, every call) skips
2218
+ your object: measured with a decorator that denies a permission in `authorize`/`listDenies`, `forRequest()`
2219
+ granted it, and with `config.clock` the denied actor delegated it through `defineScopedRole`. Return your
2220
+ decorator around the view instead (`withChainResolver: (r) => decorate(inner.withChainResolver(r))`, the same for
2221
+ `withClock`). A subclass needs nothing here — both views inherit from it by prototype —
2222
+ **unless its overrides use `#private` fields**: a view is `Object.create(this)` and does not carry them, so
2223
+ `forRequest()` (and, with `config.clock`, every call) throws `TypeError` (fail-closed, but it does not work). Use
2224
+ ordinary properties, or override `withChainResolver` and `withClock` to return a new instance of your subclass.
2130
2225
 
2131
2226
  **Default (read-only): detect, list, count.** Nothing is written. Statement 3 says, per branch, what `--rekey`
2132
2227
  would do: a row *with* a canonical twin is dropped by it, a row *without* one is re-keyed.