@jantstack/adonis-authz 1.1.0 → 2.4.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (236) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +837 -51
  3. package/build/commands/authz_catalog_diff.d.ts +28 -0
  4. package/build/commands/authz_catalog_diff.d.ts.map +1 -0
  5. package/build/commands/authz_catalog_diff.js +67 -0
  6. package/build/commands/authz_catalog_diff.js.map +1 -0
  7. package/build/commands/authz_catalog_prune_orphans.d.ts +78 -0
  8. package/build/commands/authz_catalog_prune_orphans.d.ts.map +1 -0
  9. package/build/commands/authz_catalog_prune_orphans.js +136 -0
  10. package/build/commands/authz_catalog_prune_orphans.js.map +1 -0
  11. package/build/commands/authz_catalog_sync.d.ts +37 -0
  12. package/build/commands/authz_catalog_sync.d.ts.map +1 -0
  13. package/build/commands/authz_catalog_sync.js +81 -0
  14. package/build/commands/authz_catalog_sync.js.map +1 -0
  15. package/build/commands/authz_freeze.d.ts +44 -0
  16. package/build/commands/authz_freeze.d.ts.map +1 -0
  17. package/build/commands/authz_freeze.js +95 -0
  18. package/build/commands/authz_freeze.js.map +1 -0
  19. package/build/commands/authz_reconcile.d.ts +102 -0
  20. package/build/commands/authz_reconcile.d.ts.map +1 -0
  21. package/build/commands/authz_reconcile.js +294 -0
  22. package/build/commands/authz_reconcile.js.map +1 -0
  23. package/build/commands/authz_relations_reconcile.d.ts +73 -0
  24. package/build/commands/authz_relations_reconcile.d.ts.map +1 -0
  25. package/build/commands/authz_relations_reconcile.js +225 -0
  26. package/build/commands/authz_relations_reconcile.js.map +1 -0
  27. package/build/commands/authz_scopes_relay.d.ts +47 -0
  28. package/build/commands/authz_scopes_relay.d.ts.map +1 -0
  29. package/build/commands/authz_scopes_relay.js +141 -0
  30. package/build/commands/authz_scopes_relay.js.map +1 -0
  31. package/build/commands/authz_unfreeze.d.ts +37 -0
  32. package/build/commands/authz_unfreeze.d.ts.map +1 -0
  33. package/build/commands/authz_unfreeze.js +92 -0
  34. package/build/commands/authz_unfreeze.js.map +1 -0
  35. package/build/commands/main.d.ts +8 -1
  36. package/build/commands/main.d.ts.map +1 -1
  37. package/build/commands/main.js +8 -1
  38. package/build/commands/main.js.map +1 -1
  39. package/build/commands/openfga_provision.d.ts +46 -4
  40. package/build/commands/openfga_provision.d.ts.map +1 -1
  41. package/build/commands/openfga_provision.js +91 -7
  42. package/build/commands/openfga_provision.js.map +1 -1
  43. package/build/configure.d.ts +11 -0
  44. package/build/configure.d.ts.map +1 -1
  45. package/build/configure.js +37 -1
  46. package/build/configure.js.map +1 -1
  47. package/build/index.d.ts +75 -7
  48. package/build/index.d.ts.map +1 -1
  49. package/build/index.js +67 -5
  50. package/build/index.js.map +1 -1
  51. package/build/providers/authz_provider.d.ts +26 -2
  52. package/build/providers/authz_provider.d.ts.map +1 -1
  53. package/build/providers/authz_provider.js +48 -2
  54. package/build/providers/authz_provider.js.map +1 -1
  55. package/build/services/relations.d.ts +14 -0
  56. package/build/services/relations.d.ts.map +1 -0
  57. package/build/services/relations.js +17 -0
  58. package/build/services/relations.js.map +1 -0
  59. package/build/src/catalog/catalog.d.ts +289 -0
  60. package/build/src/catalog/catalog.d.ts.map +1 -0
  61. package/build/src/catalog/catalog.js +859 -0
  62. package/build/src/catalog/catalog.js.map +1 -0
  63. package/build/src/catalog/catalog_cache.d.ts +324 -0
  64. package/build/src/catalog/catalog_cache.d.ts.map +1 -0
  65. package/build/src/catalog/catalog_cache.js +666 -0
  66. package/build/src/catalog/catalog_cache.js.map +1 -0
  67. package/build/src/clock.d.ts +24 -0
  68. package/build/src/clock.d.ts.map +1 -0
  69. package/build/src/clock.js +7 -0
  70. package/build/src/clock.js.map +1 -0
  71. package/build/src/define_config.d.ts +201 -7
  72. package/build/src/define_config.d.ts.map +1 -1
  73. package/build/src/define_config.js.map +1 -1
  74. package/build/src/drivers/database_driver.d.ts +324 -15
  75. package/build/src/drivers/database_driver.d.ts.map +1 -1
  76. package/build/src/drivers/database_driver.js +1107 -128
  77. package/build/src/drivers/database_driver.js.map +1 -1
  78. package/build/src/drivers/database_relations_driver.d.ts +75 -0
  79. package/build/src/drivers/database_relations_driver.d.ts.map +1 -0
  80. package/build/src/drivers/database_relations_driver.js +450 -0
  81. package/build/src/drivers/database_relations_driver.js.map +1 -0
  82. package/build/src/drivers/openfga_driver.d.ts +963 -55
  83. package/build/src/drivers/openfga_driver.d.ts.map +1 -1
  84. package/build/src/drivers/openfga_driver.js +2693 -372
  85. package/build/src/drivers/openfga_driver.js.map +1 -1
  86. package/build/src/drivers/openfga_facts.d.ts +369 -0
  87. package/build/src/drivers/openfga_facts.d.ts.map +1 -0
  88. package/build/src/drivers/openfga_facts.js +813 -0
  89. package/build/src/drivers/openfga_facts.js.map +1 -0
  90. package/build/src/drivers/openfga_relations_driver.d.ts +120 -0
  91. package/build/src/drivers/openfga_relations_driver.d.ts.map +1 -0
  92. package/build/src/drivers/openfga_relations_driver.js +466 -0
  93. package/build/src/drivers/openfga_relations_driver.js.map +1 -0
  94. package/build/src/errors.d.ts +586 -0
  95. package/build/src/errors.d.ts.map +1 -1
  96. package/build/src/errors.js +583 -0
  97. package/build/src/errors.js.map +1 -1
  98. package/build/src/expiry.d.ts +27 -0
  99. package/build/src/expiry.d.ts.map +1 -0
  100. package/build/src/expiry.js +50 -0
  101. package/build/src/expiry.js.map +1 -0
  102. package/build/src/freeze.d.ts +120 -0
  103. package/build/src/freeze.d.ts.map +1 -0
  104. package/build/src/freeze.js +172 -0
  105. package/build/src/freeze.js.map +1 -0
  106. package/build/src/hierarchical_resolver.d.ts +56 -0
  107. package/build/src/hierarchical_resolver.d.ts.map +1 -0
  108. package/build/src/hierarchical_resolver.js +87 -0
  109. package/build/src/hierarchical_resolver.js.map +1 -0
  110. package/build/src/{middleware → http}/app_access_middleware.d.ts +8 -6
  111. package/build/src/http/app_access_middleware.d.ts.map +1 -0
  112. package/build/src/{middleware → http}/app_access_middleware.js +10 -26
  113. package/build/src/http/app_access_middleware.js.map +1 -0
  114. package/build/src/http/resource_access_middleware.d.ts +105 -0
  115. package/build/src/http/resource_access_middleware.d.ts.map +1 -0
  116. package/build/src/http/resource_access_middleware.js +81 -0
  117. package/build/src/http/resource_access_middleware.js.map +1 -0
  118. package/build/src/identity.d.ts +228 -0
  119. package/build/src/identity.d.ts.map +1 -0
  120. package/build/src/identity.js +457 -0
  121. package/build/src/identity.js.map +1 -0
  122. package/build/src/manager.d.ts +536 -7
  123. package/build/src/manager.d.ts.map +1 -1
  124. package/build/src/manager.js +2676 -23
  125. package/build/src/manager.js.map +1 -1
  126. package/build/src/memoize_ancestors.d.ts +23 -0
  127. package/build/src/memoize_ancestors.d.ts.map +1 -0
  128. package/build/src/memoize_ancestors.js +42 -0
  129. package/build/src/memoize_ancestors.js.map +1 -0
  130. package/build/src/models/authz_assignment.d.ts +11 -11
  131. package/build/src/models/authz_assignment.d.ts.map +1 -1
  132. package/build/src/models/authz_deny.d.ts +11 -11
  133. package/build/src/models/authz_deny.d.ts.map +1 -1
  134. package/build/src/models/authz_permission.d.ts +17 -11
  135. package/build/src/models/authz_permission.d.ts.map +1 -1
  136. package/build/src/models/authz_permission.js +4 -0
  137. package/build/src/models/authz_permission.js.map +1 -1
  138. package/build/src/models/authz_role.d.ts +19 -12
  139. package/build/src/models/authz_role.d.ts.map +1 -1
  140. package/build/src/models/authz_role.js +6 -1
  141. package/build/src/models/authz_role.js.map +1 -1
  142. package/build/src/models/authz_role_permission.d.ts +11 -11
  143. package/build/src/models/authz_role_permission.d.ts.map +1 -1
  144. package/build/src/openfga.d.ts +29 -0
  145. package/build/src/openfga.d.ts.map +1 -0
  146. package/build/src/openfga.js +26 -0
  147. package/build/src/openfga.js.map +1 -0
  148. package/build/src/reconcile.d.ts +37 -0
  149. package/build/src/reconcile.d.ts.map +1 -0
  150. package/build/src/reconcile.js +69 -0
  151. package/build/src/reconcile.js.map +1 -0
  152. package/build/src/relation_partition_trigger.d.ts +8 -0
  153. package/build/src/relation_partition_trigger.d.ts.map +1 -0
  154. package/build/src/relation_partition_trigger.js +85 -0
  155. package/build/src/relation_partition_trigger.js.map +1 -0
  156. package/build/src/relations/define_relations_config.d.ts +58 -0
  157. package/build/src/relations/define_relations_config.d.ts.map +1 -0
  158. package/build/src/relations/define_relations_config.js +144 -0
  159. package/build/src/relations/define_relations_config.js.map +1 -0
  160. package/build/src/relations/manager.d.ts +38 -0
  161. package/build/src/relations/manager.d.ts.map +1 -0
  162. package/build/src/relations/manager.js +156 -0
  163. package/build/src/relations/manager.js.map +1 -0
  164. package/build/src/relations/reconcile.d.ts +62 -0
  165. package/build/src/relations/reconcile.d.ts.map +1 -0
  166. package/build/src/relations/reconcile.js +138 -0
  167. package/build/src/relations/reconcile.js.map +1 -0
  168. package/build/src/relations_config_store.d.ts +22 -0
  169. package/build/src/relations_config_store.d.ts.map +1 -0
  170. package/build/src/relations_config_store.js +74 -0
  171. package/build/src/relations_config_store.js.map +1 -0
  172. package/build/src/scope_outbox.d.ts +69 -0
  173. package/build/src/scope_outbox.d.ts.map +1 -0
  174. package/build/src/scope_outbox.js +291 -0
  175. package/build/src/scope_outbox.js.map +1 -0
  176. package/build/src/shared/backend_guard.d.ts +106 -0
  177. package/build/src/shared/backend_guard.d.ts.map +1 -0
  178. package/build/src/shared/backend_guard.js +246 -0
  179. package/build/src/shared/backend_guard.js.map +1 -0
  180. package/build/src/shared/sql_expiry.d.ts +53 -0
  181. package/build/src/shared/sql_expiry.d.ts.map +1 -0
  182. package/build/src/shared/sql_expiry.js +66 -0
  183. package/build/src/shared/sql_expiry.js.map +1 -0
  184. package/build/src/sql_descendants.d.ts +97 -0
  185. package/build/src/sql_descendants.d.ts.map +1 -0
  186. package/build/src/sql_descendants.js +203 -0
  187. package/build/src/sql_descendants.js.map +1 -0
  188. package/build/src/testing/contract.d.ts +212 -7
  189. package/build/src/testing/contract.d.ts.map +1 -1
  190. package/build/src/testing/contract.js +3449 -24
  191. package/build/src/testing/contract.js.map +1 -1
  192. package/build/src/testing/main.d.ts +10 -2
  193. package/build/src/testing/main.d.ts.map +1 -1
  194. package/build/src/testing/main.js +5 -1
  195. package/build/src/testing/main.js.map +1 -1
  196. package/build/src/testing/migration_contract.d.ts +284 -0
  197. package/build/src/testing/migration_contract.d.ts.map +1 -0
  198. package/build/src/testing/migration_contract.js +586 -0
  199. package/build/src/testing/migration_contract.js.map +1 -0
  200. package/build/src/testing/relations_contract.d.ts +51 -0
  201. package/build/src/testing/relations_contract.d.ts.map +1 -0
  202. package/build/src/testing/relations_contract.js +654 -0
  203. package/build/src/testing/relations_contract.js.map +1 -0
  204. package/build/src/testing/relations_reconcile_contract.d.ts +24 -0
  205. package/build/src/testing/relations_reconcile_contract.d.ts.map +1 -0
  206. package/build/src/testing/relations_reconcile_contract.js +172 -0
  207. package/build/src/testing/relations_reconcile_contract.js.map +1 -0
  208. package/build/src/testing/scope_tree.d.ts +65 -0
  209. package/build/src/testing/scope_tree.d.ts.map +1 -0
  210. package/build/src/testing/scope_tree.js +145 -0
  211. package/build/src/testing/scope_tree.js.map +1 -0
  212. package/build/src/traits/authz_scopes.d.ts +30 -6
  213. package/build/src/traits/authz_scopes.d.ts.map +1 -1
  214. package/build/src/traits/authz_scopes.js +30 -18
  215. package/build/src/traits/authz_scopes.js.map +1 -1
  216. package/build/src/traits/has_uuid.d.ts +12 -12
  217. package/build/src/traits/has_uuid.d.ts.map +1 -1
  218. package/build/src/types.d.ts +1313 -28
  219. package/build/src/types.d.ts.map +1 -1
  220. package/build/src/types.js +20 -4
  221. package/build/src/types.js.map +1 -1
  222. package/build/stubs/config/app_acl.stub +4 -2
  223. package/build/stubs/config/authorization.stub +168 -14
  224. package/build/stubs/migration.stub +183 -13
  225. package/build/stubs/scopes_outbox_migration.stub +57 -0
  226. package/package.json +14 -7
  227. package/build/commands/openfga_import.d.ts +0 -28
  228. package/build/commands/openfga_import.d.ts.map +0 -1
  229. package/build/commands/openfga_import.js +0 -74
  230. package/build/commands/openfga_import.js.map +0 -1
  231. package/build/src/catalog.d.ts +0 -3
  232. package/build/src/catalog.d.ts.map +0 -1
  233. package/build/src/catalog.js +0 -103
  234. package/build/src/catalog.js.map +0 -1
  235. package/build/src/middleware/app_access_middleware.d.ts.map +0 -1
  236. package/build/src/middleware/app_access_middleware.js.map +0 -1
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Driver-based **authorization engine for AdonisJS 7 + Lucid**: hierarchical scopes with downward-only inheritance, explicit denies that always win, expiring assignments and polymorphic holders — behind a single contract, so the backend is a config choice, not an architectural commitment.
4
4
 
5
- Ships two drivers that pass **the same executable contract suite**:
5
+ Ships two drivers that pass **the same executable contract suite**, case for case:
6
6
 
7
7
  - **`database`** (default) — self-contained, engine-agnostic SQL over its own `authz_*` tables. Zero extra infrastructure.
8
8
  - **`openfga`** — facts live in an [OpenFGA](https://openfga.dev) server (Zanzibar model); the catalog and your scope hierarchy stay local, so switching drivers is a facts migration, not a rewrite.
@@ -18,54 +18,247 @@ await authorization.grant({ type: 'users', uuid }, 'support', APP_SCOPE, {
18
18
  await authorization.authorize({ type: 'users', uuid }, 'audit:read', APP_SCOPE) // → boolean
19
19
  ```
20
20
 
21
+ > **2.x is a single breaking release over 1.x.** No compatibility flags — there were no external consumers to keep. What changed, and why, is in the [CHANGELOG](./CHANGELOG.md) (start with the summary at the top, ordered by risk); upgrading a 1.x install is [its own section](#upgrading-from-1x-to-2x).
22
+
21
23
  ## Install
22
24
 
23
25
  ```bash
24
26
  npm i @jantstack/adonis-authz
25
27
  node ace configure @jantstack/adonis-authz
26
28
  node ace migration:run
29
+ node ace authz:catalog:sync
27
30
  ```
28
31
 
29
- `configure` registers the provider, the commands and the `appAccess` middleware, defines the env variables, and **publishes into your project** what belongs to you: the migration for the `authz_*` tables and two config files (`config/authorization.ts` for drivers, `config/app_acl.ts` for your role catalog).
32
+ `configure` registers the provider, the commands and the `appAccess` middleware, defines the env variables, and **publishes into your project** what belongs to you: the migration for the `authz_*` tables and two config files (`config/authorization.ts` for drivers and the scope tree, `config/app_acl.ts` for your role catalog). The suite compiles both published configs against the package on every run, so they cannot drift from it.
30
33
 
31
- For the OpenFGA driver, also install its SDK (optional peer): `npm i @openfga/sdk`.
34
+ For the OpenFGA driver, also install its SDK (optional peer): `npm i @openfga/sdk`. Everything that touches the SDK lives behind the **`@jantstack/adonis-authz/openfga` subpath** (`OpenFgaAuthorizationDriver`, `provisionOpenFgaStore`, `importAuthzFactsToOpenFga`, `openFgaAuthorizationModel`, `assertHolderTypes`); the main entry never imports it, so a database-only install boots without the SDK (the suite loads `index.ts` in a child process with `@openfga/sdk` blocked to prove it: *"index.ts (la entrada principal) carga con el SDK bloqueado"*). The published config imports the driver from that subpath *inside* the factory.
32
35
 
33
36
  ## Semantics (what every driver guarantees)
34
37
 
35
- 1. **Hierarchical scopes, inheritance only downward.** A grant on a scope authorizes on that scope and all its descendants never on siblings or ancestors. The engine only reserves the root (`app`); every other level is yours, declared by the `resolveAncestors` resolver you inject.
36
- 2. **Explicit deny wins.** A deny anywhere in the scope chain blocks the permission even if a role grants it. Removing the deny restores it.
37
- 3. **Expiry is observable.** An assignment past its `expiresAt` grants nothing — enforced in SQL by the `database` driver and by an FGA *condition* in `openfga`, so no scheduler is needed.
38
- 4. **Polymorphic holders.** Users, admins, API integrations — any model with a morph name. Two holders with the same uuid and different type never cross.
39
- 5. **Deny by default.** Unknown permission, role without it, or no valid assignment → `false`. `authorize()` doesn't throw on any of those: an *unanswerable* question is answered "no". A backend that is *unreachable* is a different matter — it raises `AuthorizationBackendError` (503), never a silent `false`.
40
- 6. **Idempotent writes.** Re-granting doesn't duplicate (it refreshes the expiry); re-revoking is a safe no-op.
38
+ These are the eight invariants of the contract. Every one of them is a case in the suite that both drivers run (`src/testing/contract.ts`); the case titles are quoted so you can find them.
41
39
 
42
- These aren't prose promises: they're `tests/…` cases in the contract suite below.
40
+ 1. **Hierarchical scopes, inheritance only downward.** A grant on a scope authorizes on that scope and all its descendants — never on siblings or ancestors. The engine only reserves the root (`app`); every other level is yours, declared by the `resolveChain` resolver you inject — which answers the **canonical chain** `[the scope as stored in your table, ...ancestors]`, so the identity of a scope is what your tree says, never the spelling the caller used (*"un alias del uuid del scope … jamás evade un deny"*) — and the suite proves it on a real three-level tree, in memory and on a real SQL table on PostgreSQL and MySQL (*"herencia de dos niveles"*, *"grant en una org vale en sus units, no en app ni en la org hermana"*, *"mover una unit fuera de la org… le quita el permiso, sin otra escritura"*).
41
+ 2. **Explicit deny wins.** A deny anywhere in the scope chain blocks the permission even if a role grants it; removing the deny restores it; the order of writes does not matter (*"deny explícito gana sobre el rol"*, *"deny antes del grant también bloquea"*). A deny governs `authorize` only — **it does not affect `hasRole`**, which is a membership fact, not an access decision (*"un deny NO afecta a hasRole"*). That is why no middleware in this package accepts a role.
42
+ 3. **Expiry is observable, without a scheduler.** An assignment past its `expiresAt` grants nothing — enforced in SQL by `database`, by an FGA *condition* in `openfga`, and filtered client-side in enumerations (*"asignación expirada no concede; expiración futura sí"*).
43
+ 4. **Polymorphic holders.** `{ type: morphName, uuid }`. Two holders with the same uuid and a different type never cross (*"holder polimórfico"*). The `openfga` driver refuses a `holderTypes` map that would merge two morph names into one FGA type (*"holderTypes tiene que ser inyectivo"*).
44
+ 5. **Deny by default, and three distinguishable outcomes.** No permission, unknown permission, no live assignment, unknown scope → `false`, never a throw. A malformed question (unknown role or permission in `grant`/`deny`/`revoke`/`removeDeny`, invalid identity, bad slug, a `RoleQuery` object where a slug is expected, an `expiresAt` that is not a valid `Date`/`null`, unknown scope on a write) → **422** with an `E_AUTHZ_*` code. A dependency that does not answer — the facts backend, the SQL catalog **in both drivers**, or your own resolver (throwing *or* answering a malformed ancestor) — → **503**, never a silent `false`; in `openfga` a per-check `error` inside a 200 `batchCheck` is a 503 too, in the deny phase and in the role phase (*"un resolutor de ancestros que lanza ⇒ 503"*, *"authorize con el catálogo inaccesible lanza AuthorizationBackendError"*, *"authorize: error en un check de rol ⇒ 503"*).
45
+ 6. **Idempotent writes.** Re-grant does not duplicate; `expiresAt` has three states (below); re-revoke / re-deny / re-removeDeny are safe no-ops, and a repeated deny needs one `removeDeny` (*"grant duplicado es idempotente"*, *"deny repetido no se duplica"*).
46
+ 7. **`list*` return direct facts, complete, from the catalog's point of view.** `listSubjects`, `listRoles`, `listRoleScopes`, `listScopes` return *direct*, live assignments of the exact scope — never inherited descendants (that set would be open-ended; ask `authorize` about a concrete scope) — and they return **all of them**: 1,200 direct assignments come back whole in both drivers, and a `listScopes` subtracts a deny even when the holder carries 150 denies of other permissions (more than one `Read` page, more than an OpenFGA server's `ListObjects` cap) (*"listas exhaustivas: 1.200 asignaciones directas"*, *"listScopes resta el deny aunque el sujeto tenga más denies de OTROS permisos que el tope del backend"*). CI runs the judge against a second OpenFGA whose cap is 3. Membership is what the **catalog** says it is: a role removed from `authz_roles` is no longer a membership in either driver, even if `openfga` still holds its tuple (*"rol borrado del catálogo: la tupla sigue en el store pero authorize deniega"*), and a scope the tree no longer knows lists nothing (*"listRoles y listRoleScopes tampoco responden por un scope que el árbol no conoce"*).
47
+ 8. **`rank` is metadata.** The engine stores it and never evaluates it (*"rank es metadata"*). "Nobody grants a role at or above their own rank" is your assignment policy.
43
48
 
44
49
  ## Your domain stays yours
45
50
 
46
- Nothing about your model is hardcoded. Three seams:
51
+ Nothing about your model is hardcoded. Five seams, all in `config/authorization.ts`:
47
52
 
48
53
  ```ts
49
- // config/authorization.ts (published by configure)
50
54
  export default defineConfig({
51
55
  default: env.get('AUTHZ_DRIVER', 'database'),
52
56
 
53
- // 1. Your guards → FGA types (only the openfga driver uses this)
57
+ // 1. Your guards → FGA types (only the openfga driver uses this; must be injective)
54
58
  holderTypes: { users: 'user', admins: 'admin', integrations: 'integration' },
55
59
 
60
+ // 2. Your scope tree — THE seam. Same function for the manager and every driver.
61
+ scopes: { resolveChain: resolveScopeAncestors },
62
+
56
63
  drivers: {
57
- // 2. Your scope tree: organization → unit, project → site, whatever
58
- database: () => new DatabaseAuthorizationDriver({ resolveAncestors }),
64
+ database: () => new DatabaseAuthorizationDriver({ resolveChain: resolveScopeAncestors }),
65
+ openfga: () => new OpenFgaAuthorizationDriver({ apiUrl, storeId, holderTypes, resolveChain: resolveScopeAncestors }),
59
66
  },
60
67
 
61
- // 3. Your side-effects on every write (audit, events, notifications)
68
+ // 3. Your catalogs (one per module), for authz:catalog:sync / diff
69
+ catalogs: [async () => appAclCatalog()],
70
+
71
+ // 4. Your side-effects on every write (audit, events, notifications)
62
72
  hooks: { onWrite: (event) => audit(event) },
63
73
  })
64
74
  ```
65
75
 
66
- `onWrite` runs *after* the write succeeded, so a hook that throws is logged and swallowed: propagating it would report a failure for an operation that did happen, and invite the caller to retry it.
76
+ ### The scope tree
77
+
78
+ `resolveChain(scope)` returns the **canonical chain** of a scope — `[the scope itself as it is stored in your table, ...its ancestors from nearest to root, APP_SCOPE]` — or **`null` when the scope does not exist**. Element 0 is *the row you read*, not the argument you received: it is the identity the engine uses for every fact of that scope (assignments, denies, bindings, `purgeScope`). This matters because your tree can canonicalise ids where `authz_*` does not: PostgreSQL's `uuid` type finds the row for `BBBB…` and for the 32-hex form without hyphens, MySQL's default `*_ci` collation merges case — but `authz_*` compares byte-wise. Before 2.1 the chain was built with the caller's spelling, the ancestor's grant applied and the deny (written canonical) did not match: **a deny bypassed by an alias of the uuid**, on both engines and both drivers. Now the chain carries the canonical scope, the engine writes and reads under it, and the suite runs the judge over a real `demo_scopes` table on PostgreSQL and MySQL to prove it (*"un alias del uuid del scope (mayúsculas, guiones quitados) jamás evade un deny"*). Your answer is validated: element 0 must be the asked scope (same type; same uuid up to case and hyphens), everything must be a well-formed `ScopeRef`, an empty array is not a chain — otherwise 503 `E_AUTHZ_RESOLVER_FAILED`. Upper-case uuids are rejected at the gate (see [Identity](#identity-is-validated-once-and-everywhere)), so the only alias your tree can still merge is the hyphen-less one, and the canonical chain closes it.
79
+
80
+ `null` is a first-class answer: reads deny (`authorize`/`hasRole` → `false`), writes refuse (`grant`/`deny` → 422 `E_AUTHZ_UNKNOWN_SCOPE`), `revoke`/`removeDeny`/`purgeScope` act on the scope as given (a fact of a scope you deleted without telling the engine stays reachable), and `listScopes`, `listRoles`, `listRoleScopes`, `listDenies` and `listSubjects` omit it (*"scope que el árbol no conoce"*, *"un scope retirado del árbol deja de responder"*). Never answer `[scope, APP_SCOPE]` for what you do not know: it would make any invented scope a descendant of the root.
81
+
82
+ There is no default resolver. A driver built without one only knows `app`; asking about any other scope type is 422 `E_AUTHZ_NO_SCOPE_RESOLVER` on the first call (*"sin resolutor de ancestros, cualquier scope que no sea app es 422"*).
83
+
84
+ The tree is a **contract fact**: when it changes, tell the engine — in every driver:
85
+
86
+ ```ts
87
+ await authorization.scopes.attached(unit, org) // new node under a parent
88
+ await authorization.scopes.moved(unit, otherOrg) // BEFORE you re-parent the row (containment reads the current chain)
89
+ await authorization.scopes.detached(unit) // BEFORE you delete the row
90
+ ```
91
+
92
+ The package validates before touching the driver — `child` cannot be `app` (422), the parent must exist (422 `E_AUTHZ_UNKNOWN_SCOPE`), and `child` cannot be an ancestor of the new parent (422 **`E_AUTHZ_SCOPE_CYCLE`**); on failure the driver is not called at all (*"un ciclo es 422 E_AUTHZ_SCOPE_CYCLE en el paquete, sin llamar al driver"*). `detached` runs **`purgeScope`** — every assignment and deny of that exact scope **whose role or permission is in the catalog** is deleted and the driver proves that set is zero or throws 500 `E_AUTHZ_PURGE_INCOMPLETE` — then notifies `onWrite` with `action: 'scope_purged'`. Nothing resurrects when the same uuid is attached again, and siblings keep their facts (*"detach purga los hechos del scope: nada resucita"*, *"detach es quirúrgico"*). Facts of roles you already removed from the catalog are outside that promise: they grant nothing and are not memberships (the reads filter by the catalog), and `authz:reconcile --to=openfga --prune` collects them. `purgeScope` covers the exact scope only, and **only the facts** — it never writes the catalog, so the local roles owned by that scope survive **dormant** (see [Scoped roles](#scoped-roles-22)); you purge each node of the subtree you delete. `scopes.*` require `config.scopes.resolveChain` (500 `E_AUTHZ_CONFIG` otherwise).
93
+
94
+ Your resolver's *answer* is validated too: an element that is not a well-formed `ScopeRef`, a non-array, an empty chain or an element 0 that is not the asked scope is a 503 `E_AUTHZ_RESOLVER_FAILED` — the question was fine, the dependency was not (*"un ancestro inválido devuelto por el resolutor es 503 E_AUTHZ_RESOLVER_FAILED, no un 422"*). `scopes.attached/moved` also canonicalise the child through your tree before the cycle check, so an alias cannot slip under it.
95
+
96
+ `ScopeType` is an open `string`, so define your own union for type safety. The engine never queries your tables.
97
+
98
+ #### The tree outbox, and the relay lag you are accepting
99
+
100
+ Those three notifications write to the backend **inside your transaction, and they do not roll back with it**. If a later statement of that transaction fails — a constraint, a validation, a pool timeout; no crash needed — your database keeps the old tree and the backend keeps the new one. With the `openfga` driver the backend *is* the PDP, so what is left is a **persistent escalation your own database cannot show you**: every holder with a role in the new parent authorises over a scope that, in SQL, still belongs to the old tenant. This is not misuse; correct use leaks. The suite demonstrates it against a real server — the rollback happens and the escalation stays.
101
+
102
+ The mitigation is the **outbox port**. Declare `scopes.outbox` and `authorization.scopes.attached/moved/detached` stop writing to the backend: they **enqueue** the change inside your transaction, so the tree change and its propagation commit — or vanish — together. `node ace authz:scopes:relay` applies them afterwards.
103
+
104
+ ```ts
105
+ import { sqlScopeOutbox } from '@jantstack/adonis-authz'
106
+
107
+ const outbox = sqlScopeOutbox() // or your own implementation of ScopeOutbox
108
+
109
+ export default defineConfig({
110
+ scopes: { resolveChain, outbox },
111
+ drivers: {
112
+ openfga: () => new OpenFgaAuthorizationDriver({ /* … */ outbox }),
113
+ },
114
+ })
115
+
116
+ await db.transaction(async (trx) => {
117
+ await authorization.scopes.moved(unit, otherOrg, { within, actor, transaction: trx })
118
+ await unit.useTransaction(trx).merge({ organizationId: otherOrg.uuid }).save()
119
+ })
120
+ ```
121
+
122
+ ```bash
123
+ node ace authz:scopes:relay # drain the queue and apply the edges
124
+ node ace authz:scopes:relay --dry-run # list what is still unpropagated
125
+ ```
126
+
127
+ The package **does not impose a table**: the contract is the `ScopeOutbox` port (`enqueue`, `pending`, `markApplied`, `markFailed`, plus two optional ones: `dead` for parked entries and `acquire` for the single-writer lease). `sqlScopeOutbox` is the published implementation over Lucid and `stubs/scopes_outbox_migration.stub` is its migration — **copy it into your migrations yourself**; `node ace configure` does not publish it, because the outbox is opt-in. The only thing an implementation must do is write `enqueue` inside the transaction it is handed.
128
+
129
+ The relay is resumable and never silent: the report says *which* changes were applied, not a count. A change that cannot be applied **defers what depends on it and lets the rest through**: the failure poisons the scopes that change names, every later change naming one of them is reported as `deferred` and not attempted (transitively), and everything else is applied. That is what keeps the tree order — `attached(P, org)` before `attached(C, P)`, a `moved` before the `detached` of the same node — without letting one tenant freeze the queue for all of them. Until 2.0 the pass stopped at the first failure, and a single change that can never be applied (its parent scope was deleted before the pass) then blocked every later change of every tenant, indefinitely. `sqlScopeOutbox` also **parks** an entry after `maxAttempts` failures (default 5): it stops being retried, it is reported in `dead` on every pass, and the command exits non-zero while any exists — a parked entry is a permanent divergence of the backend's tree, not a resolved incident. The command exits non-zero on any failure too, so a supervisor notices. Applying a queued `detached` runs `purgeScope` and only then removes the edge, and it emits the `scope_purged` audit event at that point, carrying the actor that ordered it.
130
+
131
+ **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`). If you implement the port yourself and skip `acquire`, run the relay one pass at a time.
132
+
133
+ **What the outbox does not fix, in plain words.** Between your commit and the relay pass there is a lag of **seconds during which FGA decides with the old tree**. What that costs you depends on the change, and the two directions have **opposite signs** (2.0, the `rooted` relation of the `facts` model — see [What a scope that is not attached grants](#what-a-scope-that-is-not-attached-grants)):
134
+
135
+ - after a `moved` or a `detached` it is a **temporary fail-open**: the **old tenant keeps access** to the moved subtree, and the scope you deleted is only purged when the relay runs;
136
+ - after an `attached` it is a **temporary fail-CLOSED**: **a newly created scope grants nothing at all until the relay runs.** Its chain does not reach the root in the store yet, so `can_<P>` is false there for everyone — including the tenant admin you just created it for — and `database` would answer `true`. **The recipe: drain the queue in the same request, right after your commit** (`await authorization.relayScopeChanges()`) on the interactive "create a tenant" path; that shrinks the window to one relay cycle for whatever failed, and nothing else. **Without an outbox the window is zero**: `authorization.scopes.*` calls the driver inline, in your transaction — which is the trade the outbox exists to make.
137
+
138
+ This is a **breaking change of observable behaviour in 2.0**: until then a not-yet-relayed `attached` *granted*, and **did not inherit the denies above it** — the fail-open the audit found, closing which is exactly what `rooted` buys. Denying for seconds is availability; granting for seconds is the defect this package spent two releases hunting.
139
+
140
+ There is no two-phase commit between your database and the store, and no outbox can fix this, because FGA does not know it is out of date. This is the structural price of keeping the tree in two places, it is an accepted 🟠 risk of this driver, and a shorter relay cycle shortens that window **only for the changes the queue can actually apply**. A change that fails is not bounded by your cycle: it is retried pass after pass, and once it is parked it is never applied at all. While it is unapplied that node's tree is frozen in the store — a new scope never inherits its denies and a deleted one is never purged — so the window there is as long as it takes you to look. The relay tells you, on every pass and in the exit code (`failures`, `deferred`, `dead`); nothing else will. If that window is not acceptable to you, use the `database` driver, where the tree is never a second copy. (Until 2.2 the `openfga` driver had a second mode, `hierarchy: 'resolver'`, that resolved the chain from your database on every question; **2.3 removes it** — see the changelog.)
141
+
142
+ Because a port nobody declares mitigates nothing, the `openfga` driver **refuses to be constructed** without `outbox` and without an explicit `acceptScopeDriftRisk: true` — 500 `E_AUTHZ_SCOPE_DRIFT_UNGUARDED`, at construction, not on the first tenant write. `acceptScopeDriftRisk: true` is the signature for a deployment that only moves the tree from the platform, in a process that shares a transaction with nothing; it must be the literal boolean.
143
+
144
+ #### What a scope that is not attached grants
145
+
146
+ **Nothing.** The model asks, on every question, whether the scope's chain reaches the root: `can_<P>` = *what your roles grant, minus what a deny takes away, **and only if this scope reaches `app`***. A scope whose chain is broken — the store never got its `attached`, or an ancestor was detached and the subtree was left hanging — grants nothing, whatever bindings it carries. This is the same answer `database` gives for a scope your `resolveChain` does not place under the root (invariant 9), and it is a **breaking change in 2.0**: before, such a scope kept granting *and stopped inheriting the denies above it*, so detaching an intermediate node worked as a bulk `removeDeny` over its whole subtree while every `within` barrier held (the deny was still written — the path by which it was inherited was what broke).
147
+
148
+ Two consequences you have to plan for:
149
+
150
+ - **Notify `attached` for every node.** A consumer that materialises paths and only notifies some of its nodes used to get *more* than it asked for; now it gets less. Diagnose it with `authz:reconcile --dry-run`, which lists the scopes that are not reachable from `app`.
151
+ - **Publishing the model is not enough: the store needs its root marker.** The reachability of the root is anchored by one tuple per holder type (`scope:app#rooted@<holder>:*`) — **zero per scope**, so the outbox and the relay carry nothing new. `syncAuthzCatalog` writes it (idempotently, and that is also how a holder type added to your config gets one), and `authz:reconcile` reports it as drift if it is missing. **Without it the whole store denies** — fail-closed and loud on the first question, but total; it is the same class of accident as "the model was never published".
152
+
153
+ ### Identity is validated, once and everywhere
154
+
155
+ `SubjectRef.type`/`uuid`, `ScopeRef.type`/`uuid`, role and permission slugs and `expiresAt` are checked by the manager on every call and again by each driver (the contract suite and third-party drivers bypass the manager). Lowercase letters, digits, `.`, `_`, `-` — **types and uuids alike**: types since 2.0 (a `*_ci` MySQL collation would merge `Users` and `users` into one row while FGA keeps them apart), uuids since 2.1 (the tree of a consumer merges `BBBB…` with `bbbb…` on PostgreSQL's `uuid` type and on MySQL's default collation, and the alias evaded a deny — *"la identidad es una cadena validada por la gramática … un uuid con MAYÚSCULAS … es 422"*; lower-case your ids at your edge: a UUID is the same id in any case); permissions may carry one `:` (`resource:action`); slugs are lowercase and at most **42** characters; `parent`, `binding`, `ancestor`, `rooted`, `role`, `assignee`, `denied` and the prefixes `can_`, `denied_`, `permits_` are reserved; `{ type: 'app', uuid: X }` and the root sentinel uuid outside `app` are rejected — even when your tree knows that sentinel (*"uuid centinela en un scope que el árbol SÍ conoce ⇒ 422"*); `grant`, `revoke` and `listSubjects` take a slug, and a `{ slug, scopeType }` object there is 422 (*"un RoleQuery objeto donde el contrato pide un slug ⇒ 422"*); `expiresAt` is `undefined`, `null` or a valid `Date` (*"expiresAt que no es Date válida, null ni omitido ⇒ 422"*). Violations are **422** (`E_AUTHZ_INVALID_IDENTITY`, `E_AUTHZ_INVALID_SLUG`) before any catalog, tree or backend call — zero queries, spied (*"identidad inválida ⇒ 422"*, *"slug mal formado o reservado ⇒ 422"*, *"una identidad inválida se rechaza con 0 llamadas al backend"*). `assertIdentity`, `assertValidSlug` and `assertExpiresAt` are exported so you can validate at your own edge with the same rule.
156
+
157
+ ## Writes
158
+
159
+ ```ts
160
+ const outcome = await authorization.grant(subject, 'editor', scope, { expiresAt })
161
+ // outcome: { existed: boolean, previousExpiresAt?: Date | null, expiresAt: Date | null }
162
+ ```
163
+
164
+ `expiresAt` has **three states**:
67
165
 
68
- `ScopeType` is an open `string`, so define your own union for type safety and let `resolveAncestors` describe the tree. The engine never queries your tables.
166
+ | `expiresAt` | Meaning |
167
+ |---|---|
168
+ | omitted | do not touch a *live* expiry (an already-expired assignment revives without expiry) |
169
+ | `null` | remove the expiry |
170
+ | `Date` | set it |
171
+
172
+ A seeder or an onboarding that calls `grant` "to make sure they have the role" no longer turns a temporary access into a permanent one (*"expiresAt en tres estados"*, verified with a real expiry that elapses inside the case). When a re-grant changes the expiry of an existing assignment, `onWrite` receives `action: 'extended'` with `previousExpiresAt`; a no-change re-grant stays `granted` (*"cambiar la caducidad de una asignación existente notifica 'extended' con la anterior"*).
173
+
174
+ `onWrite` actions: `granted`, `extended`, `revoked`, `denied`, `deny_removed`, `scope_purged` (no `subject`). Since 2.2 the role-bearing events carry **`roles: CatalogRoleRef[]`** — the *resolved* role(s) (`uuid`, `slug`, `scopeType`, `owner`), not the `RoleQuery` that was asked: a sink that filtered by slug keeps working and now also has the uuid, which is what identifies a role since 2.2. It is a list because a `revoke` by slug removes the facts of every homonym visible in that scope; a `grant` resolves exactly one. It is absent when the role could not be resolved (a scope the tree does not know, a role outside the catalog) — the driver decides the outcome, the event never guesses. It runs *after* the write succeeded, so a hook that throws is logged and swallowed: propagating it would report a failure for an operation that did happen (*"un hook que lanza NO tumba la escritura"*). **It is not free**: resolving those roles costs a **fresh** `resolveChain` (not the `forRequest()` memo) plus a catalog view *per write* — a tree query per `grant`/`revoke` that did not exist before 2.2. Declare `hooks.onWrite` when you want the audit trail, not by default.
175
+
176
+
177
+ One exception, on purpose: when a write **times out** (503 `E_AUTHZ_BACKEND_TIMEOUT`) the outcome is *unknown* — the request may still land on the backend after you received the error. Before propagating, the manager notifies the same event with **`indeterminate: true`**, so your audit records "may have happened" instead of nothing (*"una escritura que vence el deadline notifica onWrite con indeterminate: true ANTES de propagar el 503"*). A 503 that is not a timeout (connection refused) means the write did not happen and emits nothing. The `openfga` driver also stops the SDK from retrying on its own (`retryParams.maxRetry: 0` by default): a background retry after your 503 is exactly the phantom write this is about; enabling retries is opting into it. If you retry a timed-out write yourself, remember it is idempotent.
178
+
179
+ `grant` always returns a `GrantOutcome`; a third-party driver that still returns `void` is normalized to `{ existed: false, expiresAt: options?.expiresAt ?? null }` (*"un driver de terceros cuyo grant no devuelve GrantOutcome sigue notificando granted"*). `revoke`/`removeDeny` require the role/permission to exist in the catalog for that level (422, like `grant`/`deny`); the safe no-op is for a *missing assignment* of a valid role (*"revoke/removeDeny con rol o permiso fuera del catálogo ⇒ 422"*, *"revoke/removeDeny inexistentes son no-ops seguros"*).
180
+
181
+ ## Queries
182
+
183
+ ```ts
184
+ await authorization.authorize(subject, 'docs:write', scope) // the decision
185
+ await authorization.hasRole(subject, 'owner', scope) // membership, inherits downward
186
+ await authorization.hasRole(subject, { slug: 'owner', scopeType: 'organization' }, scope)
187
+ await authorization.listRoles(subject, scope) // direct roles in that exact scope
188
+ await authorization.listRoleScopes(subject, 'organization') // scopes of that type with a direct role
189
+ await authorization.listScopes(subject, 'docs:write') // direct scopes granting it, minus denied
190
+ await authorization.listSubjects('editor', scope) // live holders in that exact scope
191
+ await authorization.listDenies(subject, scope) // direct denies in that exact scope (2.1)
192
+ ```
193
+
194
+ `hasRole` with a string matches, at every level of the chain, only the role *of that level*: an app `owner` inherits downward, an organization `owner` never matches at `app`. The object form `{ slug, scopeType }` restricts the question to chain levels of that type (*"hasRole con el mismo slug en dos niveles"*).
195
+
196
+
197
+ ## Primitives (2.1)
198
+
199
+ 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 `[]`).
200
+
201
+ **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.
202
+
203
+ ```ts
204
+ await authorization.grant(user, 'unit-editor', unit, { within: currentOrg })
205
+ await authorization.removeDeny(user, 'docs:read', unit, { within: currentOrg })
206
+ await authorization.scopes.attached(newUnit, parentUnit, { within: currentOrg }) // the parent must be inside
207
+ await authorization.scopes.moved(unit, otherUnit, { within: currentOrg }) // origin AND destination inside
208
+ await authorization.isWithin(unit, currentOrg) // the same question on its own
209
+ ```
210
+
211
+ > **`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.
212
+
213
+ `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`.
214
+
215
+ **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).
216
+
217
+ **`manager.driver()` is the documented way out of all of that.** It returns the active driver as-is: writes through it skip `actor`/`requireActor`, `within`/`requireWithin` **and `onWrite`**; reads skip the per-request memo. It exists for platform code (seeders, commands, writing at the root under `'non-root'`) and for tests — a tenant call-site should never call it, and a code review can grep for it. Nothing else is offered through it on purpose.
218
+
219
+ **Decisions in bulk.** `authorizeMany(subject, permission, scopes)` → `boolean[]` by position, identical to N `authorize` (duplicates, unknown scopes, denies); empty ⇒ `[]` without touching anything; a position that cannot be answered rejects the whole call. `openfga` answers with one `batchCheck` for all chains (a repeated scope shares one slot); `database` composes N `authorize` over a memoised view (one tree call per distinct scope). A third-party driver's `authorizeMany` is validated: a result that is not a `boolean[]` with exactly one position per scope is 500 `E_AUTHZ_INTERNAL` naming the driver (*"authorizeMany valida la respuesta de un driver de terceros"*).
220
+
221
+ **Effective permissions.** `effectivePermissions(subject, scope)` → the union of what the holder's live roles grant along the whole chain, minus what is denied at any level. Exactly `{ p | authorize(subject, p, scope) }` without asking per permission — and in **two reads** of the facts backend, not two per level: the optional port method `rolesInChain(subject, chain)` (both drivers implement it; a driver without it is composed from N `listRoles`) plus one `listDenies(subject)` (*"effectivePermissions con cadena de 3 lee roles y denies UNA vez"*).
222
+
223
+ **Enumerating scopes.** `authorizedScopes(subject, permission, scopeType)` is the **one** API that enumerates inherited scopes (the explicit exception to "`list*` are direct"):
224
+
225
+ ```ts
226
+ const result = await authorization.authorizedScopes(user, 'docs:read', 'organization')
227
+ // { kind: 'none' }
228
+ // { kind: 'some', scopes: ScopeRef[] } // exact set, ≤ maxScopes
229
+ // { kind: 'all', excludedSubtrees: ExcludedSubtree[] } // granted at the root — MINUS these subtrees
230
+ // ExcludedSubtree = { scope: ScopeRef; includesDescendants: true }
231
+ ```
232
+
233
+ `all` is never silent about denies: `excludedSubtrees` lists every scope with a live deny of the permission — **each one meaning its whole subtree**, which is why the element is a nominal `ExcludedSubtree` and not a `ScopeRef`: a `WHERE uuid NOT IN (…denied uuids…)` would still list the units of a denied organization. Either subtract the subtree in your own query (recursive CTE, materialised path) or expand it first:
234
+
235
+ ```ts
236
+ if (result.kind === 'all') {
237
+ const excluded = await authorization.expandExcludedSubtrees(result.excludedSubtrees) // each scope + all its descendants, via descendantsOf
238
+ orgs = orgs.whereNotIn('uuid', excluded.filter((s) => s.type === 'organization').map((s) => s.uuid))
239
+ }
240
+ ```
241
+
242
+ `expandExcludedSubtrees` is bounded like `authorizedScopes`, expires with its `forRequest()` view like every other read, and throws if `descendantsOf` cannot enumerate a subtree (subtracting it half-way would be fail-open). `some` = direct granting scopes ∪ their descendants via your `descendantsOf`, filtered by type — and **every candidate is checked against `resolveChain`**: its chain must run through the granting scope and must contain no denied scope (the exact rule of `authorize`). So the answer is `{ s | authorize(subject, permission, s) }` scope by scope whenever `descendantsOf` and `resolveChain` describe the same tree; if they disagree — a descendant that the ancestors resolver hangs elsewhere, or does not know — the call is 503 `E_AUTHZ_RESOLVER_FAILED`, never a list with a foreign tenant in it (*"authorizedScopes ≡ { s | authorize(s) } scope a scope"*). More than `maxScopes` ⇒ 422 `E_AUTHZ_TOO_MANY_SCOPES`, never a partial list, and the walk stops as soon as the count of the requested type exceeds it — direct scopes are counted before any subtree is fetched. `{ maxScopes }` per call can only **lower** `scopes.maxScopes` (default 1000), never raise it. It needs `scopes.descendantsOf` in the config; without it, 500 `E_AUTHZ_NO_DESCENDANTS_RESOLVER` — even for a holder with nothing (a `none` without a tree would be a lie).
243
+
244
+ **What it costs.** `authorizedScopes` is **O(descendants of the granting scopes × `resolveChain`)**, not O(answer): every candidate returned by `descendantsOf` — *of any type* — is checked against `resolveChain` once (memoised per call), so an organisation with 300 teams and one unit pays 300 resolver calls to list that one unit, and `maxScopes` (a bound on the *answer*, by type) does not cut that walk. The bound on the *work* is **`scopes.maxDescendants`** (default 10 000, the `maxNodes` handed to `descendantsOf`; more ⇒ 422) per granting scope — set it to what a request may afford, and keep `resolveChain` cheap (`hierarchicalScopeResolver` over an in-memory or cached `parentOf`, or a single SQL per scope). A subtree-shaped `descendantsOf` filtered by type would cut the walk; that change is deferred, the cost is documented instead.
245
+
246
+ ### Scopes: ancestors and descendants
247
+
248
+ ```ts
249
+ import { hierarchicalScopeResolver, sqlDescendantsOf } from '@jantstack/adonis-authz'
250
+
251
+ scopes: {
252
+ // From your table: nodeOf reads the ROW — { self: the canonical scope, parent: ScopeRef | null (top level) } — or undefined = unknown scope.
253
+ resolveChain: hierarchicalScopeResolver({ nodeOf: (scope) => nodes.nodeOf(scope), maxDepth: 64 }),
254
+ // One recursive CTE over your table (PostgreSQL, MySQL 8 and SQLite; any other dialect ⇒ E_AUTHZ_UNSUPPORTED_DIALECT).
255
+ descendantsOf: sqlDescendantsOf({ table: 'org_nodes', uuidColumn: 'uuid', parentColumn: 'parent_uuid', typeColumn: 'kind' }),
256
+ maxScopes: 1000, // answer bound of authorizedScopes
257
+ maxDescendants: 10000, // maxNodes handed to descendantsOf
258
+ }
259
+ ```
260
+
261
+ `hierarchicalScopeResolver` walks `nodeOf` — the row of a scope: its **canonical** `self` (what makes the chain canonical: the row found for an alias carries the real id) and its `parent` — with a visited set (a cycle is 422 `E_AUTHZ_SCOPE_CYCLE`) and a depth bound (`maxDepth` ancestors, `app` included) that **throws** rather than truncating (500 `E_AUTHZ_SCOPE_TOO_DEEP`: a chain without its root would lose the root's denies). A `self` or `parent` that is not a well-formed scope (`{ type: 'app', uuid }`, an upper-case type…), or a `self` that is not the row of the scope asked, is 503 `E_AUTHZ_RESOLVER_FAILED`, never normalised. It costs one `nodeOf` per level — wrap it with `memoizeAncestors` or read through `forRequest()`. `descendantsOf(scope, { maxNodes })` returns the whole subtree (any type, any depth) or `null` for an unknown scope; more than `maxNodes` ⇒ throw. `sqlDescendantsOf` validates identifiers and `scopeType` (nothing else is interpolated), gives every query the deadline, reads at most `maxNodes + 1` rows and bounds the recursion so that a cycle in your table terminates and is reported as 422 `E_AUTHZ_TOO_MANY_SCOPES` ("posible ciclo"). `descendantsOf` is **never** called from `authorize`, `hasRole`, `list*`, `authorizeMany`, `effectivePermissions` or a write — an architecture spy in the contract pins zero calls.
69
262
 
70
263
  ## Enforcing in routes
71
264
 
@@ -75,84 +268,677 @@ router
75
268
  .use(middleware.appAccess({ permission: 'audit:read' }))
76
269
  ```
77
270
 
78
- The middleware resolves the authenticated holder from its morph name and asks the engine. Identity decides *what you may do*; if you also issue scoped API tokens, that's an orthogonal check — the token narrows, it never widens.
271
+ The middleware resolves the authenticated holder from its morph name and asks `authorize` at the **`app` scope**. It accepts **`{ permission }` only**: `appAccess({ role })` was removed in 2.0 a gate over membership could not be denied and passing `role` is a 500 `E_AUTHZ_ROLE_IS_NOT_ACCESS` with the recipe (create a permission, link it to the role, gate on the permission), thrown before authentication is checked (*"appAccess({ role }) es 500 E_AUTHZ_ROLE_IS_NOT_ACCESS con la receta"*). Per-organization or per-unit enforcement is your controller's or your own middleware's job: only your domain knows which scope a route belongs to. The holder must expose `uuid`; a numeric-PK model is rejected with an explicit error.
272
+
273
+ ### Per-resource enforcement: `resourceAccess` (2.5)
274
+
275
+ `appAccess` gates a route at the `app` scope. To gate one **resource** — *this document, in that
276
+ organization* — the `resourceAccess` middleware composes the same `authorize`: your code says how to
277
+ load the resource and which `{ scope }` it lives in, and the middleware asks the engine about that
278
+ scope. It is not a new model or a driver, it is the HTTP edge of a resource's scope.
279
+
280
+ ```ts
281
+ router
282
+ .get('/orgs/:orgId/documents/:id', [DocumentsController, 'show'])
283
+ .use(middleware.resourceAccess({
284
+ resource: 'document', // ctx.document = the loaded resource, for the controller
285
+ param: 'id', // ctx.params.id
286
+ containerParam: 'orgId', // optional: the tenant/parent in a nested route
287
+ permission: 'documents:write', // mutating methods
288
+ readPermission: 'documents:read',// safe methods (GET/HEAD); omitted ⇒ reads still need `permission`
289
+ load: (ctx, id) => Document.query().where('id', id).first(), // → { scope } | null
290
+ gate: (ctx) => ctx.auth.user!.isMemberOf(ctx.params.orgId), // optional pre-ability
291
+ }))
292
+ ```
79
293
 
80
- Two things it does **not** do. It only checks the **`app` scope** — enforcing per-organization (or per-unit) access is your controller's or your own middleware's job, because only your domain knows which scope a given route belongs to. And it requires the holder to expose `uuid`: the engine identifies holders by uuid, not by the model's primary key, so a model with a numeric PK is rejected with an explicit error.
294
+ **The order of the responses is the security property** a 403 where a 404 belongs leaks which
295
+ resources exist (enumeration):
296
+
297
+ 1. **401** if there is no authenticated holder;
298
+ 2. **403** if your optional `gate(ctx)` (a prior ability, e.g. "is admin of this tenant") denies;
299
+ 3. **404** if the declared `containerParam` is absent from the route;
300
+ 4. **404** if `load` returns `null` — **the same body** as the container 404, so "does not exist" and
301
+ "not yours" are indistinguishable;
302
+ 5. `authorize` **once** over the scope `load` returned (with `readPermission` on safe methods): a
303
+ `false` here is **also a 404 with the same body**, never a 403 — that you cannot see it does not
304
+ reveal that it exists.
305
+
306
+ The non-negotiables mirror `appAccess`: `AuthorizationBackendError` (503) is **never disguised** as a
307
+ 404/403 (if `gate`/`load`/`authorize` throw, the error rises as-is — "denied" and "could not check"
308
+ stay distinct, so these calls are deliberately not wrapped in try/catch); **`role` is forbidden**
309
+ (`resourceAccess({ role })` is a 500 `E_AUTHZ_ROLE_IS_NOT_ACCESS` with the recipe, because membership
310
+ is not access and the deny does not govern it); and there is **no second `authorize`**. A throw from
311
+ `load` is "could not check" (503), never a 404. The middleware imports no consumer alias — `load`/
312
+ `gate` arrive injected in the route options.
313
+
314
+ > **Known limit — a timing channel.** The status and body of "does not exist" and "exists but is not
315
+ > yours" are identical, but the **time** is not: a non-existent id answers 404 without a round-trip to
316
+ > `authorize`, a foreign one answers the same 404 *after* that call. This is inherent to
317
+ > `load → authorize` (you cannot authorize the scope of something you have not loaded), not a defect;
318
+ > whoever needs to close the channel equalises the time in their own layer (a constant delay), not in
319
+ > the middleware.
81
320
 
82
321
  ## The catalog
83
322
 
84
- Roles and permissions are config-driven and synced idempotently:
323
+ Roles and permissions are config-driven:
85
324
 
86
325
  ```ts
87
326
  // config/app_acl.ts
88
327
  permissions: [{ slug: 'audit:read' }, { slug: 'admin:manage' }],
89
- roles: [{ slug: 'superadmin', rank: 100, permissions: '*' }],
328
+ roles: [{ slug: 'superadmin', scopeType: 'app', rank: 100, permissions: '*' }],
329
+ ```
330
+
331
+ ```bash
332
+ node ace authz:catalog:sync # sync every catalog in config.catalogs, in order
333
+ node ace authz:catalog:sync --keep-links # 1.x additive mode
334
+ node ace authz:catalog:diff # exit 1 on drift — run it in CI
335
+ node ace authz:catalog:diff --fail-on-shadows # …and on roles shadowed by a more authoritative one
336
+ node ace authz:catalog:prune-orphans # list local roles whose owner scope is gone (--force to purge)
337
+ node ace authz:scopes:relay # drain the scope-tree outbox (see The scope tree)
338
+ node ace authz:reconcile --to=openfga --dry-run # verify a driver against authz_* and your tree (exit 1 on drift)
339
+ ```
340
+
341
+ `syncAuthzCatalog(spec, { prune: 'links' | 'none', timeoutMs })` is idempotent and transactional. The default **prunes**: for every role *of the spec*, role→permission links the spec no longer lists are deleted in the same transaction, so removing a permission from a role in config removes it from every environment on the next sync (*"quitar un permiso de un rol y re-sincronizar el catálogo lo retira: sin privilegios zombi"*, a contract case in both drivers). Roles and permissions are never deleted (they carry assignments), and roles outside the spec are untouched, so two catalogs — platform and tenant — coexist (*"dos catálogos coexisten"*). **A role `(slug, scopeType)` and a permission belong to exactly one catalog**: `authz:catalog:sync` and `authz:catalog:diff` resolve every catalog first and refuse, before writing anything, if two of them declare the same one (422 `E_AUTHZ_CATALOG_CONFLICT`) — otherwise the second sync would prune the first catalog's links in silence (*"un rol o un permiso declarado en dos catálogos es 422 E_AUTHZ_CATALOG_CONFLICT, sin escribir"*). A role granting a permission that exists in no catalog is 422 `E_AUTHZ_UNKNOWN_PERMISSION`; a permission from an earlier catalog in `config.catalogs` is fine, so order matters. The whole catalog is validated before anything is written: slug grammar, `scopeType` as a scope identity, and collisions after encoding (`docs:write` vs `docs_write`) — within the spec **and against the permissions already in the database** (*"la colisión tras codificar se comprueba también contra los permisos ya en la base"*). A database that does not answer during sync or diff is a 503 `E_AUTHZ_BACKEND_UNAVAILABLE`, not a raw driver error (*"el catálogo con la base caída es 503"*).
342
+
343
+ **Global roles win, and nothing is silent (2.2).** A spec only ever declares **global** roles (`owner_scope_key = 'global'`), and a local role with the same `(slug, scopeType)` no longer stops the deploy: until 2.2 the sync answered 422 and **rolled the whole catalog back**, so a tenant admin with rank 2 could stop the platform's deploy for ever by squatting a name. The sync now writes the global — it wins — and **reports** every local role it shadows (`shadowedByGlobal: CatalogRoleRef[]`, printed as a warning by `authz:catalog:sync`); from then on, **inside that chain the name is unusable by slug for everyone — the platform included**: `grant`, `hasRole` and `listSubjects` by slug answer 422 `E_AUTHZ_AMBIGUOUS_ROLE` there, so onboarding your *own* global role in that tenant needs `{ uuid }` (measured: 5 of 5 shadowed slugs, audit N4). Outside that subtree the slug keeps working, and nothing escalates — a fact points at a role's uuid, so the local role's holder never inherits the global's permissions. The form that always works is `{ uuid }`; the way back is purging one of the two. `listRoles` returns **slugs**, so a shadowed pair is indistinguishable there (`['soporte']` for both holders, with different effective permissions): branch on permissions, or read the identity with `rolesInChain`/`{ uuid }`, never on a role name. `defineScopedRole` still refuses collisions **upwards** (global, or a local of an ancestor); a local of a *descendant* is shadowed instead — see [Scoped roles](#scoped-roles-22). Narrowing a permission's `assignableAt` is reported the same way: the sync revalidates **every** role that already carries it — local ones and globals from another catalog — and lists the links the new restriction no longer admits (`assignableAtViolations`) instead of leaving them in place in silence; it does not delete them (what is assigned keeps granting, invariant 1), so you decide. `assignableAtViolations` is drift for `authz:catalog:diff` (exit ≠ 0); the shadows are **not** — they are listed and the command exits 0, because a tenant who squats a name must not be able to keep the platform's CI gate red (audit N1). If you would rather know from CI (the shadows mean the by-slug routes of that subtree are dead for you too), `authz:catalog:diff --fail-on-shadows` counts them as drift for that run (audit P5).
344
+
345
+ `authz:catalog:diff` lists missing permissions/roles/links, surplus links, rank and `assignableAt` mismatches, the two reports above, and **homonym roles** — two roles with the same `(slug, level)` visible from one chain, which make every by-slug question there a 422 (the global+local pair is always detected, the local+local pair needs your `scopes.resolveChain`, which the command passes). Those are classified by authority: `shadowedByGlobal` and `shadowedByAncestor` are *listed* and exit 0 (`--fail-on-shadows` makes them drift too), and only what authority cannot order (`ambiguousRoles`) is drift — (`diffAuthzCatalog` / `runCatalogDiff` are exported for your own checks). The shadows of **every** catalog are printed, deduplicated (2.3: only those of the first were), and `shadowedByAncestor` carries **one entry per shadowed role**, naming the most authoritative shadower — with nested owners `a > b > c` you get two lines and not three. It also lists the roles that scopes defined for themselves as *"propios de un scope"* — informative, never surplus. The sync never touches a local role's links or rank (*"el sync solo toca roles GLOBALES"*), and a corrupt `assignable_at` row is reported as a difference — the diff exists to *report* it, so it no longer dies with a 500 in the very deploy that would repair it. A permission may carry `assignableAt` — the levels whose roles can carry it — see [Scoped roles](#scoped-roles-22).
346
+
347
+ ## Scoped roles (2.2)
348
+
349
+ A role has an **owner**: `global` — declared in config and synced with `syncAuthzCatalog` — or the scope that defined it with `defineScopedRole(actor, ownerScope, spec)`. One rule, in both drivers: *an assignment in scope S of role R counts if and only if R is global or R's owner is in chain(S)* (S inclusive). Outside its owner a local role does not exist: it grants nothing, is no membership (`hasRole`, `listRoles`, `listSubjects`, `listRoleScopes`, `listScopes`, `effectivePermissions`, `authorizedScopes` all apply the rule) and cannot be granted (422 `E_AUTHZ_ROLE_NOT_VISIBLE`, nothing written). Moving a unit out of the owner's subtree **retires** what the local role granted there, and moving it back restores it — the tree of *today* decides. **How that retirement happens depends on the driver (2.3, breaking):** in `database` it costs no write at all (the rule is evaluated on every question). In `openfga` the model has no `owner`, so a `role_binding` would keep granting while its scope is reachable — a fail-open — and the package therefore **writes**: `scopes.moved` sweeps the `scope#binding` edges of the local roles whose owner is no longer in the chain, across the **whole moved subtree**, and rewrites them when the owner is in the chain again. Global roles are never touched, and neither is a local role whose owner is still an ancestor. That write follows the same path as any other tree change: with `scopes.outbox` it is applied by `authz:scopes:relay`, so it inherits the [temporary fail-open of the relay lag](#the-tree-outbox-and-the-relay-lag-you-are-accepting), and `authz:reconcile --to=<the driver you are serving from>` reconciles it if the relay was lost — that pass reads the facts from the store itself, recomputes the same rule with the tree and the catalog of *today*, deletes the `scope#binding` edges the rule forbids and writes the ones it requires, and counts them in `drift.roleVisibility` (see [whoever owns the facts](#migrating-and-verifying-authzreconcile-23); before 2.3's last cut it rebuilt from `authz_*`, which in a `facts` deployment hold no facts at all, so the sweep never ran and the report said `0`) (*"un rol local de la organization A concede en A y sus units, no en B ni en app"*). **The same is true of a role's level** (2.3): the (c2) model does not carry `scope_type` either, so changing the level of a role — an assignment stays where it is, but the role is no longer declared for that kind of scope — is retired in `database` by evaluating the rule again, and in `facts` by the same sweep, run from `projectCatalogRole` (the package calls it after `defineScopedRole`/`updateScopedRole`; a process that writes `authz_*` by hand owes it the same call it already owed for the permission mirror). **What this makes the `scope#binding` edge mean, plainly: "the role is visible here", not "this assignment exists".** The assignment itself is the `assignee` tuple, which no sweep ever touches — which is why `hasRole`, `listRoles` and `listSubjects` still enumerate assignments and filter them through the catalog, and answer exactly as they did before. Two tenants may each define `lead@unit` with different permissions: the slug no longer identifies a role, the uuid does, and nothing crosses tenants (*"dos tenants definen el mismo slug"*). A deny anywhere in the chain still wins over a local role (invariant 2).
350
+
351
+ **A local role never lives *above* its owner.** A role whose `scopeType` is the level of one of the owner's **ancestors** (`app` included, which is in every chain) is visible nowhere: it grants nothing, is nobody's membership and cannot be granted — all it does is occupy that `(slug, level)` for the owner of the tree and for the global catalog, which is squatting with the shape of a spec (like `permissions: []`). It is 422 `E_AUTHZ_ROLE_LEVEL_ABOVE_OWNER`, in `defineScopedRole` and in `updateScopedRole` — a row that already has an impossible level is not perpetuated either; purge it. The rule is decided with the owner's chain, which is already resolved, so it costs nothing and needs no extra configuration: the owner's own level is fine and **any other level is assumed to be below** — delegating downwards (`lead@unit` owned by an organization) keeps working with the published config stub. If you do declare `scopes.descendantsOf`, the check is **tightened**: the level must actually appear below the owner in today's tree — and if that subtree cannot be enumerated (more nodes than `maxDescendants`, or a `descendantsOf` that fails) the check **degrades to the minimal rule** instead of failing: *declaring `descendantsOf` must never leave you worse off than not declaring it* (audit N3, where a tenant with more units than the bound could no longer delegate downwards at all). **Be explicit about what that degradation costs** (audit P4): the strong check is a control the watched subject can switch off — creating scopes is a normal product feature, so an actor who creates more than `maxDescendants` children of their own scope gets the minimal rule back — and it also switches itself off when the resolver is down. That is deliberate and bounded: the minimal rule is the one every consumer runs with the published stub, it never grants anything, and the residual damage is squatting a `(slug, level)` that stays repairable by **authority plus rank** — an ancestor defines its own role and shadows it (below) **only if its rank is above the squatter's**. Ranks are your metadata (invariant 8) and nothing forces them to decrease with depth, so with a non‑monotonic layout — a rank‑60 role in a unit under a rank‑50 organization admin who owns that tree — the owner of the tree gets 422 `E_AUTHZ_RANK_EXCEEDED` from **both** doors (defining its own homonym and `deleteScopedRole`), and `scopes.detached` is not a third door either (since 2.3 it purges facts and never the catalog). The recourse is then the **platform**: whoever carries the highest‑rank global role outranks every local role by construction (`0 < rank < min(actor, highest global rank)`), and `manager.driver().purgeRole(uuid)` always works (audit D1). If that trade is not acceptable for you, keep `maxDescendants` above the size of your biggest subtree.
352
+
353
+ **The slug is a name, the uuid is the identity.** Since a role can be local, `RoleQuery` — what `grant`, `revoke`, `hasRole` and `listSubjects` take — has three forms: a slug, `{ slug, scopeType }` and, since 2.2, **`{ uuid }`**. If two roles with the same `(slug, level)` are visible from the same chain — a `scopes.moved` that joins two subtrees, or a local one living next to a global one — the two name-based forms fail **closed** with 422 `E_AUTHZ_AMBIGUOUS_ROLE`, naming every uuid and owner; `{ uuid }` is the only form that answers (the role must be visible in that scope, else 422 `E_AUTHZ_ROLE_NOT_VISIBLE`). Choosing one — "the closest owner wins" — is what let the admin of A hand out B's role by the same slug, so ambiguity is an error, not a resolution rule. `authorize` never addresses by slug and keeps answering; `listRoles` is a membership API and keeps returning slugs (which may therefore have homonyms — the unambiguous form is `{ uuid }`, and `rolesInChain` in the port returns `uuid`, `slug`, `scopeType` and `owner`). `revoke` by slug does not choose either: it removes the facts of **every** homonym in that exact scope (removing never grants). `authz:catalog:diff` lists such pairs, classified by **authority** (global > local of an ancestor > local of a descendant): the ones authority orders are `shadowedByGlobal`/`shadowedByAncestor` — listed, exit 0, because a tenant must not be able to keep your CI gate red; pass **`--fail-on-shadows`** if you *do* want your pipeline to stop on them (they mean the slug routes of a whole subtree are dead, yours included) — and only a pair nothing orders (two owners each claiming to be the other's ancestor: a `resolveChain` with a cycle or that contradicts itself) stays `ambiguousRoles`, which is drift (exit ≠ 0). **The way out is purging, not renaming**: `updateScopedRole` changes `name`, `description`, `rank` and `permissions` and never the slug, so a tenant caught in an ambiguity keeps operating with `{ uuid }` and someone with enough rank purges one of the two (`deleteScopedRole`, or the platform with `manager.driver().purgeRole(uuid)`). If a `scopes.moved` dropped a high-rank homonym into a tenant with a lower rank, only the platform can undo it.
354
+
355
+ **The uniqueness is enforced, not hoped for.** Every write to `authz_*` goes through `withAuthzCatalogWrite`, which locks the `authz_catalog_version` row first (PostgreSQL/MySQL; SQLite already serialises writes) and bumps it last, so catalog writers run one at a time; `defineScopedRole` re-checks the collision **inside** that transaction against the database, not against the memo. Two concurrent `defineScopedRole` of the same `(slug, level)` **for the same owner** end with exactly one role and a 422 `E_AUTHZ_CATALOG_CONFLICT` for the loser (a contract case in PostgreSQL and MySQL, where the row lock serialises catalog writers — capability `serializedCatalogWrites`; SQLite serialises by locking the whole database, so the loser's transaction may instead die with a 503, and the judge only requires that it never writes). If the two owners are in an **ancestor→descendant** relation the race has *two* legal endings and which one you get depends on who commits first (milliseconds decide it): if the ancestor's commits first the descendant's is 422; if the descendant's commits first the ancestor's no longer collides — it is written and **shadows** it (authority, 2.2), so you end with two roles and that slug ambiguous inside the descendant's subtree. Both endings are loud; neither writes twice for one owner. **The 422 is what you get when the loser reaches the lock; if it *waits* on the lock past the deadline it gets 503 `E_AUTHZ_BACKEND_TIMEOUT` (`catalog.lock`) without writing** — fail-closed and retryable, but it is a 503, not a 422 (audit N6). Keep the critical section short: it holds while a `syncAuthzCatalog` runs, so a big deploy makes that 503 likelier for concurrent `defineScopedRole` (2.2 batches the shadow lookup into one query for exactly this reason; the `assignableAt` revalidation is already a single one). A `defineScopedRole` racing a `syncAuthzCatalog` has **two** legal endings, both loud: if the sync commits first the define hits the global and is 422; if the define commits first the sync writes the global anyway and reports the local role it shadowed (`shadowedByGlobal`). What can never happen is a local role that nobody mentions.
356
+
357
+ **When a scope disappears, its roles fall asleep — and the platform sweeps them.** `scopes.detached(child)` purges **facts and only facts** (invariant 11): assignments and denies of that exact scope, under the canonical `chain[0]` like everything else (invariant 17), and it never writes the catalog. The local roles owned by that scope stay in `authz_roles` **dormant**, and *dormant* means exactly this: **the role is not visible from any live scope whose chain does not pass through its owner**. It does **not** mean the role stops granting. The single visibility rule (invariant 18) asks the owner to be in the chain of the scope you are asking about, and **a live descendant whose materialised path still goes through the owner satisfies it** — there the role grants, is a membership on all six read paths and *can be granted*, by slug and by uuid (measured, audit of 2.3). That is the normal shape of a two-step delete, or of any consumer with materialised paths: the owner's row is gone, its children are not. So: a dormant role grants nothing **in a scope whose chain no longer reaches the owner**, and a dormant role with no live assignments grants nothing at all — but "dormant" is not a synonym for "inert". What it does in every case is occupy its `(slug, level)` wherever it is still seen, and `deleteScopedRole` cannot reach it — it resolves the owner fresh and answers 422 `E_AUTHZ_UNKNOWN_SCOPE`. That is what the sweeper is for:
358
+
359
+ ```bash
360
+ node ace authz:catalog:prune-orphans # dry run: lists the local roles whose owner no longer resolves
361
+ node ace authz:catalog:prune-orphans --force # purges them (purgeRole each: assignments + links + row), notifies role_purged
362
+ node ace authz:catalog:prune-orphans --force --allow-mass-purge # ... even if that is *every* owner (read the next paragraph first)
90
363
  ```
91
364
 
365
+ 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.
366
+
367
+ **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.
368
+
369
+ **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.
370
+
371
+ **Why it is not `scopes.detached`'s job (2.3).** Between 2.2's first cut and its close, `detached` also purged the roles owned by the scope and — with `descendantsOf` — those of the whole subtree. That put a *catalog* write at the end of an operation a **tenant** triggers, about a scope that no longer resolves, so it needed a rank policy with no chain to measure it on, a subtree enumeration and a degradation for when that enumeration fails. Five batches touched it and **three of the four regressions of that phase were born there**, every time by composing pieces that were correct on their own — the last one destroying local roles of **live** descendants that both other doors refused with 422 (audit P1). None of those pieces exists any more: `detached` is O(1) again, and the cleanup happens where nobody is racing anybody.
372
+
373
+ ```ts
374
+ // config/authorization.ts — the platform declares what may be delegated at all:
375
+ delegablePermissions: ['docs:read', 'docs:write', 'billing:read'],
376
+
377
+ // An organization admin (the actor) defines a role that exists only inside orgA and its descendants:
378
+ const lead = await authorization.defineScopedRole(admin, orgA, {
379
+ slug: 'lead', scopeType: 'unit', rank: 20, permissions: ['docs:write'],
380
+ }) // { uuid, slug, scopeType, owner: 'organization|<uuid>', rank }
381
+ await authorization.grant(bob, 'lead', unitA1, { within: orgA }) // unitA1 is under orgA
382
+ await authorization.grant(bob, 'lead', unitB1, { within: orgB }) // 422 E_AUTHZ_ROLE_NOT_VISIBLE
383
+ await authorization.updateScopedRole(admin, lead.uuid, { permissions: ['docs:read'], rank: 25 }, { within: orgA })
384
+ await authorization.deleteScopedRole(admin, lead.uuid, { within: orgA }) // purges every assignment, then the role
385
+ await authorization.grant(bob, { uuid: lead.uuid }, unitA1, { within: orgA }) // the unambiguous form
386
+ ```
387
+
388
+ **Policy — write-time, mandatory, checked before anything is written.** The `actor` is required (422 `E_AUTHZ_ACTOR_REQUIRED`, whatever `requireActor` says: without it there is no policy to evaluate). **It must come from the session, never from the request body** — the same rule as `within`, and here it matters more: the package only validates the actor's *grammar*, and everything the delegation policy allows is measured against that identity. An endpoint that forwards `req.body.actor` lets anybody delegate anybody's permissions. The owner is a real scope that is not the root (the root's roles are global: config + sync) and the role's level is not `app`. Every permission must be in `config.delegablePermissions` (a whitelist; `[]` by default, so nobody delegates anything until the platform says what — platform permissions should not be in it), exist in the catalog, be composable at that level (`assignableAt`, below) and be **effective for the actor in the owner** — granted by a role of theirs along the owner's chain and not denied there; a deny is not laundered by composing a role for a puppet (security panel C2) — else 422 `E_AUTHZ_PERMISSION_NOT_DELEGABLE`. `0 < rank < min(actor's rank, highest global rank)`, else 422 `E_AUTHZ_RANK_EXCEEDED` — the actor's rank is the highest `rank` among their visible roles along the owner's chain, so an actor whose roles have rank 0 delegates nothing. No **more authoritative** role `(slug, scopeType)` may be visible where the new one would be — global, or local to an ancestor (or the owner itself) — else 422 `E_AUTHZ_CATALOG_CONFLICT`; sibling organizations may share a slug. A homonym local to a **descendant** is *not* a conflict since 2.2: **a more authoritative definition wins and shadows the less authoritative one** (global > local of an ancestor > local of a descendant), so the owner of the tree can always define their role even if somebody below took the name first, and the squat only shadows itself. **Shadowing also takes rank**: the actor's rank must be *above* the rank of every role they would shadow, else 422 `E_AUTHZ_RANK_EXCEEDED` and nothing is written — and `updateScopedRole` on a role that already shadows one asks the same. Shadowing is as destructive as deleting (inside the shadowed role's subtree that slug becomes 422 for everyone, and the victim cannot undo it: their rank is measured on the chain of the *shadowing* role's owner, where they are nobody), so it follows the one rule the rest of the API follows — *you only act on a role you outrank* — instead of position alone: without it a rank-3 actor in an organization made a rank-40 unit role unusable by slug for good (audit P3′). The 422 does not name the shadowed role's rank or owner: an ancestor does not get to enumerate what is below it (same rule as `E_AUTHZ_AMBIGUOUS_ROLE`). **Read that rule as what it is: a check performed when the catalog is written, not an invariant of the system** (audit D3). Whether one role shadows another is a function of *today's tree*, and the tree moves without asking the catalog: `scopes.moved` can drop a subtree under an organization that already holds a homonym and a shadow appears with **no rank judged anywhere** — the owner of the moved subtree may then be unable to repair it, because their rank is measured on the chain of the shadowing role's owner, where they are nobody (only the platform can undo that one). The same happens in a narrower window at write time: if `scopes.resolveChain` does not answer for the victim's owner at that instant, the shadow cannot be proved and the write goes through (audit D2) — deliberately, because refusing would turn a **dormant** role into a lock on its `(slug, level)`, which is precisely the mine 2.3 removed. Both are loud (`authz:catalog:diff` lists them as `shadowedByAncestor`, `--fail-on-shadows` makes them drift) and neither grants anything: `authorize` never addresses by slug. And note the honest limit of the check itself — it only protects roles that **already exist**: the same actor gets the same denial by simply **going first**, which has always been free. The shadowed roles come back in the `role_defined` event (`shadowedByAncestor`) and `authz:catalog:diff` lists them without counting them as drift. Inside the descendant's chain that slug is then 422 `E_AUTHZ_AMBIGUOUS_ROLE` for everyone and `{ uuid }` is the form that answers — the same deal as with a global, and nothing grants more (a fact points at a uuid). Before 2.2 this was 422 and it was the last shape of the slug mine: a rank-5 actor could take a name from the tree owner for good and keep `authz:catalog:diff` — the CI gate of the deploy — red until someone purged role by role (audit N1). `permissions: []` is 422 `E_AUTHZ_INVALID_IDENTITY`: a role that grants nothing only occupies its owner's `(slug, level)`. `updateScopedRole` takes `name`, `description`, `rank` and `permissions` — **never** slug, level or owner, and passing one of those is 422 `E_AUTHZ_INVALID_IDENTITY` rather than a silent no-op; a no-op change writes and notifies nothing — and it and `deleteScopedRole` additionally require the actor's rank to be **above** the role's, and a global role is 422 `E_AUTHZ_ROLE_IMMUTABLE` (change the config and sync). All three take `ScopedWriteOptions` (`within`, `actor`) like the other six writes: `requireWithin` covers them and the scope checked is the role's owner. `rank` remains metadata for `authorize` (invariant 8): all of this is composition and delegation policy, never evaluation.
389
+
390
+ The three resolve the owner's chain **fresh** — never through a `forRequest()` memo: a unit that moved to another tenant during the request cannot receive a role delegated by the old tenant's admin (C3), and the owner is written with the tree's canonical identity — write through `withAuthzCatalogWrite` (the shared catalog version bumps as the last statement of the same transaction, so every other process sees the new role on its next question; the contract observes it with a second catalog memo) and notify `hooks.onCatalogWrite` (`role_defined` / `role_updated` / `role_purged`, always with `actor`, the role, its owner and its permissions; a hook that throws is logged, the write stands). `deleteScopedRole` goes through the port's `purgeRole(roleUuid)`: every assignment of the role in every scope, its links and the row, atomically, so re-creating the slug revives nothing (`database`). `purgeRole` is **optional** in the port: a driver that cannot purge roles simply does not implement it (the `openfga` driver until 3b — it cannot enumerate a role's bindings by role without reading the whole store — capability `purgeRole: false`). Then `defineScopedRole` is **500 `E_AUTHZ_UNSUPPORTED` before writing anything**: a local role that nothing could ever delete would leave `deleteScopedRole` and `authz:catalog:prune-orphans` dead for ever. State that cannot be undone is not created. If such rows exist anyway, the way out is deleting them yourself — the catalog is always SQL and it is yours. Two ways to get there: rows written by hand or by a migration, and **switching the deployment's driver to one without `purgeRole`** (the catalog is shared SQL, so roles created under `database` are still there under `openfga`). That second one freezes the *catalog* of every scope with a local role — `deleteScopedRole` is 500 and you cannot define another role there — but not the facts: `scopes.detached` never needed `purgeRole` since 2.3 and purges the scope normally. The recipe, verified (audit N7):
391
+
92
392
  ```ts
93
- import { syncAuthzCatalog } from '@jantstack/adonis-authz'
94
- await syncAuthzCatalog(appAclCatalog()) // additive, safe to re-run
393
+ // One-off, from a command: purge with a driver that can, then carry on with the new one.
394
+ const sql = new DatabaseAuthorizationDriver({ resolveChain }) // whatever your config/authorization.ts passes it
395
+ for (const uuid of roleUuids) await sql.purgeRole(uuid) // assignments + links + row, atomically
396
+ await authorization.scopes.detached(scope) // the facts, with any driver
95
397
  ```
96
398
 
97
- `rank` is metadata for *your* assignment policy ("nobody grants a role at or above their own rank"). The engine stores it; enforcing a privilege ceiling is a decision only your domain can make the engine is mechanism, not policy.
399
+ Plan the driver switch like a fact migration (`authz:reconcile`), and treat "this deployment has local roles" as a reason not to move to a driver without `purgeRole`. Without `listDenies` in the port, `defineScopedRole` and a permission change in `updateScopedRole` are 500 `E_AUTHZ_UNSUPPORTED` too: the policy subtracts the actor's denies and will not assume there are none.
400
+
401
+ **`assignableAt` — composition, never evaluation.** A permission may declare the levels whose roles can carry it: `{ slug: 'org:settings', assignableAt: ['app', 'organization'] }`. `syncAuthzCatalog`, `defineScopedRole`/`updateScopedRole` and — for links written by hand — `grant` reject a role of another level carrying it (422 `E_AUTHZ_ROLE_NOT_ASSIGNABLE_AT`, nothing written). `authorize` **never** looks at it: an assignment that exists keeps granting what its role links (invariant 1), pinned by a contract case in both drivers (*"assignableAt es control de COMPOSICIÓN, jamás de evaluación"*). It covers "a unit role must not carry `org:settings`" without a permission that stops inheriting downwards. The config wins over the stored value; `authz:catalog:diff` reports the drift.
402
+
403
+ **A note for driver authors (fragility, like the one about the owner rule in `authorize`).** Two of the sharpest rules here hang on a *single* contract case per harness: the owner check inside `authorize`, and the ambiguity rule of `RoleQuery` (a driver with its own `roleVisible` can pass 82 of 83 cases with the escalation inside). If you refactor either path, do not trust a green suite alone — read the case, and add one of your own.
404
+
405
+ Storage: `authz_roles.owner_scope_key varchar(80) NOT NULL DEFAULT 'global'` with `unique(slug, scope_type, owner_scope_key)` and an index by owner — the key is `<type>|<uuid>`, the same `scopeKey` as the OpenFGA binding ids (exported, with `scopeFromKey`); `'global'` is reserved and no scope produces it (the root gives `app`, everything else carries `|`); any other value — `app` included, which would be visible in *every* chain: a global in disguise the sync does not govern — is a corrupt row, 500 `E_AUTHZ_INTERNAL` — and `authz_permissions.assignable_at` (a JSON list, `NULL` = any level; it must fit in `varchar(500)`, checked at write time with 422, so a truncated value can never turn every `view()` into a 500; a corrupt value is 500 `E_AUTHZ_INTERNAL`, never "any level"). A 1.x row is global after the [upgrade recipe](#operational-notes-for-the-sql-engines) and the next sync recognises it as the same role.
406
+
407
+ ## Relations (ReBAC) (2.4)
408
+
409
+ Alongside role-based `authorize`, the package ships a **separate** relationship engine for
410
+ object-level sharing — the Drive case: *this document is shared with that user as `viewer`, with
411
+ that team as `editor`*. It is a distinct port (`RelationsDriver`), a distinct façade
412
+ (`RelationsManager`) and a distinct config (`defineRelationsConfig`); roles and relations never
413
+ answer each other's questions.
414
+
415
+ ```ts
416
+ const relations = defineRelationsConfig({
417
+ holderTypes: ['user', 'admin'],
418
+ objectTypes: [
419
+ { type: 'document', relations: [
420
+ { name: 'owner' },
421
+ { name: 'editor', includes: ['owner'] }, // includes, no `from` in v1
422
+ { name: 'viewer', includes: ['editor'] }, // editor ⊆ viewer
423
+ ] },
424
+ ],
425
+ database: { membersOf: true }, // membersOf is database-only (see below)
426
+ })
427
+
428
+ await rel.relate(user, 'viewer', { type: 'document', id }, tenant) // share with a user
429
+ await rel.relate({ object: team, relation: 'member' }, 'editor', doc, tenant) // share with a TEAM (userset)
430
+ await rel.check(user, 'viewer', doc, tenant) // one Check; editor⊆viewer resolves server-side
431
+ ```
432
+
433
+ `group` is a **built-in** object type (the userset carrier: `group#member`, nesting allowed), so
434
+ teams work without declaring anything. Every operation takes a **`partition: ScopeRef`** — the
435
+ tenant — and it is **mandatory**: a relation in tenant A never resolves in tenant B (`APP_SCOPE` is
436
+ the mono-tenant value). The partition lives in the object id (`document:<partitionKey>|<uuid>`), not
437
+ in the model.
438
+
439
+ **The model is shared with the catalog, and so is its byte budget.** In the `openfga` driver,
440
+ relations fuse into the same `facts` model and the same store, so a single `Check` still answers
441
+ each question. The price is one budget: the 262,144-byte model holds **both** your permissions and
442
+ your object types. Measured: with realistic permission names the ceiling is **~450 permissions**,
443
+ and each object type costs about **0.46 of a permission** (`group` ≈ 0.1). So a catalog near the
444
+ ceiling has room for ~20 object types, not unlimited; a small catalog has room to spare. The gate
445
+ watches the **fused** model — `defineRelationsConfig` that would push it over is 500
446
+ `E_AUTHZ_MODEL_TOO_LARGE` before anything is published (80 % warns), the same protection
447
+ `syncAuthzCatalog` already gives the permissions. A consumer that needs *many* object types **and**
448
+ is pinned to the permission ceiling is the documented case for a separate store; everyone else
449
+ shares.
450
+
451
+ **The boundary is enforced, not hoped for (the 🔴 the audit found, closed by construction).** In the
452
+ shared store a naive relations write could compose the id of a real `role_binding` and escalate to
453
+ `roles.authorize`. Two rules close it structurally: `defineRelationsConfig` **refuses** to declare a
454
+ reserved `facts` type or relation (`scope`/`role`/`role_binding`/`group`/`can_<P>`/`assignee`… → 422
455
+ `E_AUTHZ_RELATION_CONFIG`), and `relate`/`unrelate` **refuse** an object type or relation not
456
+ declared (422 `E_AUTHZ_RELATION_TYPE_UNKNOWN` / `E_AUTHZ_RELATION_UNKNOWN`) **before touching the
457
+ driver** — so the id of a `role_binding` is never composed by the relations driver, the collision
458
+ does not exist rather than being watched. This is F-05, and it is a **chokepoint**: every write path
459
+ funnels through it, and the published contract plants the exploit so a third-party relations driver
460
+ that does not enforce it **does not pass**. Because it lives in the manager, calling
461
+ `manager.driver()` (the platform escape hatch) skips it — as with every other barrier.
462
+
463
+ **`membersOf` is `database`-only.** `membersOf(group, 'member', partition)` returns the **transitive**
464
+ membership (through nested groups). Only the `database` driver has it (a recursive CTE); in `openfga`
465
+ it is 500 `E_AUTHZ_UNSUPPORTED` naming it — the transitive form is `ListUsers`, which truncates
466
+ without a reliable signal, and we never return a silent partial. `listSubjects` (direct facts,
467
+ invariant 7) works in both. `listObjects` in `openfga` signals `truncated: true` when the server's
468
+ `ListObjects` cap cuts the page, never a mute partial list.
469
+
470
+ **The config is persisted, and republishing never mutilates the model.** `saveRelationsConfig(spec)`
471
+ stores the relations config in `authz_relations_config` under the shared version gate (invariant 14).
472
+ Because the catalog and the relations config share one model lifecycle, both `syncAuthzCatalog` and a
473
+ config save republish the fused model — and they race for the `modelId`. `republishFusedModel` reads
474
+ **both** persisted halves (catalog permissions + relation types) every time, so the published model
475
+ is never "the model of one, the tuples of another"; the `modelId` is pinned with a bounded CAS, and
476
+ contention that will not yield is 409 `E_AUTHZ_WRITE_CONFLICT`, never a half model.
477
+
478
+ **Migrating tuples between drivers** is `node ace authz:relations:reconcile --to=<key>` — the relations
479
+ analog of `authz:reconcile`, idempotent, bidirectional and never silent (it reports written / deleted
480
+ / unchanged / extra). `--to`/`--from` are keys of `relations.drivers` in `config/authorization.ts`;
481
+ `--dry-run` is the read-only verifier and also flags **model drift** (an object type in the source the
482
+ destination does not declare); `--prune` deletes what the source no longer backs. It migrates **facts
483
+ only** — there is no tree or catalog in `relations/` — and works **per partition** (`--partition-type`
484
+ /`--partition-uuid`; default `app`).
485
+
486
+ **Not in 2.4 — relation expiry (R-15).** `authz_relations` is insert/delete-only; a relation tuple
487
+ has no `expiresAt`. Time-boxed shares are **deferred to 2.6** (an additive `expires_at` column plus
488
+ the `BEFORE UPDATE` trigger the `database` driver already carries). Also deferred: `includes` with
489
+ `from` (cross-object inheritance like `viewer from parent`), which would add a TTU between object
490
+ types and force re-measuring depth.
491
+
492
+ ## Errors
493
+
494
+ Every error the package raises carries `status` and `code`. A standard AdonisJS exception handler answers on its own; catch only when an endpoint needs a specific response.
495
+
496
+ | Code | Status | When |
497
+ |---|---|---|
498
+ | `E_AUTHZ_INVALID_IDENTITY` | 422 | malformed holder/scope, `{app, uuid}`, root sentinel outside `app` |
499
+ | `E_AUTHZ_INVALID_SLUG` | 422 | role/permission slug: grammar, length, reserved name or prefix, collision |
500
+ | `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) |
501
+ | `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` |
502
+ | `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) |
503
+ | `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) |
504
+ | `E_AUTHZ_ROLE_IMMUTABLE` | 422 | `updateScopedRole`/`deleteScopedRole` on a global role (2.2) |
505
+ | `E_AUTHZ_ROLE_LEVEL_ABOVE_OWNER` | 422 | `defineScopedRole`/`updateScopedRole` with a `scopeType` that is the level of an *ancestor* of the owner (or, with `scopes.descendantsOf` declared, a level that does not appear below it) (2.2) |
506
+ | `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) |
507
+ | `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) |
508
+ | `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` |
509
+ | `E_AUTHZ_UNKNOWN_SCOPE` | 422 | write on a scope the resolver does not know; unknown parent in `scopes.*` |
510
+ | `E_AUTHZ_NO_SCOPE_RESOLVER` | 422 | driver without `resolveChain` asked about a non-`app` scope |
511
+ | `E_AUTHZ_SCOPE_CYCLE` | 422 | `scopes.attached/moved` would close a cycle; `hierarchicalScopeResolver` met a cycle |
512
+ | `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) |
513
+ | `E_AUTHZ_WITHIN_REQUIRED` | 422 | `requireWithin` set and a write without `within` (2.1) |
514
+ | `E_AUTHZ_WITHIN_ROOT_FORBIDDEN` | 422 | `requireWithin: 'non-root'` and `within: APP_SCOPE` (2.1) |
515
+ | `E_AUTHZ_ACTOR_REQUIRED` | 422 | `requireActor: true` and a write without `actor` (2.1) |
516
+ | `E_AUTHZ_TOO_MANY_SCOPES` | 422 | `authorizedScopes`/`expandExcludedSubtrees` over `maxScopes`, or `descendantsOf` over `maxNodes` (`sqlDescendantsOf`: also a possible cycle) — never a partial list (2.1) |
517
+ | `E_AUTHZ_BACKEND_UNAVAILABLE` | 503 | facts backend or SQL catalog did not answer (both drivers, catalog sync/diff and the `authz_catalog_version` check included); the version row is missing or unreadable ("migración 2.0 no aplicada": fail-closed, never version 0); a per-check `error` in an OpenFGA `batchCheck` |
518
+ | `E_AUTHZ_BACKEND_TIMEOUT` | 503 | `timeoutMs` elapsed (subclass of the above) |
519
+ | `E_AUTHZ_FROZEN` | 503 | the engine's writes are frozen by a platform operation (`authz:reconcile`, or the cutover window of `authz:freeze`) — **durably, fleet-wide** (row `id = 2` of `authz_catalog_version`, 2.3): reads keep working and the error is **retryable** (`error.retryable === true`) — reissue the write when the window ends (the message says how it lifts) |
520
+ | `E_AUTHZ_FREEZE_HELD` | 423 | `freeze()` (or a second `authz:reconcile`) found a live freeze owned by someone else: two windows never interleave, and only the owner's token — or `authz:unfreeze` — lifts one. The message names the holder, the reason and the fence |
521
+ | `E_AUTHZ_RESOLVER_FAILED` | 503 | your `resolveChain`, `parentOf` or `descendantsOf` threw or answered a malformed scope; `descendantsOf` and `resolveChain` disagree in `authorizedScopes`; a subtree to exclude cannot be enumerated |
522
+ | `E_AUTHZ_WRITE_CONFLICT` | 409 | an `openfga` write kept clashing with another transaction over the same tuples (FGA answers `Aborted`/409, or 400 "cannot write a tuple which already exists"): the driver re-reads and re-applies, and only gives up after three rounds. The backend answered, so this is never a 503 — retry the write |
523
+ | `E_AUTHZ_CONFIG` | 500 | contradictory config (`holderTypes` not injective or a holder type not declared in it, `scopes.*` without resolver, `appAccess` without `permission`, `catalog` together with `catalogRevalidate`, an invalid `maxAgeMs`); `bumpAuthzCatalogVersion` called without the writing transaction's client |
524
+ | `E_AUTHZ_ROLE_IS_NOT_ACCESS` | 500 | `appAccess({ role })` |
525
+ | `E_AUTHZ_INTERNAL` | 500 | package invariant violated (empty scope set on a write, misaligned batch, a third-party `authorizeMany` answering the wrong shape, a `Read` continuation token that never advances or more than 10,000 pages, a corrupt `assignable_at`/`owner_scope_key` row) |
526
+ | `E_AUTHZ_PURGE_INCOMPLETE` | 500 | `purgeScope` could not prove zero |
527
+ | `E_AUTHZ_RECONCILE_TOO_LARGE` | 500 | the destination's dump does not fit the declared `--max-tuples` cap (default 1 000 000) and reconciling needs the whole snapshot to know what is left over. Nothing was written; raise the cap if your process can take it |
528
+ | `E_AUTHZ_MASS_RECONCILE_REFUSED` | 500 | `authz:reconcile --prune` would delete facts from the destination while the source has not contributed a single **usable** one — empty, or every fact it returned was skipped (expired, on scopes your tree no longer resolves): the signature of a wrong connection, of the wrong source, or of a blind resolver. Nothing was deleted; pass `--allow-mass-delete` if you really mean to empty it (`--dry-run` flags it instead of throwing). A source that is merely **stale** but still usable is covered by reading the facts from whoever owns them |
529
+ | `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 |
530
+ | `E_AUTHZ_TOO_MANY_LOCAL_ROLES` | 500 | more local roles than `maxLocalRoles` (10 000) in a `prune-orphans` pass; never a partial list |
531
+ | `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_*`) |
532
+ | `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 % |
533
+ | `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 |
534
+ | `E_AUTHZ_SCOPE_DRIFT_UNGUARDED` | 500 | the `openfga` driver was constructed without `scopes.outbox` and without `acceptScopeDriftRisk: true`. Thrown at construction: a rollback of your transaction would otherwise leave the store's tree ahead of yours, and that escalation is invisible from your database |
535
+ | `E_AUTHZ_SCOPE_TREE_DRIFT` | 500 | the materialized tree has more than one `parent` edge for the same scope: someone else writes to the store. A write never "fixes" it by guessing; `authz:reconcile` rebuilds the tree from yours and reports the scope |
536
+ | `E_AUTHZ_NO_DESCENDANTS_RESOLVER` | 500 | `authorizedScopes`/`expandExcludedSubtrees` without `scopes.descendantsOf` |
537
+ | `E_AUTHZ_VIEW_EXPIRED` | 500 | a `forRequest()` view used to read (`expandExcludedSubtrees` included) after its `maxAgeMs` (default 30 s, monotonic clock) |
538
+ | `E_AUTHZ_UNSUPPORTED_DIALECT` | 500 | `sqlDescendantsOf` / `sqlScopeEdges` on a dialect other than PostgreSQL / MySQL 8 / SQLite |
539
+ | `E_AUTHZ_SCOPE_TOO_DEEP` | 500 | `hierarchicalScopeResolver` over `maxDepth` (no truncated chain) |
540
+
541
+ ## Driver options
542
+
543
+ Both drivers take `resolveChain` and **`timeoutMs`** (default 5000): every SQL query the driver builds is given a knex timeout — the `DELETE`s inside `purgeScope`'s transaction included; only knex's own `BEGIN`/`COMMIT` carry none — every FGA call has a total deadline, and an elapsed deadline is 503 `E_AUTHZ_BACKEND_TIMEOUT`. A server that accepts the connection and never answers is released in under a second (*"authorize contra un servidor mudo ⇒ 503 E_AUTHZ_BACKEND_TIMEOUT en menos de 1 s"*). SQLite's synchronous driver cannot actually time out; what the suite pins there is that every query carries the deadline (*"toda consulta sale con el timeout configurado"*). A deadline releases the caller, it does not abort the request in flight: see `indeterminate` above.
544
+
545
+ Both also take **`catalogRevalidate`** (`'always'`, the default, or `{ everyMs }`) *or* **`catalog`** (a `CatalogCache` to share between drivers of the same process; its own `revalidate` is the policy) — the catalog memo described under [Performance](#performance). Passing both is 500 `E_AUTHZ_CONFIG` at construction: the driver's `catalogRevalidate` would be silently ignored otherwise.
546
+
547
+ Both take **`now`** (default `() => new Date()`): the wall clock every time-based *decision* uses — `expires_at > now()` in SQL, the `current_time` of every FGA check (one instant per operation: every check of a `batchCheck` carries the same `current_time`, and the two reads of `listScopes` filter with the same `now`), the client-side expiry filter of the enumerations and the three states of a re-grant. The audit stamps (`created_at`) are **not** decisions and use the system clock: with MySQL's `TIMESTAMP` an injected clock in 2040 made every write fail (*"… se escribe estando el reloj en 2040"*). Every driver of the package also implements `withClock(now)` on the port (a view bound to another clock, like `withChainResolver`), and the manager applies **`clock`** from the config to the driver it resolves — all `forRequest()` views share it; a config `clock` over a driver without `withClock` is 500 `E_AUTHZ_CONFIG`, never a clock silently ignored. It exists so that expiry is observable *without sleeping* (the contract fixes the exact instant: one millisecond before `expiresAt` grants, at `expiresAt` it does not — *"caducidad exacta con el reloj inyectado"*) and so that your own tests can freeze time; in production leave it alone and keep NTP running. It is not the monotonic clock of `forRequest({ maxAgeMs })`, which measures a window and must not move with NTP. Nothing else in `src/` reads the wall clock (a grep test pins it) — except the model trait `withAuthzScopes` (`whereRoles`/`wherePermissions`), which cannot see the manager: it decides "live" with the system clock unless you compose it with the same clock, `compose(BaseModel, withAuthzScopes({ clock }))` (*"withAuthzScopes({ clock }) decide la vigencia con ESE reloj"*). Its primary-key comparison is dialect-aware (on PostgreSQL a `uuid` primary key is cast to text against the `varchar` subquery: *"… con la clave primaria uuid nativa del modelo"*).
548
+
549
+ `openfga` additionally takes `holderTypes` (required, injective; a holder whose morph name is not in it is 500 `E_AUTHZ_CONFIG`), `modelId`, a `logger` (default `console`), **`retryParams`** (default `{ maxRetry: 0 }`, see `indeterminate` above) and **`consistency`**: `'higher_consistency'` (default) or `'minimize_latency'`. The default protects the "removing the deny restores" promise against a server started with `--check-query-cache-enabled`, where a fresh revoke or deny would keep granting for up to the cache TTL; `minimize_latency` is the explicit opt-out (*"todo check lleva context.current_time; toda llamada HIGHER_CONSISTENCY"*). `driver.diagnostics.unparseableBindings` counts store tuples the engine cannot interpret — binding ids it does not understand and malformed tuples alike; each one is logged, never skipped in silence.
550
+
551
+ **The `openfga` driver *is* the `facts` mode, and there is no other (2.3, breaking).** The scope tree is materialised in the store as one `scope:<child>#parent@scope:<parent>` edge per node, which is what the model needs to inherit downwards without asking your database. `authorization.scopes.attached/moved/detached` maintain those edges: `moved` is one `Read` plus one atomic `Write` carrying the delete of the old parent and the write of the new one, and `detached` removes the edge **after** `purgeScope` has proved the facts of that scope are gone. Finding more than one parent for a scope is 500 `E_AUTHZ_SCOPE_TREE_DRIFT`: the package writes one edge per node, so two means something else writes to your store, and it is reported rather than silently "fixed".
552
+
553
+ Until 2.2 there was a second mode, `hierarchy: 'resolver'`, in which the tree stayed in your database and the package expanded the chain into a `batchCheck` of N×M on every question. **The option is gone**; passing it is a TypeScript error and is ignored at runtime. What that mode did is described in the changelog, together with what it cost to keep.
554
+
555
+ It also takes **`outbox`** and **`acceptScopeDriftRisk`**, and one of the two is mandatory: without either, construction throws 500 `E_AUTHZ_SCOPE_DRIFT_UNGUARDED`. Pass the same `scopes.outbox` instance you put in the config (the driver never uses it — the manager is what enqueues; here it is the evidence for the gate). **Declaring it on the driver alone is not enough, and since 2.3 it is refused**: the manager reads `config.scopes.outbox`, so an outbox that only the driver knows about leaves the mitigation switched off. The manager therefore applies the same gate when it resolves a driver that declares `capabilities.hierarchyFacts` — the outbox (or `scopes.acceptScopeDriftRisk: true`) has to be in **the config**, which is where the enqueueing happens. Read [The tree outbox](#the-tree-outbox-and-the-relay-lag-you-are-accepting) before choosing: the reason for the gate is that a rollback of your transaction otherwise leaves an escalation nothing in your database can show you, and the reason `acceptScopeDriftRisk` exists is that a deployment that only moves the tree from the platform can knowingly accept it.
556
+
557
+ **What the driver actually promises, and what it does not (2.3).** The exact claim, and nothing more:
558
+
559
+ > In `facts` mode, `authorize` is a **single `Check`** against OpenFGA: it does not consult your tree (`resolveChain`) and it consults the local catalog only through an in-memory memo, invalidated by `syncAuthzCatalog`. `hasRole`, `listRoles`, `listRoleScopes`, `listSubjects` and `listScopes` **do** use `resolveChain`. `grant` and `deny` use it too, to validate that the scope exists.
560
+
561
+ So **"no SQL in the hot path" is not a claim this package makes**, and you should not repeat it: what is true is *no SQL per request in `authorize`*. Membership and enumeration still go through your tree — in the (c2) model there is no alternative, and it is measured, not assumed. What you do get is that `authorize` survives your application database being down, and that its latency is constant in tree depth and in number of roles.
562
+
563
+ Every driver **declares** this through `driver.capabilities`, and each declared value has a case in the contract suite — never a skip:
564
+
565
+ | Capability | `database` | `openfga` | What the judge fixes |
566
+ |---|---|---|---|
567
+ | `hierarchyFacts` | `false` | **`true`** | the tree is the backend's; the manager then requires the outbox (or your signature) |
568
+ | `singleCheckAuthorize` | `false` | **`true`** | `authorize` makes one backend call and zero `resolveChain` |
569
+ | `roleInheritanceNative` | `false` | `false` | the five membership reads **do** consult your tree (a spy counts the calls) |
570
+ | `listObjectsInherited` | `false` | `false` | a grant on the ancestor never appears in the descendant's `list*`, even though `authorize` says `true` there (invariant 7; `ListObjects` is never used — it truncates at the server's cap with no signal) |
571
+ | `purgeRole` | `true` | **`true`** | with (c2) a binding points at its role, so a role's bindings *can* be enumerated |
572
+ | `countRoleAssignments` | `true` | **`true`** | how many live facts a role has — what makes `stillGranting` true instead of a guess (2.3; same enumeration `purgeRole` needs) |
573
+ | `canonicalScopeReads` | `true` | **`false`** | whether a read canonicalises the caller's scope spelling against your tree before looking for facts (see below) |
574
+ | `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 |
575
+
576
+ **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.
577
+
578
+ > **(a) A resolver that is down no longer makes `authorize` throw — and it can no longer make it stop, either.** This is the property, and it is one-sided. While your tree is unavailable `authorize` and `authorizeMany` keep answering, and what they answer is `true` for everything that was granted; meanwhile `revoke`, `deny`, `removeDeny`, `purgeScope`, `hasRole` and every `list*` are 503 `E_AUTHZ_RESOLVER_FAILED`, because they all canonicalise against your tree. The mode is **grant-only until your database comes back**: it grants and there is no way to revoke. Plan your incident response around that — in `database`, everything stops together. Closing it would mean putting `resolveChain` back on `authorize`'s hot path, which is the whole point of this mode (*"con el resolutor del consumidor caído el modo es \*grant-only\*"*, *"authorizeMany con un scope cuyo árbol lanza: RESPONDE con el árbol del store"*).
579
+
580
+ > **(b) A uuid alias does not find its facts on the read path.** `authorize` composes `scope:<type>|<uuid>` from the caller's spelling without canonicalising it, so the same id written differently — without dashes, which PostgreSQL's `uuid` column and MySQL's `*_ci` collation fold into the same row — answers `false` where `database` answers `true`. It is fail-**closed**: it never evades a deny and never grants what was not granted, but it is not the same answer. **Pass scope uuids exactly as your table stores them.** The write path is *not* affected: `grant`, `revoke`, `removeDeny`, `purgeScope` and `scopes.detached` canonicalise in both modes, including after the row is gone (that half was fail-*open* until 2.3 and is fixed, not declared), and the judge pins it on both faces of the pair so this is not read as "spelling does not matter in `facts`" (*"un alias del uuid que el árbol funde con la fila canónica NO encuentra sus hechos … pero las ESCRITURAS sí canonizan"*).
581
+
582
+ **The anti-cycle checks are the package's, in both modes, and they are not optional.** Measured against OpenFGA v1.19: the server *accepts* an edge that closes a cycle, does not hang, answers in 2–7 ms, and from then on inheritance runs both ways — a grant on a descendant grants on its ancestor, and with the root inside the cycle it grants everywhere. Nothing is logged and there is no error to catch. That is why `child ≠ app`, "the parent exists" and `child ∉ ancestors(parent)` are checked before anything is written (422, no edge), and why you should not expect the backend to be a second line of defence.
583
+
584
+ ## Performance
585
+
586
+ Two optimisations landed in 2.1, both measured and both **without changing a single answer** (the contract suite is the proof: same cases, both drivers, before and after). Reproduce the numbers with `OPENFGA_TEST_URL=http://localhost:8101 node --import @poppinss/ts-exec scripts/bench_authorize.mjs` (chain of 3 scopes through your resolver, 5 roles per level, 20 permissions, N=200 after 30 warm-up calls, HTTP round-trip included; OpenFGA v1.19.0 on the same machine):
587
+
588
+ | `authorize` (`openfga`) | before 2.1 | 2.1 (lot A) | 2.1 (lot D, shared catalog version) | backend calls per question |
589
+ |---|---|---|---|---|
590
+ | granted by a root role (worst case: the whole chain) | p50 **4.33 ms** · p95 7.33 ms | p50 **2.03 ms** · p95 3.83 ms | p50 **2.35 ms · p95 3.70 ms** | 2 SQL + 2 `batchCheck` → **1 SQL (version check) + 1 `batchCheck`** |
591
+ | granted by nobody | p50 2.36 ms · p95 3.48 ms | p50 0.01 ms | p50 **0.05 ms** | 2 SQL + 1 `batchCheck` → **1 SQL + 0** |
592
+
593
+ (`database` on in-memory SQLite: 0.37 → 0.27 → 0.38 ms p50 for the granted case — the version check is one primary-key `SELECT` per question.)
594
+
595
+ **The catalog is memoised; facts and decisions never are — and the memo never decides with a catalog the database has already replaced.** Each driver loads `authz_permissions`, `authz_roles` and `authz_role_permissions` once, lazily, into an in-process `CatalogCache` (three queries, all with the driver's deadline; a load that fails is a 503 and caches nothing). Every question still reads its facts — assignments, denies, tuples — from the backend: a `grant`, `deny` or `revoke` is visible in the very next call (*"el memo nunca cachea hechos ni decisiones"*). What the memo answers is "which uuid is `docs:read`", "which roles of which level grant it", "which roles exist at this level" — and since those answers **do** feed decisions (`rolesGranting` in `openfga`, `effectivePermissions` in both drivers), the memo is only ever served after checking it is current:
596
+
597
+ - **A shared version in the database.** The migration ships `authz_catalog_version` (one row, `id = 1`). `syncAuthzCatalog` / `node ace authz:catalog:sync` increment it **as the last statement of the sync's transaction** — a sync that does not commit does not bump it. Before serving, each `CatalogCache` compares the version it loaded with that row (one primary-key `SELECT`, with the deadline, classified 503 like any other query; concurrent checks share one read) and reloads when the database is ahead. So a sync run by one worker, one container or a deploy job is seen by **every process on its next question** — no pub/sub, no restart, no TTL (*"el catálogo que decide es el de la base: un sync en otro proceso…"*, a contract case in both drivers, with two managers and two memos over the same database). If the version row cannot be read — the table is missing, **the row is missing or not a number** (a database without the 2.0 migration) — the question is 503 `E_AUTHZ_BACKEND_UNAVAILABLE` saying so: never version `0`, never an answer from a memo that might be stale (*"sin la fila de authz_catalog_version… 503"*).
598
+ - **`catalogRevalidate: 'always'`** (default) checks on every question. **`{ everyMs }`** checks at most once per window: it saves that `SELECT` at the price of a **bounded window in which another process's revocation is not yet seen** (a fail-open window you accept explicitly; a sync in the *same* process is still seen immediately). `{ everyMs: 30_000 }` is a reasonable trade for a read-heavy deployment whose catalog changes at deploy time. The window — like a view's `maxAgeMs` — is measured with a **monotonic clock** (`performance.now()`), so a wall clock stepped backwards by NTP or a snapshot restore neither stretches it nor revives an expired view (*"la ventana de { everyMs } se mide con reloj MONÓTONO"*).
599
+ - **Writing `authz_*` by hand** (a seeder, a data migration, a script) goes through **`withAuthzCatalogWrite(async (trx) => { … })`** — exported: it opens the transaction, runs your write with *that* client and bumps the version **as the last statement, inside**, so either both land or neither. Order matters and is enforced: `bumpAuthzCatalogVersion(trx)` requires the writing transaction's client (500 `E_AUTHZ_CONFIG` without it, or with the global `db`). A bump that commits *before* its write would make every other process reload the **old** rows tagged with the **new** version — and never revalidate again, a permanent fail-open (reproduced with two real processes; closed in 2.1). Until the write commits, the previous catalog stands, pinned as a negative case (*"un cambio en authz_* SIN subir la versión NO se ve"*). `withAuthzCatalogWrite` is the cross-process channel only: this process sees it on its next question under `'always'` and at the end of the window under `{ everyMs }` — call `invalidateAuthzCatalog()` after it if you use `everyMs` and need it at once (what `syncAuthzCatalog` does); `driver.catalog.invalidate()` reaches only that driver's memo (an invalidation that lands while a load is in flight is not lost).
600
+ - Two drivers in one process can share one memo: `new DatabaseAuthorizationDriver({ catalog })` and `new OpenFgaAuthorizationDriver({ catalog })` with the same `new CatalogCache({ revalidate, timeoutMs })` — and without `catalogRevalidate` on the drivers (500 `E_AUTHZ_CONFIG`: the shared memo's `revalidate` is the policy).
601
+
602
+ **One `batchCheck` per `authorize` in `openfga`.** The denies of the chain and the roles that grant the permission travel in the same request (the SDK splits at 50 checks and parallelises); the rule is unchanged and evaluated in this order: any per-check `error` ⇒ 503, any deny `allowed` ⇒ `false`, any role `allowed` ⇒ `true`. When no role of the catalog grants the permission anywhere in the chain, the answer is `false` without a request — the denies cannot change it. Each operation takes one snapshot of the catalog, so one version check per question.
603
+
604
+ **A per-request view memoises the scope tree, on reads only — and expires.** `authorization.forRequest({ maxAgeMs })` returns an `AuthorizationView`: same API as the manager, sharing its driver and hooks, whose reads (`authorize`, `hasRole`, `list*`, `authorizeMany`, `effectivePermissions`, `authorizedScopes`, `expandExcludedSubtrees`) resolve ancestors through `memoizeAncestors(config.scopes.resolveChain)` — one call to your resolver per scope for the life of the view — while its writes (`grant`, `revoke`, `deny`, `removeDeny`, `scopes.*`) and `isWithin` resolve fresh: a stale read expires by itself, a grant on a chain that moved is written forever. The memo holds ancestors, never decisions: a deny written between two `authorize` of the same view changes the second answer (*"forRequest(): las lecturas de una vista resuelven cada scope una vez; las escrituras, en fresco"*). Because a view kept beyond its request would serve the old chain forever (after a `scopes.moved`, a cross-tenant answer), **a view stops reading after `maxAgeMs` (default 30 000 ms, monotonic clock)**: any later read is 500 `E_AUTHZ_VIEW_EXPIRED`, loud on purpose. `forRequest({ maxAgeMs: 0 })` is the explicit "no limit". No `AsyncLocalStorage`: the view is an explicit object with the lifetime you give it. The pattern in AdonisJS is a middleware:
605
+
606
+ ```ts
607
+ // app/middleware/authz_middleware.ts
608
+ import authorization from '@jantstack/adonis-authz/services/main'
609
+
610
+ export default class AuthzMiddleware {
611
+ async handle(ctx: HttpContext, next: NextFn) {
612
+ ctx.authz = authorization.forRequest() // declare `authz` on HttpContext in your types
613
+ return next()
614
+ }
615
+ }
616
+
617
+ // a controller or a policy
618
+ if (!(await ctx.authz.authorize(user, 'docs:write', unit))) return ctx.response.forbidden()
619
+ ```
620
+
621
+ `memoizeAncestors(resolver)` is exported for the cases where you hold a driver directly; keep it on the read path. Without `scopes.resolveChain` in the config, or with a third-party driver that does not implement the optional `withChainResolver`, the view reads through the driver as-is — correct, just not memoised.
98
622
 
99
623
  ## Custom drivers, judged by the same suite
100
624
 
101
625
  Implement `AuthorizationDriver`, register its factory, and prove it:
102
626
 
103
627
  ```ts
104
- import { runAuthorizationDriverContract } from '@jantstack/adonis-authz/testing'
628
+ import { runAuthorizationDriverContract, resolveChainFrom } from '@jantstack/adonis-authz/testing'
105
629
 
106
630
  runAuthorizationDriverContract({
107
631
  name: 'my-driver',
108
- makeDriver: () => new MyDriver(),
632
+ level: '2.2', // '2.1' = up to Phase 2, '2.0' = up to Phase 1; omit for the 1.x cases only
633
+ capabilities: { // what the driver declares; each one has its own cases
634
+ hierarchyFacts: false,
635
+ transactions: false,
636
+ truncationSignal: false,
637
+ singleCheckAuthorize: false,
638
+ injectableClock: false,
639
+ exhaustiveLists: true, // false ⇒ also pass `limits: { listMaxResults }`
640
+ listDenies: true, // the port's optional listDenies; judged at '2.1' and above (declare false below it)
641
+ purgeRole: true, // purgeRole really purges (2.2); false ⇒ it must say so with 500 E_AUTHZ_UNSUPPORTED
642
+ countRoleAssignments: true, // the port's optional countRoleAssignments (2.3); false ⇒ pruneOrphanRoles reports `undefined`, never `false`
643
+ canonicalScopeReads: true, // reads canonicalise the caller's scope spelling against your tree (2.3); false ⇒ an alias finds no facts (fail-closed)
644
+ enumerateFacts: false, // the port's optional enumerateFacts (2.3): can this driver be the SOURCE of authz:reconcile?
645
+ },
646
+ // The suite builds the scope tree case by case; hand it to your driver.
647
+ makeDriver: (tree) => new MyDriver({ resolveChain: resolveChainFrom(tree) }), // tree.chainOf(scope) = the canonical chain
648
+ // Optional: another instance over the SAME facts backend with its own catalog memo (what a second
649
+ // process would be). Default: a prototype view of the driver with a fresh `CatalogCache` when it exposes `catalog`.
650
+ makeTwin: (driver, tree) => new MyDriver({ resolveChain: resolveChainFrom(tree), sameBackendAs: driver }),
651
+ seedCatalog: (catalog) => syncAuthzCatalog(catalog),
652
+ cleanup: () => wipeEverything(),
653
+ })
654
+ ```
655
+
656
+ 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.
657
+
658
+ 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).
659
+
660
+ Since 2.3 the port also has **`countRoleAssignments(roleUuids)`** (optional, and **breaking if you wrote a 2.2 driver**): how many live facts each role has, across every scope, answered **by position** like `authorizeMany`. A live fact is an assignment that has not expired, judged with the driver's own clock; a role with none — or one the backend does not know — is `0`; a malformed uuid is 422 `E_AUTHZ_INVALID_IDENTITY`. It is what `pruneOrphanRoles` (`authz:catalog:prune-orphans`) asks before saying whether an orphan role is still granting, and it is a question for the *driver* because the facts are the driver's: until 2.3 the sweep counted rows of `authz_assignments` — the `database` driver's table — so with `openfga` it always answered "this role grants nothing", right before a destructive delete. It is deliberately conservative: it counts facts, it does not re-resolve each fact's scope. A driver that does not implement it declares **`countRoleAssignments: false`** and the sweep reports `assignments` and `stillGranting` as **`undefined`** — never `false` — and the command lists those roles apart; the pair at `'2.2'` judges either the counting (*"countRoleAssignments(uuids) cuenta los hechos VIGENTES"*) or that "I don't know" never degrades to "it does not grant" (*"sin countRoleAssignments: el puerto NO lo trae"*).
661
+
662
+ Since 2.3 there is also **`enumerateFacts({ limit, after })`** (optional): being the *source* of `authz:reconcile`. It hands the driver's live facts back page by page — at most `limit` per page, in a total and stable order, with an opaque cursor that must advance (repeating it is a 500, never a loop) — and it **filters nothing**: an already-expired assignment arrives with its `expiresAt` so the destination can count it in `skipped` with its reason, and what the source cannot express as a port fact (leftovers of an older version, a holder type your config does not declare) comes back in the page's own `skipped`, never dropped in silence. The pair at `'2.2'` judges either the enumeration (*"enumerateFacts: los hechos del driver salen paginados, con su caducidad SIN filtrar"*) or, with **`enumerateFacts: false`**, that `authz:reconcile` **says so** — 500 `E_AUTHZ_UNSUPPORTED` naming the method — instead of reading zero facts and then emptying the destination with `--prune`. The `database` driver declares `false` on purpose: its facts are `authz_assignments` / `authz_denies`, the published schema, and the destination reads them straight from there.
663
+
664
+ ### The migration contract: `runMigrationContract` (2.3)
665
+
666
+ Passing the driver contract means two drivers answer the same. It does **not** mean that moving your data from one to the other keeps the answers — and the panel that designed this phase was explicit that migration *"is possible with declared losses and a window"*, not lossless. `runMigrationContract` is the executable form of that sentence, published next to the rest of the suite because a third-party driver has to be able to run it:
667
+
668
+ ```ts
669
+ import { runMigrationContract } from '@jantstack/adonis-authz/testing'
670
+
671
+ runMigrationContract({
672
+ name: 'mine ⇄ theirs',
673
+ a: 'mine',
674
+ b: 'theirs',
109
675
  seedCatalog: (catalog) => syncAuthzCatalog(catalog),
676
+ setup: async (tree) => ({ reconcile: (o) => manager.reconcile(o), drivers: { mine, theirs } }),
110
677
  cleanup: () => wipeEverything(),
678
+ expectedLosses: [
679
+ { reason: 'expired', why: 'an assignment whose expiry has passed grants nothing, so it is not migrated' },
680
+ ],
111
681
  })
112
682
  ```
113
683
 
114
- A driver that passes honors the semantics above, so call-sites never change when you swap backends.
684
+ It seeds a **fixed fixture** (7 nodes, 6 holders, 4 roles, 14 grants, 5 expiries, 6 denies, written through the driver's own API), asks **448 identical questions** on the source, migrates, and asks the same 448 on the destination — 168 `authorize`, 168 `hasRole`, 42 `listRoles`, 24 `listScopes`, 28 `listSubjects` and 18 `listRoleScopes`. It runs **three combinations**: there, back, and there-and-back-again with `--prune`. And it cuts both ways on the losses:
685
+
686
+ - **an answer that changes and that no declared loss explains fails the contract** — this is the whole point;
687
+ - **every reason the pass counts in `report.skipped` that you did not declare fails it too**;
688
+ - and a loss you declared that **never happens** fails as well: a loss that does not occur is a false line in your README.
689
+
690
+ Those last two cross what the driver *declares about itself*, so they close the **careless** omissions, not the silent ones: a driver that drops a fact without counting it never populates `skipped`. Two more checks close that, and they do not ask the driver anything:
691
+
692
+ - **the census (2.3.1).** The contract looks for the **20 seeded facts one by one in the destination** — 14 assignments through `listRoles` and 6 denies through `listDenies`, the port's direct read path (invariant 7) — and a fact that is missing with no declared **and counted** reason fails, whether or not any of the 448 answers moved. It was measured that without it a deny relocated to another scope of the same chain passed all three combinations green: `authorize` cannot tell it apart, and no other question asks about denies. `listDenies` is optional in the port: a driver that does not implement it leaves its denies observed by `authorize` alone, and the verdict says so in `censusLimits` instead of keeping quiet about it;
693
+ - **the expiry cross (2.3.1).** None of the 448 returns an `expiresAt` and the contract advances no clock, so losing the expiry of a *live* grant — turning a temporary permission into a permanent one, which is fail-**open** — used to be invisible. It is crossed through the only path in the port that hands an expiry back: `grant` with `expiresAt` **omitted** returns `previousExpiresAt` (invariant 10). It runs on the destination and, in the `a→b→a` combination, on the intermediate one too.
694
+
695
+ For the package's own pair (`database` ⇄ `openfga` in `facts` mode, against a real server, with the tree in SQL) the declared list is **one entry**: `expired`. The other three losses the design panel had listed were measured and are not losses of the migration: sub-second precision in MySQL is closed by the published schema (`expires_at` is `DATETIME(3)` and the codec writes and reads milliseconds), facts on phantom scopes are `unknown-scope` and have their own case in both directions, and the `*_ci` collation is a **read-path** divergence (the `canonicalScopeReads` pair), not something migrating loses — with one exception that *is* counted: two facts of the source that fold into a single destination row are reported as `folded-scope`, and the row keeps the expiry that lasts longest.
115
696
 
116
- The package runs that suite on itself: `npm test` judges the `database` driver over in-memory SQLite no host application, no Postgres and `OPENFGA_TEST_URL=… npm test` adds the `openfga` driver to the same verdict. CI runs both before anything ships.
697
+ Two things worth knowing before you refactor a driver, because the suite cannot tell you: the owner rule in `authorize` hangs on **one case per harness** (the other `'2.2'` cases go through read paths that filter elsewhere), and "binding ids are parsed from the right" is currently a mutant-equivalent decision (see the OpenFGA notes). Both stop being free the day a `scopeKey` grows more parts.
698
+
699
+ What passing means: **for everything the suite covers, both drivers answer the same** — including the malformed-input edges that used to diverge (`{app, uuid}`, a uuid with `#`), which are contract cases now. What is *not* identical between drivers is operational and listed below: latency, failure modes, the two-call expiry refresh in OpenFGA. Switching drivers is a facts migration (`authz:reconcile`), not a change at the call-sites the manager exposes.
700
+
701
+ The package runs that suite on itself: `npm test` judges the `database` driver over in-memory SQLite — no host application — and `OPENFGA_TEST_URL=… npm test` adds the `openfga` driver to the same verdict. `npm run test:pg` and `npm run test:mysql` run the **same** suite over PostgreSQL 18 and MySQL 8.4 (`TEST_PG_URL` / `TEST_MYSQL_URL`; each run creates a database with a random suffix and drops it), `npm run test:sqlite-file` over a SQLite file with a pool of 2–5 connections (real connection-level concurrency: a case pins `pool.max ≥ 2` and a read that answers while another connection holds an open transaction — *"una lectura responde mientras OTRA conexión mantiene una transacción abierta"*; the two-concurrent-grants case itself is a JavaScript check-then-insert race and dies with a single connection too). On PostgreSQL and MySQL the judge additionally runs with the scope tree in a real SQL table (`hierarchicalScopeResolver` + `sqlDescendantsOf` over `demo_scopes`), which is where the uuid-alias bypass lived. CI runs all of it: SQLite in memory and as a file, PostgreSQL and MySQL, each with and without OpenFGA, plus a second OpenFGA server with `ListObjects`/`ListUsers` capped at 3; a case also checks that the child process the suite spawns leaves no database behind. Two capability pairs are exercised on both drivers: `listDenies` and **`injectableClock`** (`true` ⇒ the judge fixes the instant through `withClock(now)` and observes exact expiry, renewal and "expires right now" without waiting; `false` ⇒ it can only observe the three states of `expiresAt` in real time, with a 1.5 s wait).
117
702
 
118
703
  ## OpenFGA tooling
119
704
 
120
705
  ```bash
121
- node ace openfga:provision # creates a store + writes the model from your holderTypes
122
- node ace openfga:import --dry-run # copies assignments/denies from the database driver
123
- node ace openfga:import
706
+ node ace openfga:provision # creates a store + writes the model for your holderTypes AND your permissions
707
+ node ace openfga:provision --store-id 01H… # writes a new model version into an existing store
708
+ node ace authz:catalog:sync # …and projects the catalog into it (role→permission tuples + the root marker)
124
709
  ```
125
710
 
126
- The import **copies**, it doesn't move: your `authz_*` tables stay intact, so rolling back is setting `AUTHZ_DRIVER=database` again.
711
+ **`openfga:provision` writes the `facts` model, and that model carries your permissions** (four relations each: `<P>`, `can_<P>`, `denied_<P>`, `permits_<P>`), so the command resolves the `catalogs` you declared in `config/authorization.ts` — they are plain functions, no database needed. Without them there is nothing to publish and the command exits non-zero rather than leaving a store that denies everything. Adding or removing a permission changes the model: write a new version with `--store-id` and re-run `authz:catalog:sync`, which rewrites the derived projection (and re-places the root marker) through the driver.
127
712
 
128
- ### Operational notes for this driver
713
+ **`openfga:import` is gone (2.3, breaking).** It copied `authz_*` into the tuple shapes of the old `resolver` mode (`role_binding#assignee`, `deny_binding#denied`) — shapes the current model does not even declare, so a store filled by it would be rejected by the server and grant nothing. Its replacement is **`authz:reconcile`**, below. `E_AUTHZ_STORE_NOT_EMPTY` went with the command.
129
714
 
130
- Choosing this driver adds a **second runtime dependency to every authorization check**: the catalog is read from your database and the facts from FGA. If FGA is unreachable, the engine throws `AuthorizationBackendError` — it does not quietly return `false`.
715
+ ### Migrating and verifying: `authz:reconcile` (2.3)
131
716
 
132
- **You don't write `try/catch` for this.** The error carries `status = 503`, so a standard AdonisJS exception handler answers on its own, and with the right code: the application isn't broken (500), a dependency is unavailable. Catch it only if a particular endpoint needs a particular response.
717
+ ```bash
718
+ node ace authz:reconcile --to=openfga --dry-run # the VERIFIER: reads everything, writes nothing, exit 1 on drift
719
+ node ace authz:reconcile --to=openfga --from=database # migrate: rebuild the store from authz_* and your tree
720
+ node ace authz:reconcile --to=openfga --from=database --prune # …and delete the facts that source no longer backs
721
+ node ace authz:reconcile --to=openfga # already serving from it? then it is the MAINTENANCE pass (see below)
722
+ node ace authz:reconcile --to=database # the way back: rebuild authz_* from the store's facts
723
+ node ace authz:reconcile --to=database --prune # …and delete the rows the store no longer backs
724
+ ```
133
725
 
134
- Why not swallow it and return `false`? Denying silently during an outage strips every user of their permissions with nothing to indicate why, and sends you hunting for a misconfigured role that doesn't exist. Access is denied either way; only the diagnosis differs.
726
+ This is the **only** migration and verification primitive of the package, and the reason phase 3b exists: *every driver is complete on its own (facts + tree + catalog projection), and moving between them is one idempotent command*. `--to` names a key of `drivers` in `config/authorization.ts`, **not** the active driver: migrating is filling the destination while the engine keeps serving from the other one.
135
727
 
136
- And why the engine's own error type instead of the driver's? Because a raw `FgaError` would force any call-site that wants to tell "backend down" apart from anything else to `import { FgaError } from '@openfga/sdk'` coupling it to the very backend this package abstracts, and breaking that code the day you switch drivers.
728
+ **The way back (`--to=database`, 2.3) migrates the facts and only the facts.** The **tree is not migrated** in that direction — the `database` driver reads it from *your* tables on every question, and they are its source of truth, so copying it somewhere would be inventing a second copy and a drift that does not exist today. The **catalog is not migrated** either: it is local property always, it already lives in `authz_*`, and no driver is ever its source. That is why the `root marker`, `catalog projection` and `tree` phases report **zero** in that direction: there is nothing derived to rebuild, and the zero says so. The tree is still *used* — to decide which facts are migratable (`unknown-scope`) and under which canonical identity each row is written (invariant 17).
137
729
 
138
- So three outcomes stay distinguishable:
730
+ **Where the facts come from: the `enumerateFacts` port.** A driver that can be the **source** of a migration implements `enumerateFacts({ limit, after })` and hands its live facts back page by page, **unfiltered** (an already-expired assignment must arrive, with its expiry, so the destination can count it in `skipped` — filtering it at the source would make it vanish with no trace) and with an opaque cursor that must advance. The `openfga` driver implements it — its facts are tuples in the store and only it knows how to turn them back into `(holder, role, scope)`. The `database` driver **declares `enumerateFacts: false` on purpose**: its facts *are* `authz_assignments` / `authz_denies`, this package's published schema, and the destination reads them straight from there. Both faces have cases in the published contract suite.
139
731
 
140
- | Situation | Result |
141
- |---|---|
142
- | No permission | `false` |
143
- | Invalid question (unknown permission or role) | `Exception`, 422 |
144
- | Couldn't ask (backend unreachable) | `AuthorizationBackendError`, 503 |
732
+ **Which driver is the source is decided out loud, never guessed.** With exactly two registered drivers the source is the one that is not `--to`. With more than one candidate the command stops (500 `E_AUTHZ_CONFIG`) and asks for `--from=<driver>` — where the facts come from decides what ends up written. With no candidate at all it stops with 500 `E_AUTHZ_UNSUPPORTED` naming `enumerateFacts`, rather than reading zero facts and then emptying the destination with `--prune`.
733
+
734
+ **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`):
735
+
736
+ - **`--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 and deletes no fact at all**. 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.)
737
+ - **`--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.
738
+ - **Otherwise** (the destination is not the active driver): the migration of always, facts read from `authz_*`.
739
+
740
+ **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.
741
+
742
+ What one pass does, in order: the **root marker** (`scope:app#rooted`, without which the whole store denies), the **catalog projection** (`role:<uuid>#permits_<P>`, read with the very same function `syncAuthzCatalog` uses), the **tree** from `enumerateEdges`, and the **facts** from `authz_assignments` / `authz_denies` — each assignment as its `assignee` tuple plus the two (c2) edges, each deny as `scope:<key>#denied_<P>`. It is **idempotent** (a second pass writes zero — the word the requirement used, and it has its case), **resumable** (the source is read in batches of 100 with a cursor over the primary key; repeating a pass converges) and **never silent**: the report carries `{ written, updated, unchanged, extra, deleted, skipped{reason} }` per phase, plus each row that did not migrate with its reason (`unknown-scope`, `unknown-role`, `unknown-permission`, `unknown-holder-type`, `expired`, `role-not-visible`, `cycle`, …).
743
+
744
+ **The source is read in one consistent snapshot — in the direction where the source is `authz_*` (2.3).** The two sweeps (`authz_assignments`, then `authz_denies`) used to run on the global connection, one after the other, so the gap between them — the time it takes to walk the first table in batches of 100 — let *composite* business operations slip through. An offboarding is `revoke` + `removeDeny`: landing in that gap, the pass kept one half of each and wrote **the role without its deny** into the destination, granting a permission that **neither the previous nor the following state granted**, while the report said `written=13 extra=0 skipped={} clean=true`. That is not a lost permission, it is a fabricated one — an escalation, with nothing in the report to distrust. Both sweeps now run inside **one repeatable-read transaction**, so the worst outcome of the window is *the consistent state of `t0`* — recoverable drift that the next pass repairs — instead of a state that never existed. What each engine guarantees is not the same and is stated rather than assumed: PostgreSQL takes the snapshot with `BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ`; MySQL/InnoDB is sent `SET TRANSACTION ISOLATION LEVEL REPEATABLE READ` explicitly (it is InnoDB's default, but a server setting is not a promise of this package) and fixes the consistent read on the first query; SQLite takes no isolation level at all (knex warns and ignores it) because a read transaction is already a snapshot there, so the package does not send it one.
745
+
746
+ **What that does NOT cover, said out loud.** It covers the direction whose source is `authz_*` (`--to=openfga`). When the source of truth of the facts is the **store** (`enumerateFacts`: `--to=database`, and the maintenance pass), the `Read` pages are **not** a consistent snapshot either, and there is no repeatable read to ask for: the same half-a-transaction composition is possible there. That direction is *not* closed in this version. The instrumentation that could name it — `readChanges({ startTime })` as a window witness, reporting the tuples that moved during the pass — is **not implemented**; the honest statement today is that the direction whose source is a store has an open window, and a second pass after the cutover is the only thing that closes it.
145
747
 
146
- The `database` driver has no equivalent failure: authorization is available whenever your database is, which you need anyway.
748
+ **What it deletes on its own, and what needs `--prune`.** The root marker, the catalog projection and the tree are mirrors of local data nobody else writes, so whatever is left over goes — that is what repairs a scope that ended up with **two parents** in the store (the drift `scopes.moved` refuses to guess about) and what removes edges your tree no longer backs. **Facts are only deleted with `--prune`**: the facts of a scope that no longer resolves (the ones that would grant again if the scope came back with the same uuid) and anything an older version of the store left behind. One deliberate exception: a `scope#binding` edge that your source backs but whose visibility rule says *no* (invariant 18) is deleted **without** `--prune` and counted in `drift.roleVisibility` — leaving it is fail-open, and it is exactly the write `scopes.moved` / `projectCatalogRole` lose when the relay does not get there.
749
+
750
+ **`--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.
751
+
752
+ **Cycles are reported, not just edge differences.** OpenFGA accepts a `parent` cycle and evaluates it, which makes inheritance bidirectional (a grant in a descendant would grant in its ancestor), so **no edge of a cycle is written**: those nodes stop reaching the root and therefore deny, and the cycle is named in the report. The **relay window** is reported too (how many tree changes are queued and unrelayed — the window in which the backend decides with the old tree) and so are the **parked** entries, which are not a window but permanent divergence.
753
+
754
+ **While the pass that WRITES runs, writes are frozen — durably, fleet-wide (2.3)** (never under `--dry-run`, see above). The freeze lives in **row `id = 2` of `authz_catalog_version`** — the same cross-process signal every write already depends on — so it reaches **every process that shares the `authz_*` tables**, not just the process that froze: every engine write (`grant`/`revoke`/`deny`/`removeDeny`, the three `scopes.*`, the delegation API, `pruneOrphanRoles --force` and the relay) answers 503 `E_AUTHZ_FROZEN` (**retryable**) and **reads keep working** — `authorize` is never frozen, not for a millisecond. Until 2.3 `freeze()` was a per-process boolean and this paragraph promised otherwise; that promise was false in every deployment with more than one worker, and it is gone. What the mechanism can honestly promise is this: **another process gets a retryable 503 while the window is live** — never "no write enters the window" (a write that had already passed its barrier when the freeze landed still lands; there is no atomicity between a SQL row and an external store, so that phrasing would not even be falsifiable). The cost is measured: one extra primary-key `SELECT` per **write** (+0.14 ms p50 on PostgreSQL, +0.11 on MySQL), zero per `authorize`. And the window is *minutes*, not seconds, at the declared cap (0.136 ms per fact ⇒ ≈ 136 s at `--max-tuples` 1,000,000).
755
+
756
+ The freeze has an **owner and a lease**. `manager.freeze(reason?, { leaseMs?, kind? })` takes the row (a live freeze held by someone else is 423 `E_AUTHZ_FREEZE_HELD`, never two owners) and returns a **token** (`{ fence, holder }`); `unfreeze(token)` only lifts the freeze whose token matches, so a nested or stale window can never lift somebody else's barrier — a `reconcile` running *inside* a frozen window runs inside it and leaves it standing. The lease (default 15 s) is renewed conditionally every `leaseMs / 3` while the freezing process lives; if that process dies (`SIGKILL`, OOM, pod eviction) **the fleet resumes writing on its own within `leaseMs`** — nobody cleans a row by hand. And the guarantee is **demonstrated, not assumed**: the writing pass reports `frozen: { durable, lapsed, leaseMs, fence }`, and `lapsed: true` — the lease was lost mid-pass (an event-loop stall longer than the lease, the database down, someone lifting the window) — means the pass is **not certified** and the command exits non-zero.
757
+
758
+ **What the freeze does NOT freeze, by name**: `syncAuthzCatalog` (a free function that never sees the manager — a sync during the window changes what the catalog grants), `manager.driver()` (the documented way out of *all* the manager's barriers), and your own scope-tree tables (your SQL never passes through this package). While the window is open the relay cannot drain either, so the queued-tree window *grows* with the freeze — the report counts it. Two more honest boundaries: the guarantee holds only between processes that **share the `authz_*` tables** (that is invariant 14's deployment shape; a process pointed at another database sees nothing), and it is a guarantee of **this package's manager** — a third-party driver inherits the wording but the published contract suite never checks it (`MigrationContractHarness` has no manager, no second writer and no second process).
759
+
760
+ ### The cutover window: `authz:freeze` / `authz:unfreeze` (2.3)
761
+
762
+ The dangerous interval is not the pass: it is **[end of the last pass → the last worker reloads `config.default`]** — minutes or hours, decided by a human, during which every write still goes to the driver that is about to stop being the source of truth. Freezing only the pass would close the small window and leave the big one open. So the window belongs to the **operator**:
763
+
764
+ ```bash
765
+ node ace authz:freeze --reason="cutover to openfga" # open the window: fleet-wide 503 on writes
766
+ node ace authz:reconcile --to=openfga --from=database # the pass RECOGNISES the operator window
767
+ # … switch config.default, redeploy, verify …
768
+ node ace authz:unfreeze # close the window
769
+ ```
770
+
771
+ `authz:reconcile` treats a live **operator** freeze as its own context: it runs inside it, does not take a second freeze, does not renew it and does not lift it when the pass ends — and its report's `frozen.fence` names the window it ran in (with `lapsed: true` if the window did not survive the whole pass). A live freeze of *another* pass is 423 instead: two migrations never interleave. **The operator window does not expire by default** — a cutover has no known duration in advance (window length and outage tolerance are independent magnitudes, the same argument that killed a fixed TTL), and a window that expires mid-cutover silently hands back exactly the fail-open this mechanism exists to close. The declared price: forget `authz:unfreeze` and the fleet cannot write until someone runs it — a *loud* incident (every 503 names the reason and the command that lifts it), not an invisible loss. `--lease-ms` is the opposite opt-in: the window lifts itself after that many milliseconds with nobody renewing it (the command has exited), and *its* declared price is that a cutover slower than the lease resumes the writers silently, mid-cutover. `authz:unfreeze` lifts an operator window; it refuses to lift a live pass's freeze unless you pass `--fence=<n>` (the explicit human decision for a process that died without a lease — and a stale fence lifts nothing).
772
+
773
+ **`--prune` refuses to run blind.** If it would delete facts while the source has not contributed a single **usable** one, it stops with 500 `E_AUTHZ_MASS_RECONCILE_REFUSED` before writing anything: that is the signature of a wrong connection, of the wrong source, or of a resolver that no longer resolves any of the source's scopes. Until 2.3 the guard looked at the *raw* count — incremented before each skip — so a source whose facts were **all discarded** (every one expired, or on scopes your tree no longer knows) disarmed it and `--prune` emptied the destination with a green report; now a fact only counts if it actually backs something the pass would keep, and the error says how many were read and discarded. `--allow-mass-delete` is the human decision; `--dry-run` never throws, it flags it. A source that is merely **stale** but still partially usable is not this guard's job: what protects you there is the rule above (the facts are read from whoever owns them).
774
+
775
+ **The declared cap on the dump (2.3).** Reconciling means comparing against the **whole** state of the destination — without that snapshot there is no way to know what is left over, and "what is left over" is half the job. The *source* is read in batches with a cursor; the destination snapshot is not, and it is held in memory. That is declared rather than hidden: above `--max-tuples` (default 1 000 000) the pass refuses with 500 `E_AUTHZ_RECONCILE_TOO_LARGE` **before writing anything**, naming the cap. There is no partitioned migration in this version, and "resumable" means *idempotent and repeatable* — a pass that was interrupted is resumed by running it again; the second one converges and what was already written comes back as `unchanged` — not "a cursor persisted between runs".
776
+
777
+ **Migration is not lossless, and the losses are declared.** Run `runMigrationContract` (below) against your own pair: it seeds a fixed fixture, asks **448 identical questions** on both ends and fails if a single answer changes without a declared loss, if the pass counted a skip you did not declare, or if a seeded fact is simply **missing from the destination** with nothing declaring it — that last one is the census, and it does not depend on any of the 448 moving.
778
+
779
+ **The way out of a store written by the previous version.** After 2.3 "a store written by the previous version is not read by this one": the old importer wrote assignments without the two (c2) edges and denies as `deny_binding` objects, a type the current model does not declare. `authz:reconcile --to=openfga --from=database --prune` rebuilds the store from `authz_*` — the source of truth *of that migration*, named out loud because the store you are rebuilding may already be the live one (see [whoever owns the facts](#migrating-and-verifying-authzreconcile-23)) — and clears the leftovers — a tuple whose type the model no longer declares can still be read and deleted (measured against the server). That store grants again after one pass.
780
+
781
+ ### Operational notes for this driver
782
+
783
+ Choosing it adds a **second runtime dependency to every authorization check**: the catalog is read from your database (once per process, then from the memo — see [Performance](#performance)) and the facts from FGA. If FGA is unreachable, the engine throws `AuthorizationBackendError` (503) — it does not quietly return `false`. Denying silently during an outage strips every user of their permissions with nothing to indicate why. Note that the `database` driver is **not** exempt from the 503 outcome: its catalog and facts live in SQL, and a database that does not answer is classified the same way (*"la base local caída es un 503, no un error crudo"*). What `database` avoids is the *second* dependency.
147
784
 
148
785
  Three more properties worth knowing before putting it in front of production traffic — none of them can grant access that wasn't granted, all fail towards *denied*:
149
786
 
150
- - **Changing an expiry is not atomic.** FGA rejects deleting and writing the same tuple key in one transaction, so *replacing* an assignment's expiry is a delete followed by a write. Between the two, `authorize()` answers `false`, and a crash in that window loses the assignment; re-running the grant restores it (writes are idempotent). The driver reads the current tuple first, so this only happens when the expiry actually changes a first grant is a plain write, and re-granting something identical (a seeder run again) touches nothing at all. That read is a shortcut, not a precondition: if it fails, or if a concurrent writer wins the race, the grant is still written.
151
- - **Expiry follows the app server's clock.** The `not_expired` condition is evaluated against a `current_time` your process sends with each check, so a skewed clock makes assignments expire early or late. Keep NTP running — the same requirement your JWTs already have.
787
+ - **Binding ids carry the catalog uuid, never the slug (2.2).** A role assignment is `role_binding:<scopeKey>|<roleUuid>#assignee` with `<scopeKey>` = `app` or `<type>|<uuid>` (a deny is not an object of its own: it is the relation `scope:<scopeKey>#denied_<P>`); ids are parsed **from the right** (last component = the uuid, everything before it = the scope key) and contain no `~` escape. With today's grammar a `scopeKey` always has one or two parts, so parsing from the right and counting parts agree on every id the grammar admits: the rule is a *structural* decision with no test that can tell the two apart, and it only gets one when a `scopeKey` grows more parts (3b, `facts` mode). Do not "simplify" it to counting on the strength of a green suite. A store written by 1.x/2.0–2.1 (slug in the id) is **not read** by 2.2: those tuples grant nothing, are no membership, are counted in `diagnostics.unparseableBindings`, and `authz:reconcile --to=openfga` reports them as leftovers (`--prune` clears them). There is no *import* command (*"un store con ids 1.x (slug en el id) no es leído por 2.2"*).
788
+ - **Enumerations read tuples, not computed relations.** `listSubjects`, `listRoles`, `listRoleScopes` and `listScopes` use the paginated `Read` API (100 tuples per page, until the continuation token is empty; a token that repeats or more than 10,000 pages is 500 `E_AUTHZ_INTERNAL`, never a hang) and filter expiry client-side. That is what makes them complete regardless of the server's `ListObjects`/`ListUsers` caps. The price: `Read` returns *written* tuples only. With the model this package generates (`assignee` and `denied_<P>` are direct relations) that is exactly the same set; if you extend the model with relations derived over `role_binding`, this driver's enumerations will not see them. Membership reads also consult the catalog (`authz_roles` for that level) from the in-process memo, so no query in steady state — and `listRoleScopes` asks your resolver once per scope it returns, like `listScopes` (a `forRequest()` view memoises those calls).
789
+ - **Changing an expiry is not atomic.** FGA rejects deleting and writing the same tuple key in one transaction, so *replacing* an expiry is a delete followed by a write. Between the two, `authorize()` answers `false`, and a crash in that window loses the assignment; re-running the grant restores it. The driver reads the current tuple first, so this only happens when the expiry actually changes — a first grant is a plain write, an identical re-grant touches nothing (*"quitar la expiración es explícito (expiresAt: null); omitirla no la toca ni escribe nada"*). A grant *without* `expiresAt` whose read fails is a 503 whose message carries the recipe: preserving a live expiry requires knowing it; pass `{ expiresAt: null }` if you mean "permanent". A first write that collides with a concurrent one (FGA's "tuple already exists") re-reads and re-grants on top of it; any other write failure is propagated classified, with the SDK error as `cause` — never treated as a race (*"un write que falla con 400 no es una carrera"*).
790
+ - **Expiry follows the app server's clock.** The `not_expired` condition is evaluated against a `current_time` your process sends with each check, and enumerations filter with the same clock. Keep NTP running.
791
+ - **There is no distributed transaction with your database, and the catalog that *decides* is the projection in the store.** A `grant` validates the role against the local catalog and then writes the tuple. Delete that role from `authz_*` **by hand** afterwards and the two drivers stop agreeing, which is worth knowing before you write a migration script: membership (`hasRole`, `listRoles`, `listRoleScopes`, `listSubjects`) filters through the local catalog in both drivers and fails closed from the first instant, but `authorize` is decided by the store, where the permission→role mapping is the projection (`role:<uuid>#permits_<P>`) that your delete did not touch — **so it keeps granting**. Whoever writes `authz_*` by hand owes it a `driver.projectCatalogRole(uuid)`, exactly as it already owes a catalog-version bump; `syncAuthzCatalog` and the delegation API do it for you (*"en `facts` el catálogo que decide es la proyección del store, no la fila que acabas de borrar"*). `purgeScope`, meanwhile, cannot reach bindings of roles that are no longer in the catalog (it reads by exact object, built from the catalog; `Read` cannot enumerate by id prefix without a `user`). Reconciling both is the job of `authz:reconcile --to=openfga`.
152
792
 
153
- - **There is no distributed transaction with your database.** A `grant` validates the role against the local catalog and then writes the tuple to FGA. Delete that role from the catalog afterwards and the tuple is orphaned — but `authorize()` finds no permission→role mapping for it and denies, so the inconsistency fails closed. `openfga:import` is likewise not atomic; it is idempotent, so a run that dies half-way is fixed by running it again.
793
+ #### The model ceiling is bytes, not permissions
154
794
 
155
- All of these are consequences of the facts living in another system, and none of them apply to the `database` driver.
795
+ The `facts` model publishes **four relations per permission**, so a big catalog can outgrow OpenFGA's authorization-model limit (262,144 bytes by default, `OPENFGA_MAX_AUTHORIZATION_MODEL_SIZE_IN_BYTES`). `syncAuthzCatalog` checks it **before writing anything** — 500 `E_AUTHZ_MODEL_TOO_LARGE`, with a warning past 80 % — and the check is exact: the package measures the **protobuf** size, which is what the server measures, and it matches the number the server reports byte for byte (four catalog shapes, verified against a real server).
796
+
797
+ What is *not* a property of the model is **how many permissions those bytes are**. It depends on three things: how many holder types you declare, **how long their names are in the model**, and how long your permission slugs are. Measured, and pinned by a case:
798
+
799
+ | catalog | permissions that fit |
800
+ |---|---|
801
+ | 1 holder type, slugs `p0`…`pN` | **800** |
802
+ | 3 holder types (`user`/`admin`/`integration`), slugs `p0`…`pN` | **691** |
803
+ | 1 holder type, slugs `docs:readN` | **576** |
804
+ | 3 holder types, slugs `recursoN:accion` (**realistic**) | **447** |
805
+ | 3 holder types, slugs of 40 characters | **272** |
806
+
807
+ Versions up to 2.3 published "**≈691 permissions**" without saying that it was measured on a catalog whose permissions are named `p0`, `p1`, `p2`… With permission names anybody would actually write, the ceiling is around **450** — 35 % lower. And the same three holder types with shorter names (`bot` instead of `integration`) give 721, so "three holder types" does not pin the figure either. Nothing here can grant access: the byte gate is exact and fires before writing. Take the table as the shape of the curve and let the 80 % warning tell you where *your* catalog is.
808
+
809
+ #### The scope-chain depth ceiling (facts)
810
+
811
+ The `facts` model answers `authorize` in a single `Check` that walks the scope chain to the root
812
+ (`can_<P>` unions two tuple-to-userset rewrites and subtracts the deny), and OpenFGA bounds how deep
813
+ a `Check` resolves (`--resolve-node-limit`, 25 by default). Measured against OpenFGA v1.19 with 500
814
+ resolutions per side, the `facts` model resolves reliably to **22 `parent` hops** and no further: at
815
+ 23 the same question answers *almost* always and fails between 4 % and 26 % of the time (the node
816
+ budget is consumed non-deterministically resolving the union), at 24 it always fails. So the driver
817
+ declares **22** — `FACTS_MAX_RESOLVE_DEPTH`, the depth that resolves *every* time, not the first that
818
+ fails. Past the ceiling the server returns 400 ("resolution required too many rewrite rules") and the
819
+ package propagates it as **503, never a `false`** (invariant 5): fail-closed, but a chain that deep is
820
+ legal for the `database` driver, which has no such ceiling — the same tree is fine in one driver and a
821
+ 503 in the other, and it is a DoS within reach of whoever can nest sub-scopes. Raise
822
+ `OPENFGA_RESOLVE_NODE_LIMIT` on the server if your tree is deeper.
823
+
824
+ ### Operational notes for the SQL engines
825
+
826
+ The published migration (`stubs/migration.stub`) carries three decisions that were **observed** by running the suite on PostgreSQL and MySQL, not guessed — each one was a red test first — plus, since 2.2, `authz_roles.owner_scope_key` (`varchar(80)`, byte-wise like the other identity columns, `DEFAULT 'global'`, in the role unique index) and `authz_permissions.assignable_at` (see [Scoped roles](#scoped-roles-22)). The suite also **executes** the migration on a scratch database of each engine and compares what the engine reports for every column (type, length, precision, nullability, collation) with the schema the tests run on (*"el esquema que CONSTRUYE el stub y el espejo del harness son el mismo"*).
827
+
828
+ - **Identity columns are `varchar(64)`, not `uuid`.** `holder_uuid` and `scope_uuid` hold whatever your grammar-valid id is (`[a-z0-9._-]`, ≤ 36 chars): `user-42`, a ULID, a UUID. PostgreSQL's `uuid` type rejected anything else with `invalid input syntax for type uuid` (a 503 on `grant`). The suite pins that non-UUID ids work in every engine (*"la identidad es una cadena validada por la gramática, no un UUID del motor"*).
829
+ - **Identity columns and slugs are compared byte-wise.** They carry `collate 'utf8mb4_bin'` in the migration (`holder_type`, `holder_uuid`, `scope_type`, `scope_uuid` in assignments and denies; `slug` and `scope_type` in the catalog tables); knex only compiles it for MySQL, where the default collation (`utf8mb4_0900_ai_ci`) merged `abc` and `ABC` into one row — a grant to one authorised the other and the unique index treated them as duplicates. PostgreSQL and SQLite already compare `=` byte-wise. If you copy the migration into an existing MySQL schema, alter those columns' collation too. Your own **scope tree table** is outside this promise — that is why the chain resolver returns the canonical row and why upper-case uuids are rejected (see [The scope tree](#the-scope-tree)).
830
+ - **`expires_at` is `DATETIME(3)`.** knex's `timestamp` is `TIMESTAMP(0)` on MySQL: it *rounds* to the second (an expiry 600 ms away was stored 1 s away and kept granting past its instant) and cannot hold dates after 2038-01-19. Expiry is millisecond-exact in every engine and `2040-01-01` is a valid expiry (*"la caducidad guarda milisegundos y fechas más allá de 2038"*). PostgreSQL stores it as `timestamptz(3)`.
831
+
832
+ 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).
833
+
834
+ **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.
835
+
836
+ **`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"*).
837
+
838
+ ## Upgrading from 1.x to 2.x
839
+
840
+ 2.x is a breaking release with **no compatibility flags** (the [CHANGELOG summary](./CHANGELOG.md)
841
+ groups every breaking change by risk); this section is the whole upgrade path.
842
+
843
+ **The schema jump.** 1.x had `authz_permissions`, `authz_roles`, `authz_role_permissions`,
844
+ `authz_assignments` and `authz_denies`, with `uuid` identity columns, `timestamp` for `expires_at`,
845
+ the default collation, a `(slug, scope_type)` unique on roles, and no version row. 2.x adds and
846
+ changes:
847
+
848
+ - **`authz_catalog_version`** — the cross-process catalog version (row `id = 1`) and the durable,
849
+ fleet-wide freeze (row `id = 2`). Without a readable version row every write is 503 "migration 2.0
850
+ not applied" (invariant 14), so both rows are seeded at version 0.
851
+ - **`authz_roles.owner_scope_key`** (`varchar(80)`, `DEFAULT 'global'`) plus the new
852
+ `(slug, scope_type, owner_scope_key)` unique — roles are global or local to an owner scope (2.2). A
853
+ 1.x role stays **global** and the next `authz:catalog:sync` recognises it as the same role (same
854
+ uuid), without duplicating it.
855
+ - **`authz_permissions.assignable_at`** (`varchar(500)`, nullable) — the levels a permission may be
856
+ composed at (2.2).
857
+ - **`authz_relations` and `authz_relations_config`** — the ReBAC tables of 2.4. A fresh install gets
858
+ them from the published forward migration (`node ace configure` publishes all eight tables); the
859
+ ALTER recipe below does **not** create them, because they are new tables, not a transformation of
860
+ 1.x ones.
861
+ - Identity columns become **`varchar(64)` `collate utf8mb4_bin`** (not `uuid`), so a non-UUID id
862
+ (`user-42`, a ULID) is valid and case is compared byte-wise; `expires_at` becomes **`DATETIME(3)`**
863
+ (millisecond-exact, valid past 2038). Each was a red test first — see [Operational notes for the SQL
864
+ engines](#operational-notes-for-the-sql-engines).
865
+ - **The scope-tree outbox** (`authz_scope_outbox`) is **opt-in** and not part of this recipe: `node
866
+ ace configure` offers to publish its migration, or copy `stubs/scopes_outbox_migration.stub`
867
+ yourself (see [the tree outbox](#the-tree-outbox-and-the-relay-lag-you-are-accepting)).
868
+
869
+ **There is no id-migration command, and 2.x does not read a 1.x OpenFGA store.** A store written by
870
+ 1.x/2.0–2.1 carried the role **slug** in the binding id, under the old `resolver`-mode tuple shapes;
871
+ 2.2+ carries the role **uuid** and the `facts` model does not even declare those shapes, so those
872
+ tuples grant nothing and are no membership. The way across is **`authz:reconcile --to=openfga
873
+ --from=database --prune`**, which rebuilds the store from `authz_*` and your tree and clears the
874
+ leftovers in one pass — there is no `openfga:import` (removed in 2.3). See [Migrating and
875
+ verifying](#migrating-and-verifying-authzreconcile-23).
876
+
877
+ **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.
878
+
879
+ ```sql
880
+ -- PostgreSQL: upgrading a 1.x schema to 2.x
881
+ ALTER TABLE authz_assignments
882
+ ALTER COLUMN holder_uuid TYPE varchar(64),
883
+ ALTER COLUMN scope_uuid TYPE varchar(64),
884
+ ALTER COLUMN expires_at TYPE timestamptz(3);
885
+ ALTER TABLE authz_denies
886
+ ALTER COLUMN holder_uuid TYPE varchar(64),
887
+ ALTER COLUMN scope_uuid TYPE varchar(64);
888
+ CREATE TABLE authz_catalog_version (
889
+ id integer NOT NULL PRIMARY KEY,
890
+ version bigint NOT NULL DEFAULT 0,
891
+ updated_at timestamptz NOT NULL,
892
+ freeze_reason varchar(255),
893
+ freeze_holder varchar(120),
894
+ freeze_until_ms bigint,
895
+ freeze_fence bigint NOT NULL DEFAULT 0
896
+ );
897
+ INSERT INTO authz_catalog_version (id, version, updated_at) VALUES (1, 0, now());
898
+ INSERT INTO authz_catalog_version (id, version, updated_at) VALUES (2, 0, now());
899
+ ALTER TABLE authz_roles ADD COLUMN owner_scope_key varchar(80) NOT NULL DEFAULT 'global';
900
+ ALTER TABLE authz_roles DROP CONSTRAINT authz_roles_slug_scope_uq;
901
+ ALTER TABLE authz_roles ADD CONSTRAINT authz_roles_slug_scope_owner_uq UNIQUE (slug, scope_type, owner_scope_key);
902
+ CREATE INDEX authz_roles_owner_idx ON authz_roles (owner_scope_key);
903
+ ALTER TABLE authz_permissions ADD COLUMN assignable_at varchar(500);
904
+ ```
905
+
906
+ ```sql
907
+ -- MySQL: upgrading a 1.x schema to 2.x
908
+ ALTER TABLE authz_roles
909
+ MODIFY slug varchar(100) COLLATE utf8mb4_bin NOT NULL,
910
+ MODIFY scope_type varchar(20) COLLATE utf8mb4_bin NOT NULL;
911
+ ALTER TABLE authz_permissions
912
+ MODIFY slug varchar(100) COLLATE utf8mb4_bin NOT NULL;
913
+ ALTER TABLE authz_assignments
914
+ MODIFY holder_type varchar(50) COLLATE utf8mb4_bin NOT NULL,
915
+ MODIFY holder_uuid varchar(64) COLLATE utf8mb4_bin NOT NULL,
916
+ MODIFY scope_type varchar(20) COLLATE utf8mb4_bin NOT NULL,
917
+ MODIFY scope_uuid varchar(64) COLLATE utf8mb4_bin NOT NULL,
918
+ MODIFY expires_at datetime(3) NULL;
919
+ ALTER TABLE authz_denies
920
+ MODIFY holder_type varchar(50) COLLATE utf8mb4_bin NOT NULL,
921
+ MODIFY holder_uuid varchar(64) COLLATE utf8mb4_bin NOT NULL,
922
+ MODIFY scope_type varchar(20) COLLATE utf8mb4_bin NOT NULL,
923
+ MODIFY scope_uuid varchar(64) COLLATE utf8mb4_bin NOT NULL;
924
+ CREATE TABLE authz_catalog_version (
925
+ id int NOT NULL PRIMARY KEY,
926
+ version bigint NOT NULL DEFAULT 0,
927
+ updated_at timestamp NOT NULL,
928
+ freeze_reason varchar(255) NULL,
929
+ freeze_holder varchar(120) NULL,
930
+ freeze_until_ms bigint NULL,
931
+ freeze_fence bigint NOT NULL DEFAULT 0
932
+ );
933
+ INSERT INTO authz_catalog_version (id, version, updated_at) VALUES (1, 0, CURRENT_TIMESTAMP);
934
+ INSERT INTO authz_catalog_version (id, version, updated_at) VALUES (2, 0, CURRENT_TIMESTAMP);
935
+ ALTER TABLE authz_roles
936
+ ADD COLUMN owner_scope_key varchar(80) COLLATE utf8mb4_bin NOT NULL DEFAULT 'global',
937
+ DROP INDEX authz_roles_slug_scope_uq,
938
+ ADD UNIQUE INDEX authz_roles_slug_scope_owner_uq (slug, scope_type, owner_scope_key),
939
+ ADD INDEX authz_roles_owner_idx (owner_scope_key);
940
+ ALTER TABLE authz_permissions ADD COLUMN assignable_at varchar(500) NULL;
941
+ ```
156
942
 
157
943
  ## Compatibility
158
944
 
@@ -160,8 +946,8 @@ All of these are consequences of the facts living in another system, and none of
160
946
  |---|---|
161
947
  | Node | ≥ 20.6 |
162
948
  | AdonisJS | ^7 (peer) · Lucid ^22 (peer) |
163
- | OpenFGA SDK | ^0.9 (optional peer, only for that driver) |
164
- | Databases | PostgreSQL, MySQL, SQLite |
949
+ | OpenFGA SDK | ^0.9 (optional peer, only for that driver); server verified against `v1.19.0` |
950
+ | Databases | The full contract suite (`database` driver, and `openfga` with the catalog in SQL) runs on every engine in CI: **SQLite** (in memory, and as a file with a pool of 2–5), **PostgreSQL 18** and **MySQL 8.4**. See [Operational notes for the SQL engines](#operational-notes-for-the-sql-engines) for the three schema decisions those runs forced. |
165
951
  | Module format | ESM only |
166
952
 
167
953
  ## Scope and maintenance