@jantstack/adonis-authz 2.0.0-alpha.1 → 2.4.0-alpha.10

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 (224) hide show
  1. package/README.md +934 -40
  2. package/build/commands/authz_catalog_diff.d.ts.map +1 -1
  3. package/build/commands/authz_catalog_diff.js +1 -2
  4. package/build/commands/authz_catalog_diff.js.map +1 -1
  5. package/build/commands/authz_catalog_prune_orphans.d.ts +78 -0
  6. package/build/commands/authz_catalog_prune_orphans.d.ts.map +1 -0
  7. package/build/commands/authz_catalog_prune_orphans.js +135 -0
  8. package/build/commands/authz_catalog_prune_orphans.js.map +1 -0
  9. package/build/commands/authz_catalog_sync.d.ts +17 -0
  10. package/build/commands/authz_catalog_sync.d.ts.map +1 -1
  11. package/build/commands/authz_catalog_sync.js +27 -4
  12. package/build/commands/authz_catalog_sync.js.map +1 -1
  13. package/build/commands/authz_freeze.d.ts +44 -0
  14. package/build/commands/authz_freeze.d.ts.map +1 -0
  15. package/build/commands/authz_freeze.js +94 -0
  16. package/build/commands/authz_freeze.js.map +1 -0
  17. package/build/commands/authz_reconcile.d.ts +111 -0
  18. package/build/commands/authz_reconcile.d.ts.map +1 -0
  19. package/build/commands/authz_reconcile.js +417 -0
  20. package/build/commands/authz_reconcile.js.map +1 -0
  21. package/build/commands/authz_relations_reconcile.d.ts +144 -0
  22. package/build/commands/authz_relations_reconcile.d.ts.map +1 -0
  23. package/build/commands/authz_relations_reconcile.js +438 -0
  24. package/build/commands/authz_relations_reconcile.js.map +1 -0
  25. package/build/commands/authz_scopes_relay.d.ts +97 -0
  26. package/build/commands/authz_scopes_relay.d.ts.map +1 -0
  27. package/build/commands/authz_scopes_relay.js +344 -0
  28. package/build/commands/authz_scopes_relay.js.map +1 -0
  29. package/build/commands/authz_scopes_requeue.d.ts +80 -0
  30. package/build/commands/authz_scopes_requeue.d.ts.map +1 -0
  31. package/build/commands/authz_scopes_requeue.js +230 -0
  32. package/build/commands/authz_scopes_requeue.js.map +1 -0
  33. package/build/commands/authz_unfreeze.d.ts +45 -0
  34. package/build/commands/authz_unfreeze.d.ts.map +1 -0
  35. package/build/commands/authz_unfreeze.js +157 -0
  36. package/build/commands/authz_unfreeze.js.map +1 -0
  37. package/build/commands/main.d.ts +16 -8
  38. package/build/commands/main.d.ts.map +1 -1
  39. package/build/commands/main.js +59 -6
  40. package/build/commands/main.js.map +1 -1
  41. package/build/commands/openfga_provision.d.ts +92 -4
  42. package/build/commands/openfga_provision.d.ts.map +1 -1
  43. package/build/commands/openfga_provision.js +155 -7
  44. package/build/commands/openfga_provision.js.map +1 -1
  45. package/build/configure.d.ts +11 -0
  46. package/build/configure.d.ts.map +1 -1
  47. package/build/configure.js +37 -1
  48. package/build/configure.js.map +1 -1
  49. package/build/index.d.ts +50 -11
  50. package/build/index.d.ts.map +1 -1
  51. package/build/index.js +49 -7
  52. package/build/index.js.map +1 -1
  53. package/build/providers/authz_provider.d.ts +31 -2
  54. package/build/providers/authz_provider.d.ts.map +1 -1
  55. package/build/providers/authz_provider.js +65 -2
  56. package/build/providers/authz_provider.js.map +1 -1
  57. package/build/services/relations.d.ts +14 -0
  58. package/build/services/relations.d.ts.map +1 -0
  59. package/build/services/relations.js +17 -0
  60. package/build/services/relations.js.map +1 -0
  61. package/build/src/{catalog.d.ts → catalog/catalog.d.ts} +41 -2
  62. package/build/src/catalog/catalog.d.ts.map +1 -0
  63. package/build/src/{catalog.js → catalog/catalog.js} +120 -14
  64. package/build/src/catalog/catalog.js.map +1 -0
  65. package/build/src/{catalog_cache.d.ts → catalog/catalog_cache.d.ts} +46 -22
  66. package/build/src/catalog/catalog_cache.d.ts.map +1 -0
  67. package/build/src/{catalog_cache.js → catalog/catalog_cache.js} +53 -43
  68. package/build/src/catalog/catalog_cache.js.map +1 -0
  69. package/build/src/define_config.d.ts +206 -3
  70. package/build/src/define_config.d.ts.map +1 -1
  71. package/build/src/define_config.js +11 -0
  72. package/build/src/define_config.js.map +1 -1
  73. package/build/src/drivers/database_driver.d.ts +128 -7
  74. package/build/src/drivers/database_driver.d.ts.map +1 -1
  75. package/build/src/drivers/database_driver.js +510 -24
  76. package/build/src/drivers/database_driver.js.map +1 -1
  77. package/build/src/drivers/database_relations_driver.d.ts +140 -0
  78. package/build/src/drivers/database_relations_driver.d.ts.map +1 -0
  79. package/build/src/drivers/database_relations_driver.js +748 -0
  80. package/build/src/drivers/database_relations_driver.js.map +1 -0
  81. package/build/src/drivers/openfga_driver.d.ts +777 -122
  82. package/build/src/drivers/openfga_driver.d.ts.map +1 -1
  83. package/build/src/drivers/openfga_driver.js +2228 -477
  84. package/build/src/drivers/openfga_driver.js.map +1 -1
  85. package/build/src/drivers/openfga_facts.d.ts +384 -0
  86. package/build/src/drivers/openfga_facts.d.ts.map +1 -0
  87. package/build/src/drivers/openfga_facts.js +836 -0
  88. package/build/src/drivers/openfga_facts.js.map +1 -0
  89. package/build/src/drivers/openfga_relations_driver.d.ts +186 -0
  90. package/build/src/drivers/openfga_relations_driver.d.ts.map +1 -0
  91. package/build/src/drivers/openfga_relations_driver.js +724 -0
  92. package/build/src/drivers/openfga_relations_driver.js.map +1 -0
  93. package/build/src/errors.d.ts +430 -6
  94. package/build/src/errors.d.ts.map +1 -1
  95. package/build/src/errors.js +453 -6
  96. package/build/src/errors.js.map +1 -1
  97. package/build/src/freeze.d.ts +200 -0
  98. package/build/src/freeze.d.ts.map +1 -0
  99. package/build/src/freeze.js +306 -0
  100. package/build/src/freeze.js.map +1 -0
  101. package/build/src/http/app_access_middleware.d.ts.map +1 -0
  102. package/build/src/http/app_access_middleware.js.map +1 -0
  103. package/build/src/http/resource_access_middleware.d.ts +105 -0
  104. package/build/src/http/resource_access_middleware.d.ts.map +1 -0
  105. package/build/src/http/resource_access_middleware.js +81 -0
  106. package/build/src/http/resource_access_middleware.js.map +1 -0
  107. package/build/src/identity.d.ts +74 -1
  108. package/build/src/identity.d.ts.map +1 -1
  109. package/build/src/identity.js +100 -2
  110. package/build/src/identity.js.map +1 -1
  111. package/build/src/manager.d.ts +549 -6
  112. package/build/src/manager.d.ts.map +1 -1
  113. package/build/src/manager.js +2243 -193
  114. package/build/src/manager.js.map +1 -1
  115. package/build/src/models/authz_assignment.d.ts +7 -7
  116. package/build/src/models/authz_assignment.d.ts.map +1 -1
  117. package/build/src/models/authz_deny.d.ts +7 -7
  118. package/build/src/models/authz_deny.d.ts.map +1 -1
  119. package/build/src/models/authz_permission.d.ts +7 -7
  120. package/build/src/models/authz_permission.d.ts.map +1 -1
  121. package/build/src/models/authz_role.d.ts +7 -7
  122. package/build/src/models/authz_role.d.ts.map +1 -1
  123. package/build/src/models/authz_role_permission.d.ts +7 -7
  124. package/build/src/models/authz_role_permission.d.ts.map +1 -1
  125. package/build/src/openfga.d.ts +18 -2
  126. package/build/src/openfga.d.ts.map +1 -1
  127. package/build/src/openfga.js +15 -1
  128. package/build/src/openfga.js.map +1 -1
  129. package/build/src/reconcile.d.ts +37 -0
  130. package/build/src/reconcile.d.ts.map +1 -0
  131. package/build/src/reconcile.js +69 -0
  132. package/build/src/reconcile.js.map +1 -0
  133. package/build/src/relation_partition_trigger.d.ts +8 -0
  134. package/build/src/relation_partition_trigger.d.ts.map +1 -0
  135. package/build/src/relation_partition_trigger.js +93 -0
  136. package/build/src/relation_partition_trigger.js.map +1 -0
  137. package/build/src/relations/define_relations_config.d.ts +109 -0
  138. package/build/src/relations/define_relations_config.d.ts.map +1 -0
  139. package/build/src/relations/define_relations_config.js +211 -0
  140. package/build/src/relations/define_relations_config.js.map +1 -0
  141. package/build/src/relations/manager.d.ts +138 -0
  142. package/build/src/relations/manager.d.ts.map +1 -0
  143. package/build/src/relations/manager.js +494 -0
  144. package/build/src/relations/manager.js.map +1 -0
  145. package/build/src/relations/reconcile.d.ts +137 -0
  146. package/build/src/relations/reconcile.d.ts.map +1 -0
  147. package/build/src/relations/reconcile.js +294 -0
  148. package/build/src/relations/reconcile.js.map +1 -0
  149. package/build/src/relations_config_store.d.ts +22 -0
  150. package/build/src/relations_config_store.d.ts.map +1 -0
  151. package/build/src/relations_config_store.js +74 -0
  152. package/build/src/relations_config_store.js.map +1 -0
  153. package/build/src/scope_outbox.d.ts +123 -0
  154. package/build/src/scope_outbox.d.ts.map +1 -0
  155. package/build/src/scope_outbox.js +532 -0
  156. package/build/src/scope_outbox.js.map +1 -0
  157. package/build/src/{drivers → shared}/backend_guard.d.ts +42 -0
  158. package/build/src/shared/backend_guard.d.ts.map +1 -0
  159. package/build/src/{drivers → shared}/backend_guard.js +78 -1
  160. package/build/src/shared/backend_guard.js.map +1 -0
  161. package/build/src/{drivers → shared}/sql_expiry.d.ts +2 -0
  162. package/build/src/shared/sql_expiry.d.ts.map +1 -0
  163. package/build/src/{drivers → shared}/sql_expiry.js +4 -0
  164. package/build/src/shared/sql_expiry.js.map +1 -0
  165. package/build/src/shared/transaction_guard.d.ts +53 -0
  166. package/build/src/shared/transaction_guard.d.ts.map +1 -0
  167. package/build/src/shared/transaction_guard.js +74 -0
  168. package/build/src/shared/transaction_guard.js.map +1 -0
  169. package/build/src/sql_descendants.d.ts +55 -1
  170. package/build/src/sql_descendants.d.ts.map +1 -1
  171. package/build/src/sql_descendants.js +75 -1
  172. package/build/src/sql_descendants.js.map +1 -1
  173. package/build/src/testing/contract.d.ts +129 -4
  174. package/build/src/testing/contract.d.ts.map +1 -1
  175. package/build/src/testing/contract.js +915 -177
  176. package/build/src/testing/contract.js.map +1 -1
  177. package/build/src/testing/main.d.ts +10 -2
  178. package/build/src/testing/main.d.ts.map +1 -1
  179. package/build/src/testing/main.js +5 -1
  180. package/build/src/testing/main.js.map +1 -1
  181. package/build/src/testing/migration_contract.d.ts +293 -0
  182. package/build/src/testing/migration_contract.d.ts.map +1 -0
  183. package/build/src/testing/migration_contract.js +589 -0
  184. package/build/src/testing/migration_contract.js.map +1 -0
  185. package/build/src/testing/relations_contract.d.ts +73 -0
  186. package/build/src/testing/relations_contract.d.ts.map +1 -0
  187. package/build/src/testing/relations_contract.js +1238 -0
  188. package/build/src/testing/relations_contract.js.map +1 -0
  189. package/build/src/testing/relations_reconcile_contract.d.ts +24 -0
  190. package/build/src/testing/relations_reconcile_contract.d.ts.map +1 -0
  191. package/build/src/testing/relations_reconcile_contract.js +220 -0
  192. package/build/src/testing/relations_reconcile_contract.js.map +1 -0
  193. package/build/src/testing/scope_outbox_contract.d.ts +98 -0
  194. package/build/src/testing/scope_outbox_contract.d.ts.map +1 -0
  195. package/build/src/testing/scope_outbox_contract.js +677 -0
  196. package/build/src/testing/scope_outbox_contract.js.map +1 -0
  197. package/build/src/traits/authz_scopes.js +1 -1
  198. package/build/src/traits/authz_scopes.js.map +1 -1
  199. package/build/src/traits/has_uuid.d.ts +8 -8
  200. package/build/src/traits/has_uuid.d.ts.map +1 -1
  201. package/build/src/types.d.ts +1537 -84
  202. package/build/src/types.d.ts.map +1 -1
  203. package/build/src/types.js +65 -0
  204. package/build/src/types.js.map +1 -1
  205. package/build/stubs/config/authorization.stub +149 -5
  206. package/build/stubs/migration.stub +136 -0
  207. package/build/stubs/scopes_outbox_migration.stub +57 -0
  208. package/package.json +4 -2
  209. package/build/commands/openfga_import.d.ts +0 -34
  210. package/build/commands/openfga_import.d.ts.map +0 -1
  211. package/build/commands/openfga_import.js +0 -97
  212. package/build/commands/openfga_import.js.map +0 -1
  213. package/build/src/catalog.d.ts.map +0 -1
  214. package/build/src/catalog.js.map +0 -1
  215. package/build/src/catalog_cache.d.ts.map +0 -1
  216. package/build/src/catalog_cache.js.map +0 -1
  217. package/build/src/drivers/backend_guard.d.ts.map +0 -1
  218. package/build/src/drivers/backend_guard.js.map +0 -1
  219. package/build/src/drivers/sql_expiry.d.ts.map +0 -1
  220. package/build/src/drivers/sql_expiry.js.map +0 -1
  221. package/build/src/middleware/app_access_middleware.d.ts.map +0 -1
  222. package/build/src/middleware/app_access_middleware.js.map +0 -1
  223. /package/build/src/{middleware → http}/app_access_middleware.d.ts +0 -0
  224. /package/build/src/{middleware → http}/app_access_middleware.js +0 -0
package/README.md CHANGED
@@ -18,7 +18,7 @@ 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.0.0 is a breaking release.** No compatibility flags: what changed, and why, is in the [CHANGELOG](./CHANGELOG.md), ordered by risk.
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
22
 
23
23
  ## Install
24
24
 
@@ -31,7 +31,11 @@ node ace authz:catalog:sync
31
31
 
32
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.
33
33
 
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.
34
+ > **Upgrading to 2.4.0-alpha.7 on MySQL from 2.4.0-alpha.4 or earlier — stop the relay, do not roll.** alpha.5 and alpha.6 took both the old (64-char-truncated) and the new (stable, hashed) `GET_LOCK` names of the outbox lease so a mixed rolling deploy kept a single writer; alpha.7 takes only the new one (`SCOPE_OUTBOX_TAKES_LEGACY_LOCK = false`). A leftover alpha.4 relay holding the old name is **not seen** by alpha.7 — pinned by a case, on purpose — so upgrade with a full stop of every relay (cron and inline) and a restart. From alpha.5 or later, or on PostgreSQL/SQLite, nothing to do.
35
+
36
+ > **Upgrading to 2.4.0-alpha.4 — how the commands are registered.** `@jantstack/adonis-authz/commands` is now a real ace loader (it exports `getMetaData()`/`getCommand()`, built with `ListLoader` over the nine command classes, next to the named class exports). Until alpha.4 it only re-exported the classes, so the line `configure` writes — `commands: [() => import('@jantstack/adonis-authz/commands')]` — made **the whole `node ace` crash** at boot (`TypeError: loaderInstance.getMetaData is not a function`). If you registered the commands by wrapping the barrel in a `ListLoader(Object.values(m))` (the only way it worked before alpha.4), remove the wrapper and register the module directly: `() => import('@jantstack/adonis-authz/commands')`, which is what `node ace configure` writes. The wrapper now fails with `TypeError: command.serialize is not a function`, because the module also exports the loader functions: `Object.values(m)` includes them and `ListLoader.getMetaData()` calls `serialize()` on each value. The module has no `default` export on purpose — ace calls `getMetaData()` on the module namespace and never unwraps `default` (same shape as `@adonisjs/core/commands`).
37
+
38
+ 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`, `OpenFgaRelationsDriver`, `provisionOpenFgaStore`, `openFgaFactsModel`, `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.
35
39
 
36
40
  ## Semantics (what every driver guarantees)
37
41
 
@@ -89,15 +93,167 @@ await authorization.scopes.moved(unit, otherOrg) // BEFORE you re-parent the
89
93
  await authorization.scopes.detached(unit) // BEFORE you delete the row
90
94
  ```
91
95
 
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` (3b) collects them. `purgeScope` covers the exact scope only; until `descendantsOf` exists (2.1) you purge each node of the subtree you delete. `detached` returns a `ScopeDetachOutcome` (`{ purgedRoles, truncated, reason? }`) since 2.2, which is where you see whether the local roles of the subtree were reached — see [Scoped roles](#scoped-roles-22). `scopes.*` require `config.scopes.resolveChain` (500 `E_AUTHZ_CONFIG` otherwise).
96
+ 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
97
 
94
98
  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
99
 
96
100
  `ScopeType` is an open `string`, so define your own union for type safety. The engine never queries your tables.
97
101
 
102
+ **How deep can the tree go?** In `database`, as deep as your resolver walks. With the `openfga` driver the `facts` model resolves a chain reliably up to **22 `parent` hops** (`FACTS_MAX_RESOLVE_DEPTH`) and answers 503 — never `false` — past it, so design the tree with that number in front of you and, if you want the engine to refuse the 23rd level before it is written, declare `scopes.maxDepth` (422 `E_AUTHZ_SCOPE_DEPTH_LIMIT`, partial: it does not see the height of a moved subtree). Every bound the package enforces is in one table: [Limits](#limits).
103
+
104
+ #### The tree outbox, and the relay lag you are accepting
105
+
106
+ 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.
107
+
108
+ 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.
109
+
110
+ ```ts
111
+ import { sqlScopeOutbox } from '@jantstack/adonis-authz'
112
+
113
+ const outbox = sqlScopeOutbox() // or your own implementation of ScopeOutbox
114
+
115
+ export default defineConfig({
116
+ scopes: { resolveChain, outbox },
117
+ drivers: {
118
+ openfga: () => new OpenFgaAuthorizationDriver({ /* … */ outbox }),
119
+ },
120
+ })
121
+
122
+ await db.transaction(async (trx) => {
123
+ await authorization.scopes.moved(unit, otherOrg, { within, actor, transaction: trx })
124
+ await unit.useTransaction(trx).merge({ organizationId: otherOrg.uuid }).save()
125
+ })
126
+ ```
127
+
128
+ ```bash
129
+ node ace authz:scopes:relay # drain the queue and apply the edges
130
+ node ace authz:scopes:relay --dry-run # list what is still unpropagated
131
+ node ace authz:scopes:relay --only=12,13 # apply only these entries (the receipt of scopes.*), 2.4.0-alpha.5
132
+ node ace authz:scopes:requeue --id=12 # return a parked entry to the queue (refuses to revert later applied changes), 2.4.0-alpha.5
133
+ node ace authz:scopes:relay --max-age=300000 # exit ≠ 0 if the oldest pending entry is older than 5 min (the age is always printed), 2.4.0-alpha.8
134
+ ```
135
+
136
+ The package **does not impose a table**: the contract is the `ScopeOutbox` port (`enqueue`, `pending`, `markApplied`, `markFailed`, plus five optional ones: `dead` for parked entries, `acquire` for the single-writer lease and, since 2.4.0-alpha.5, `earlierPending` — the dependency probe of `only` —, `requeue` and `laterApplied` — the pair behind `authz:scopes:requeue`; in the same release `enqueue` may return `{ id }`, `pending` takes an optional `{ ids }` filter and `markFailed` an optional `{ transient }`, all three covariant: an implementation written against alpha.4 still compiles and works). Since 2.4.0-alpha.5 the port has a published judge, [`runScopeOutboxContract`](#the-outbox-contract-runscopeoutboxcontract-240-alpha5). `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. Two things an implementation must do: write `enqueue` inside the transaction it is handed, and **`pending()` must return the whole unapplied queue, oldest first** — it must not hide an entry the relay did not filter through `{ ids }`, by tenant, by scope or by anything else: the order guarantee of the relay (a `moved` before the `attached` that depends on it) rests on the manager seeing the whole queue, and no guard of the manager can tell when a port hides rows on its own (that is exactly how the cross-tenant escalation measured in the audit is reproduced with a "filtering" outbox). It is a duty of the port and the contract judges it (O-08).
137
+
138
+ **Two rules about that transaction (2.4.0-alpha.2, lot L-1).** First, **`{ transaction }` carries the write, never the authority**: the freeze barrier that precedes every write (row `id = 2` of `authz_catalog_version`, see [the freeze](#migrating-and-verifying-authzreconcile-23)) is read through the engine's own connection, **never through your transaction** — a transaction opened *before* a freeze does not see it in its snapshot (InnoDB's REPEATABLE READ, PostgreSQL under that level, SQLite in WAL), and a barrier decided by the caller's snapshot is not a barrier (the suite reproduces the bypass on all three engines and pins the 503). The price is declared: **`{ transaction }` requires a connection pool of at least 2**. With a pool of 1 (SQLite `:memory:`) the barrier cannot get a connection while you hold the only one, and the notification is **503 `E_AUTHZ_BACKEND_TIMEOUT` at `freezeTimeoutMs`** (config, default 5000 ms) — fail-closed, never a hang, never a bypass. Second, `sqlScopeOutbox` **checks whose transaction it is**: it must be an *open* Lucid transaction (`isTransaction === true`) of the **outbox's own connection** (`connection`, or the primary one). A transaction of another connection, a `QueryClient` (`db.connection()`) or the whole `db` service are **500 `E_AUTHZ_CONFIG`** naming both connections, before any statement — until L-1 a transaction of another connection silently enqueued in *its* database (where no relay reads and `dead()` never shows it), and `db` passed the duck-check and wrote outside any transaction, in silence. The same check (`assertCallerTransaction`) is the rule for every `{ transaction }` of the package. The whole story — what `{ transaction }` means on each call, what the engines do, the `resolveChain` limit and what `openfga` offers instead — is in [Writing inside your transaction](#writing-inside-your-transaction-240-alpha2).
139
+
140
+ 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.
141
+
142
+ **"Was mine applied?" — the receipt and `only` (2.4.0-alpha.5).** `scopes.attached/moved/detached` return a receipt, **`{ queued: [id] }`** — the outbox ids of what they just enqueued (plural on purpose: a `detached` whose row is already gone enqueues one entry per spelling of the uuid). Empty means there is nothing to relay: without an outbox the change was applied inline; with a third-party `ScopeOutbox` whose `enqueue` still returns `void` there is no id, and then there is no `only` either. Hand the receipt to the relay on the interactive path, right after your commit: **`await authorization.relayScopeChanges({ only: queued })`**. That is the fix for the cost a consumer measured: with 500 entries of another tenant ahead in the queue, the inline pass cost **4,507 ms and 2,004 OpenFGA calls** to land one change; with `only` it costs **19 ms and 4 calls**, because the filter lives in the port (`pending(limit, after, { ids })` — the primary key) and the pass never reads the other 500 rows (the suite spies on `pending` to pin that; a port implementation that ignores the filter is 500 `E_AUTHZ_CONFIG` before anything is applied, never a silent drain of the backlog inside your request). **The order by scope key is not relaxed — deferred, never out of order.** Before applying each requested entry the relay probes the *whole* queue (`earlierPending`: one existence query against the `(applied_at, id)` index, 0.69 ms measured; a port without it is paged through `pending()`): if an earlier unapplied entry names one of the same scopes and was not requested, yours is reported `deferred` with `depends-on:<id>` and not applied. Why, measured: with `#1 moved(unit → orgB)` pending and `only: [#2 attached(child, unit)]`, a filter without the probe wrote `child → unit` while the store still said `unit → orgA`, and the editor of the *old* organization authorised over the child (`authorize(A@orgA, docs:read, child) = true`, report all green). One level is enough — the last link of any chain of pending dependencies shares a scope with yours by definition of the key contamination — so the probe costs one query, not the queue. The report is **per entry**: `entries` names every entry the pass looked at, `applied` / `deferred` (reason `depends-on:<id>` or `parked:<id>`) / `failed` / `dead`, with or without `only`; and `missing` names the ids of `only` that are not in the queue at all — neither pending nor parked: already applied by an earlier pass, or unknown; the relay cannot tell — never an empty report in green. **`notEvaluated` names the ids of `only` that are still pending and the pass never looked at** because it reached `limit` first (2.4.0-alpha.10): until then `{ only: [a, b], limit: 1 }` put the pending `b` in `missing` — "already applied, or unknown" — and the applying pass exited 0. Telling them apart costs one read of the queue filtered by those ids, only when some requested id was not seen. `authz:scopes:relay --only … --limit …` names each of them ("is PENDING and this pass did NOT evaluate it", saying whether it was `--dry-run` or the applying pass and which `--limit`) and exits ≠ 0, in both. `only: []` is 500 `E_AUTHZ_CONFIG`: an empty `filter` on your side, or a receipt from an outbox without ids, cannot end up draining the whole queue under the lease of an HTTP request. **`remaining` and `dead` are always global**, with or without `only`: a filtered report that kept quiet about a queue that does not converge would be a green report over a broken tree. Three things this is **not**. `only` takes the ids from the receipt your own process just got, **never from the request body** — an endpoint that accepts `only` from a client is policy in an API that has no policy (the relay skips `requireActor`/`requireWithin` on purpose). **There is no filter by tenant or organization**: a `detached` whose row is already gone belongs to no organization (excluding it always is a permanent fail-open — its facts are never purged; including it always is "anyone relays anyone's `detached`"), and a `moved` would be attributed to its *destination*, which is the bias that keeps accesses. And `only` does not pull its dependencies along — that is transitive and ends up being the whole queue; if what you want is "the queue landed up to here", run the relay without `only`. **Do not return the relay report over HTTP**: it enumerates the tree activity of every tenant (ids, scope uuids, actors, the backend's error text); read it, log it, act on `entries`, and hand the client your own answer. Finally, **the inline pass returns a report, it does not throw**: a `deferred` or a `failures` does not abort your request unless you look at it — if `entries` says your change is `deferred`, it has not landed, and that is the fail-closed window described below.
143
+
144
+ **A 503 does not consume an attempt (2.4.0-alpha.5).** `maxAttempts` exists to converge on the *irreparable* — the parent of an enqueued `attached` was deleted and will not come back — and until alpha.4 a backend outage spent the same budget: measured, with the store down, **five requests of another tenant** running the inline relay parked an entry that was not theirs (one attempt per request: the entry at the head of the queue pays for everyone's pass), and from then on its dependants were applied without it. Now the manager tells the two apart by the nature of the error: an `AuthorizationBackendError` — the store did not answer, the deadline expired, any 503 — is reported in `failures` with `transient: true` and passed to `markFailed(id, error, { transient: true })`, and `sqlScopeOutbox` writes `last_error` **without** counting the attempt; a 422, a 500, `E_AUTHZ_SCOPE_TREE_DRIFT`, a missing parent or a bare error from a third-party driver count as before. A port implementation that ignores the third parameter counts everything — exactly the alpha.4 behaviour, degrading towards safe. For the inline recipe, with the numbers: an outage of the store lasting `N` requests leaves `N−1` entries deferred behind the failing head on request `N`, and none of them parked.
145
+
146
+ **A parked entry blocks, by scope key, what grants or re-parents (`attached`, `moved`) and lets through what retires (`detached`) (2.4.0-alpha.5).** Until alpha.4 a parked entry left `pending()` and stopped contaminating, so what depended on it was applied *without* it — for an `attached` behind a parked `moved` that is the store keeping the **old parent, permanently**, the persistent escalation this outbox exists to prevent (measured; and live in a consumer running the inline relay, with no change of theirs involved). Now the keys of `dead()` seed the blocked set at the start of every pass: an `attached` or `moved` naming one of those scopes is reported `deferred` with `parked:<id>`, on every pass, until someone looks at the parked entry (the command names it; the way out is `authz:reconcile --to=<driver>`, which recomputes with today's tree, or a new notification from your side). A `detached` of that key is applied anyway — a purge never waits (invariant 11: a half-applied tree may leave denies *in excess*, never facts alive on a node nobody purges) — **and that holds transitively** (closing lot of alpha.5, auditor 🟠 1): a `detached` behind an `attached` that is itself deferred *because of* a parked entry is applied too, because the manager tracks the *origin* of every key block; only a block born from a real failure of the pass (an entry that threw) still holds a purge back, as it did since 2.0, so the order between a failed `attached` and the purge of that same node is kept. The parked keys are read **in full on every pass, whatever `--limit` or `batchSize`** (🟠 2: a census bounded by `limit` let the 101st parked entry go unnoticed with `--limit 50` or the inline `{ only, limit: 1 }`, and the `attached` landed under the old parent); more than `PARKED_CENSUS_MAX` (50,000) parked entries is a 500 before anything is applied, never a blind plug. The price is declared: one irreparable parked entry freezes the *grants and moves* of its scope key until a human acts — bounded to the key, not the queue, and loud (`deferred` + `dead` + exit code ≠ 0).
147
+
148
+ **When the parked set grows.** Nothing in the package shrinks it for you, on purpose: `authz:reconcile` never touches the outbox (the counter does not go down), `authz:scopes:requeue` has no `--all` (re-queueing an irreparable entry parks it again in one pass, and re-queueing a stale one is the reversal the probe refuses), and past `PARKED_CENSUS_MAX` (50,000) every pass is a 500 before applying anything. The remedy is an operator's SQL on **your** table: list `authz_scope_outbox` rows with `attempts >= maxAttempts` and `applied_at IS NULL`, read `last_error`, and either `authz:scopes:requeue --id` the ones whose cause is gone (a parent that exists again) or **delete** the irreparable ones (a parent that will never exist) — then `authz:reconcile --to=<driver>` rebuilds the tree from today's rows. Deleting a parked entry is safe for the tree only because the reconcile recomputes it; do not delete and skip the reconcile.
149
+
150
+ **The root `app` is not a scope key, and a parked entry only plugs the key of its own child (2.4.0-alpha.8).** Two corrections to the key rule above, both measured in a consumer. *First*, until alpha.7 the keys of an `attached`/`moved` were `[child, parent]` and the parent of every tenant registration is `app`, so every tenant registration shared a key with every other one: one parked `attached(orgA, app)` left every later registration `deferred · parked:` on every pass (five passes with `applied = []`), two concurrent registrations relayed inline left the second one `deferred · depends-on:` **with nothing parked at all** (the root was a global serialiser of sign-ups, and under (c2r) a fail-closed one: the new organisation granted nothing until the cron ran), and `authz:scopes:requeue` refused the parked one with a false conflict as soon as another tenant had registered. The root **never moves, is never purged and is never a child** (`scopes.attached(APP_SCOPE, …)`, `scopes.moved(APP_SCOPE, …)` and `scopes.detached(APP_SCOPE)` are 422), so two changes whose *only* shared scope is `app` commute and there is no order to keep: the manager now leaves the root out of the keys — decided on the reference (`type === 'app'` and a null uuid), never on the string `'app'`, and in the three places that build the list (the pass, the `only` probe, the requeue probe), also for blocks born from a real failure. The child's key still orders everything that matters: a `detached(orgA)` that failed still holds back an `attached(orgA, app)` with the same uuid, and a failed `moved(unit, app)` still holds back a later `moved(unit, orgB)`. The port is untouched: `sqlScopeOutbox.earlierPending`/`laterApplied` still know how to match the root as a parent; the manager just never asks. *Second*, the parked census now seeds only the key of the parked entry's **child**: a parked entry says the position of *its child* in the store is wrong, nothing about its new parent, so a `moved(otherUnit → orgA)` behind a parked `moved(unit → orgA)` is applied (until alpha.7 it was `deferred · parked:` for no reason and the store kept the old parent of everything under `orgA` with no expiry), while `attached(child, unit)` and `moved(unit → orgC)` — which name the parked child — stay `deferred · parked:<id>` and a `detached(unit)` still passes. When comparing two changes the manager still uses both keys; only the seeding of the census changed. Both are a change of observable behaviour: an `attached`/`moved` that used to come out `deferred` under those conditions is now applied, which is the bug.
151
+
152
+ **Under `only` the purge exemption holds too (2.4.0-alpha.6).** With `relayScopeChanges({ only })` the probe that finds an earlier unapplied entry sharing a scope key with yours now **inherits the origin of the block** of that entry, the same way the full pass tracks it: if the earlier entry is — directly or through its own dependencies — waiting only on a **parked** one (it would be `parked:<id>` or `depends-on:` of one in a full pass), a requested `detached` is **applied and purges**, exactly as in the full pass (invariant 11: a purge never waits for a parked entry). If the earlier entry is an `attached`/`moved` that is genuinely pending (it would be applied by the next full pass) or is held by a **real failure** of the pass, the `detached` stays `deferred` (`depends-on:<id>`), because applying a purge ahead of a pending `moved` of the same scope leaves drift. Until alpha.6 the probe marked every such deferral as a real block, so a deployment that only ever relayed inline never purged behind a parked entry (audit 🟡 of alpha.5, measured). The probe walks back one `earlierPending` query per hop over entries it does not apply, at most `RELAY_ORIGIN_MAX_HOPS` (100) hops; past that it assumes a real block and defers — towards safe, never applying without knowing. Entries you did not request are not touched: they keep their attempts and their place.
153
+
154
+ **Requeueing a parked entry: `node ace authz:scopes:requeue --id=<id>` (2.4.0-alpha.5).** A parked entry is never retried on its own. When you have fixed the cause — the missing parent is back, the store was reconciled — `authorization.requeueScopeChanges([id])` (platform API, like the relay; the command is its ace face) returns it to `pending()` with its old id, and **`attempts` is set to `maxAttempts − 1`**, neither `0` nor kept (owner's decision): if the cause is still broken the entry is parked again after **one** pass — and it contaminates its scope key only during that pass, so an automated requeue of something irreparable is not a permanent deferral tap for that tenant's new changes —, and if you fixed it, it lands on the first pass. No new column. It is all or nothing: an id that does not exist, is still pending, or was already applied is a 500 naming it and **no row is touched** (an applied entry never comes back: requeueing an applied `detached` would purge again). **The anti-reversion probe lives in the manager and has no exception.** Before touching the queue it asks the port (`laterApplied`) whether an entry *applied after* the parked one shares a scope key with it; if one does, requeueing would re-apply the old change on top of the new — measured: a parked `moved(unit → orgB)` requeued after `moved(unit → orgC)` was applied leaves the store with the **old** parent and a single edge, so nothing denounces it while the old tenant regains access — and the call is **500 `E_AUTHZ_REQUEUE_REFUSED`** naming the later entry and the key, with the right remedy (`authz:reconcile --to=<driver>`, which recomputes with today's tree). **`--force` exists only in the ace command**: it prints the diff — the parked entry and every applied entry it would revert — and then requeues anyway; `requeueScopeChanges(ids, { force: true })` is what the command calls and nothing reachable over HTTP should. The probe is the manager's, not the table's, so a third-party `ScopeOutbox` gets it for free by implementing the two optional methods (`requeue` and `laterApplied`); without them the command says so (500 `E_AUTHZ_UNSUPPORTED`). Two limits of the probe, written: the diff lists at most 100 later entries per parked one and says when it is cut (`truncated`, "… y más"); and **`laterApplied` only sees the outbox** — `authz:reconcile` does not write to it, so a requeue *after* a reconcile can revert what the reconcile just certified and the probe has nothing to compare against: **do not requeue after reconciling**; if you already did, the remedy is to reconcile again.
155
+
156
+ **Closing the incident in one call: `requeueScopeChanges(ids, { apply: true })` / `node ace authz:scopes:requeue --id=<id> --apply` (2.4.0-alpha.8).** Measured in a consumer (panel relay 2, tester P14): after `requeue` the entry comes back with its *original* id — smaller than everything enqueued since — so the tenant's next inline `relayScopeChanges({ only })` found it *in front* and came out `deferred · depends-on:<id>`; the incident needed a full pass behind the command. With `apply: true` the entry is requeued **and relayed in the same call**, and the relay's report travels in `report.relay`. Four rules, each pinned by a case: **one lease** for the whole call, taken *first* — if another pass holds it the result is `relay.busy === true` with **zero rows requeued** and the command exits ≠ 0 (requeueing and then saying "busy" would leave the entry pending with `attempts = maxAttempts − 1`, to be parked again by the next pass and poison its key once more; `waitForLeaseMs` composes with it); it **passes the freeze barrier** before touching anything (with `apply` the command writes the tree; without `apply`, `requeue` still does not, it only touches the queue); it **does not compose with `force`** (500 `E_AUTHZ_CONFIG` — with both, reverting the tree over what was applied later would be a single call); and the internal `only` is **exactly the requeued ids**: what depended on the parked entry is *not* dragged along (it stays `deferred`/pending, the report says so — `remaining`, and the command prints it), because `only` is *your receipt* and nothing else. The anti-reversion probe still runs before anything is requeued (a refusal is 500 with zero driver calls, and the lease is released). If the cause is still broken, the same call parks the entry again (`attempts` goes from `maxAttempts − 1` to `maxAttempts`, `relay.failed` says why); a 503 from the store leaves it pending with `attempts = maxAttempts − 1`, as always. `--apply` is explicit, never the default: without it the command behaves exactly as before. Platform API, like the relay: never reachable over HTTP.
157
+
158
+ **The relay is a single writer.** `pending()` reserves nothing, so two passes at once (a Kubernetes `CronJob` with the default `concurrencyPolicy: Allow`, two replicas, a pass that lasts longer than its interval) work on the same batch: the straggler re-applies an old `attached` after the other applied the new `moved`, and the store is left with the **old parent and a single edge, so nothing denounces it** — the old tenant keeps access to a subtree that is no longer theirs (measured). `sqlScopeOutbox.acquire()` takes a lease for the pass — a server-side lock on PostgreSQL and MySQL, a process-wide one on SQLite — and a second simultaneous pass does nothing and says so (`busy`). Since 2.4.0-alpha.5 that is **observed between real processes** (`tests/scope_outbox_multiprocess.spec.ts`: a second process relays the same queue while the first holds the lease ⇒ `busy`, zero writes, on PostgreSQL and MySQL), and the SQLite limitation is pinned the same way — the second process *does* apply over the first: **SQLite is not the engine for two replicas of the relay**. If you implement the port yourself and skip `acquire`, run the relay one pass at a time.
159
+
160
+ **Waiting for the lease instead of giving up: `relayScopeChanges({ waitForLeaseMs })` / `--wait-lease-ms` (2.4.0-alpha.8).** By default (`waitForLeaseMs: 0`) a pass that finds the lease taken reports `busy` at once — one `acquire()` attempt, the behaviour above, unchanged. With a positive value the pass retries `acquire()` every `RELAY_LEASE_POLL_MS` (25 ms; measured 0.29 ms per busy poll in PostgreSQL, and a failed poll holds no connection between attempts) until the lease is free or the budget is spent, and then reports `busy` — bounded, never a hang. It is opt-in and it **needs a pool of at least 2** connections, like `{ transaction }`: with a pool of 1 the waiting pass may be holding the very connection the pass that has the lease needs. Do not size it to a full backlog: a pass over 2,000 entries with real backend writes takes tens of seconds; the option exists so that an operator's `authz:scopes:requeue --apply --wait-lease-ms` does not lose systematically against the cron, not to queue relays behind each other.
161
+
162
+ **How old is the queue? `report.queue` and `--max-age` (2.4.0-alpha.8).** Measured in a consumer (panel relay 2, auditor B2): with the store down, ten relay passes in a row reported `attempts = 0` on every entry (a 503 does not consume an attempt, since alpha.5), `dead = 0` and **no notion of time anywhere** — the cron was red because of `failures`, but nobody could tell since when, and nothing distinguished a queue that has been stuck for an hour from one that was enqueued a second ago. Every relay report now carries **`queue: { oldestPendingAt, pendingAgeMs }`**: the `createdAt` of the **oldest pending entry of the whole queue** — the `pending(1)` the pass already ran at the end for `remaining`, so it costs no extra query — and its age, `max(0, now − createdAt)`, measured with the manager's injectable clock (`config.clock`; no `Date.now()` in the package sources, the suite greps for it). It is **always global, also under `only`**, like `remaining` and `dead`: it names nobody, it only says "there is backlog". Both fields are `null` when nothing is *pending* (an empty queue, or only parked entries — those have their own alarm, `deadCount`, below) and when the port does not provide `createdAt`: a third-party `ScopeOutbox` whose entries carry no `createdAt` reads as `null`, **never as `0` or "now"** (the same rule as `stillGranting`: "I do not know" does not degrade to a number). `sqlScopeOutbox` returns it from the `created_at` column it already had (`createdAt?: Date` is an optional field of `PendingScopeTreeChange`; the outbox contract has a `createdAt` capability pair). Read the number for what it is: **the age is the clock of whoever enqueued** — another replica with a skewed clock stamps its own `created_at`, so the age is clamped at zero and never negative, and it is not a decision the package takes (audit stamps are not decisions). The command prints it on every pass; **`--max-age=<ms>`** makes the pass exit ≠ 0 when the age is above the threshold — **only with a threshold**: without it the age is reported and never fails the run, because a cron that goes red for ever over a queue that simply has backlog is a cron nobody reads. Parking by age (`parkAfterMs`) is *not* offered, on purpose — see the changelog: it reintroduces, with another clock, the mass parking alpha.5 removed, and it was measured to make every pass a 500 once the census fills.
163
+
164
+ **Enqueueing outside your transaction: `sqlScopeOutbox({ requireTransaction: true })` (2.4.0-alpha.8).** The mitigation above is *entirely* the `{ transaction: trx }` you hand to `scopes.attached/moved/detached`. Without it the call still enqueues — the row commits on its own, outside your unit of work — and if your transaction rolls back afterwards **the change stays in the queue and the relay applies it to the store**: your database says one parent, the PDP another, and nothing on your side can show it (the S5 leak, on the *correct-use* path; measured by the tester of panel relay 2, P13). With `requireTransaction: true` that call is **500 `E_AUTHZ_CONFIG` before the `INSERT`**, naming exactly that leak, through the manager and through the outbox alone; with an open transaction of the outbox's connection it enqueues as always. The default is **`false` in 2.4** — the published outbox contract (`runScopeOutboxContract`) enqueues without a transaction in its `ctx()`, and a `true` default would turn every third-party outbox red — and **the default becomes `true` in 2.5** (owner's decision), together with the contract passing the transaction in `ctx()` (a prerequisite of that release; without it the default cannot change). Turn it on today: it is the letter of the mitigation, and the only thing you lose is the ability to enqueue by mistake. A transaction that is **already committed or rolled back** is a different 500, below.
165
+
166
+ **What the report says about the parked set — `deadCount`, `deadDetail`, and the `origin` of a deferral (2.4.0-alpha.8).** Three small corrections to the report, each measured in a consumer. **`deadCount`** is always present and always global: `dead[]` is capped at `batchSize` (100), so `dead.length` *lies* once more than that is parked; the alarm "the queue does not converge" now rests on the count, which comes from the census the pass already reads for the key plug (no extra query). **Under `only`, `dead[]` carries `id`, `change` and `attempts` and — by default — no `error`**: the inline recipe runs inside a tenant's request, and the error text is what the backend said about *another* tenant's tree; pass `deadDetail: 'full'` to get it back (without `only` nothing changes, and the entries you *asked for* keep their `reason` in `entries`, they are yours). Read this as the safe-fields rule for a report you might be tempted to surface: `deadCount` and the ids say "there is a parked backlog" and nothing else; `change` still names scopes of other tenants (the actor never travels in `dead[]`), so the rule above stands — do not return the report over HTTP. And every `deferred` entry now carries **`origin`**: `'parked'` (held, directly or transitively, by a parked entry — the only origin that lets a `detached` through, invariant 11), `'failed'` (held by a real failure of this pass), or `'pending'` (under `only`: held by an earlier entry that is genuinely pending and was not requested — it will land on the next full pass). Until alpha.8 `depends-on:<id>` did not distinguish the last two and the origin lived only in the Spanish prose of `deferred[].error`; it is derived from the block itself, never parsed back from the text of `reason`.
167
+
168
+ **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)):
169
+
170
+ - 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;
171
+ - 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.
172
+
173
+ 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.
174
+
175
+ 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.)
176
+
177
+ 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.
178
+
179
+ #### What a scope that is not attached grants
180
+
181
+ **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).
182
+
183
+ Two consequences you have to plan for:
184
+
185
+ - **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`.
186
+ - **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".
187
+
188
+ ### Writing inside your transaction (2.4.0-alpha.2)
189
+
190
+ `{ transaction: trx }` — an open Lucid transaction of yours — means **three different things** depending on the call, and the difference is the whole story of this section:
191
+
192
+ | Call | What `{ transaction }` does | `database` | `openfga` |
193
+ |---|---|---|---|
194
+ | `grant` / `revoke` / `deny` / `removeDeny` | **writes the fact inside your transaction** — commit and the fact is there with your rows, roll back and neither is | yes (`transactionalWrites: true`, lot L-3) | **500 `E_AUTHZ_UNSUPPORTED`**, nothing called (lot L-5) — see [the recipe by direction](#openfga-the-recipe-by-direction) |
195
+ | `relate` / `unrelate` / `purgeObject` / `purgeSubject` | **writes the tuple inside your transaction** | yes (lot L-4) | the same 500 |
196
+ | `scopes.attached` / `moved` / `detached` | **enqueues** the tree change in the outbox, inside your transaction (`scopes.outbox`, see [the tree outbox](#the-tree-outbox-and-the-relay-lag-you-are-accepting)). Enqueueing ≠ writing: this never passes the capability gate | both | both |
197
+ | `defineScopedRole` / `updateScopedRole` / `deleteScopedRole` | **refused**, 500 `E_AUTHZ_UNSUPPORTED`: the catalog is written through `withAuthzCatalogWrite`, the cross-process serializer (invariant 14), which your transaction would defeat | — | — |
198
+
199
+ The capability behind the first two rows is **`transactionalWrites`** (same name on both ports), and it means **exactly** *both or neither with your transaction* — never *"not lost"*; there is no intermediate value. `database` declares `true`; `openfga` declares `false` and cannot declare anything else: a tuple lives in another service and there is no two-phase commit between it and your database. `requireTransactionalWrites: true` in the config (root, or `relations.requireTransactionalWrites`) turns a wrong deployment into one that **does not start** — 500 `E_AUTHZ_CONFIG` when the driver is resolved, reads included — instead of a 500 on a rarely travelled route. It is the first capability of this package that is **not portable between drivers**: the same call is 200 on `database` and 500 on `openfga`, and the suite pins that asymmetry on purpose (each driver declares what it can do; you read the capability and adjust).
200
+
201
+ #### The rule: the write goes through your transaction, the authority never does
202
+
203
+ **A transaction that is already committed or rolled back is refused before any statement (2.4.0-alpha.8).** Lucid keeps `isTransaction === true` on a finished transaction, so until alpha.7 `assertCallerTransaction` let it through and the statement blew up inside knex as **503 `E_AUTHZ_BACKEND_UNAVAILABLE`** ("the backend did not answer") — which is false: it is the caller's wiring (a `trx` reused after `await trx.commit()`). It is now the fourth check of the shared guard (`isCompleted === true` ⇒ **500 `E_AUTHZ_CONFIG`** naming the operation, "confirmada o revertida", zero statements), in the three places that take `{ transaction }`: `sqlScopeOutbox.enqueue`, the `database` roles driver and the `database` relations driver — pinned in the four engines.
204
+
205
+ **Through your transaction: the write, and only the write** — the `INSERT`/`UPDATE`/`DELETE`, plus the "does it already exist?" read that belongs to it (a `grant` and its re-grant, a `relate` and its renewal, inside the same transaction see each other). **Through the engine's own connection, always: the authority** — the freeze barrier (row `id = 2` of `authz_catalog_version`), the catalog memo, `resolveChain`, F-05 and `assertWrite`. They are decided **before the first statement on your transaction** (the suite spies on the transaction and counts zero), and a transaction opened *before* a freeze does not get to decide it is not frozen (its snapshot would say so under InnoDB's REPEATABLE READ, PostgreSQL at that level and SQLite in WAL — the suite reproduces the bypass on the three engines and pins the 503). Until 2.4.0-alpha.2 the barrier *was* read through the caller's transaction "so a pool of 1 would not deadlock"; that traded authority for availability and it is gone.
206
+
207
+ Hence the deployment rule, said out loud: **`{ transaction }` requires a pool of at least 2.** With a pool of 1 (SQLite `:memory:`, the suite's default mode) the authority cannot get a connection while you hold the only one, and the write is **503 `E_AUTHZ_BACKEND_TIMEOUT` at `freezeTimeoutMs`** (config, default 5000 ms — the barrier's own total deadline, pool wait included; knex's own `acquireConnectionTimeout` would have made it 60 s) with **zero statements on your transaction**: fail-closed, never a hang, never a bypass. A pool-of-1 deployment declares it — `new DatabaseAuthorizationDriver({ transactionalWrites: false })`, `new DatabaseRelationsDriver(config, { transactionalWrites: false })` — and gets gate 1's immediate 500 instead; the driver declares what its deployment can do, the package does not guess the pool. This also applies to `scopes.*` with `{ transaction }` (the outbox): a single-connection deployment that used it has to raise the pool (breaking of deployment, lot L-1). While the engine is **frozen**, `{ transaction }` is the retryable 503 `E_AUTHZ_FROZEN` before the first statement on your transaction.
208
+
209
+ The transaction must be **yours and the driver's**: an open Lucid transaction (`isTransaction === true`) of the driver's connection (the primary one for facts; `connection` or the primary one for relations and the outbox). Another connection, a `QueryClient` (`db.connection()`) or the whole `db` service are 500 `E_AUTHZ_CONFIG` naming both connections, before any statement (`assertCallerTransaction`, exported: the same check for every `{ transaction }` of the package — until lot L-1 a foreign transaction silently enqueued in *its* database and `db` passed the duck-check and wrote outside any transaction). "Both or neither" is only true on the same connection.
210
+
211
+ What the published suite pins, **by census and not by the answer**: after a rollback `authz_assignments`/`authz_denies` hold zero rows for that holder and `authz_relations` zero new tuples in that partition, for the four writes of each port, on SQLite (file), PostgreSQL and MySQL; `purgeObject`/`purgeSubject` delete and revert *together*; while the transaction is open the authority (reading through the engine's connection) does not see the write; on commit census and answer change together (`runAuthorizationDriverContract` / `runRelationsDriverContract`, the `true` face of the pair). `onWrite`/`onRelationWrite` fire when the driver returns, with **`transactional: true`** on the event: at that instant the row exists only inside your transaction and it is a fact if and only if you commit — the package never sees that; a sink that wants the last word hangs itself on `trx.after('commit')`.
212
+
213
+ #### What the engines do when the write lives as long as your transaction (measured)
214
+
215
+ Outside a transaction the driver absorbs a clash: it re-reads and keeps the winner's row (2.5-B · K4). Inside yours it must not — PostgreSQL has already aborted your transaction, and under REPEATABLE READ the re-read would not see the winner (a mutant that re-reads fails differently on each engine) — so **a clash poisons your transaction**: the driver never issues a second statement on a transaction that may be aborted, the choice between roll back and retry is yours, and `onWrite` publishes nothing for the loser (a clash is not a deadline). Measured on the three engines, facts and tuples alike:
216
+
217
+ | Situation | PostgreSQL | MySQL | SQLite (file, WAL) |
218
+ |---|---|---|---|
219
+ | **Two open transactions write the same fact/tuple** (unique index) | the second `INSERT` waits until the first ends; on its commit, `23505` ⇒ **409 `E_AUTHZ_WRITE_CONFLICT`** ("roll back and retry"); your transaction is **aborted** (`25P02` on every later statement until you roll back) | waits (shared lock on the duplicate); `1062` ⇒ **409 `E_AUTHZ_WRITE_CONFLICT`**; your transaction **lives on**, only the statement was undone | does not wait: a reader cannot become the writer while another holds the reserved lock ⇒ `SQLITE_BUSY` at once, *before* the first commits ⇒ 503 `E_AUTHZ_BACKEND_UNAVAILABLE`; alive |
220
+ | **The driver's `timeoutMs` elapses while your statement waits** | knex cancels the query (`pg_cancel_backend`) ⇒ 503 `E_AUTHZ_BACKEND_TIMEOUT`; **aborted** | knex kills it (`KILL QUERY`) ⇒ 503 `E_AUTHZ_BACKEND_TIMEOUT`; lives on | the synchronous driver cannot elapse a deadline: `SQLITE_BUSY` above |
221
+ | **Two transactions write two tuples in crossed order** (A→B / B→A) | detected after `deadlock_timeout` (1 s by default); the victim gets **409 `E_AUTHZ_WRITE_CONFLICT`** (`40P01`) and stays **aborted** until its rollback; the winner waits for that rollback | InnoDB detects it at once; the victim gets **409** (`1213`) and InnoDB **rolls back its whole transaction** — its earlier rows are gone too; the winner may resolve first | cannot deadlock: the second writer is `SQLITE_BUSY` (503) at its first write |
222
+ | **A cross-partition userset row your own code inserts on that transaction** (the partition trigger fires inside it, lot L-4) | refused by the trigger; your transaction is **aborted** and the next `relate` of the package on it is a classified 503 (`25P02`), never the raw error | refused; lives on | refused; lives on |
223
+ | **A transaction that read before another writer committed** (a freeze put and lifted after your first `SELECT`, say) | — | — | cannot write afterwards: `SQLITE_BUSY` snapshot ⇒ 503, the engine's rule |
224
+
225
+ Two more things that follow from "as long as your transaction": for the 503 of an elapsed deadline `onWrite` publishes **`indeterminate: true` *and* `transactional: true`** — your rollback does determine the outcome, but the rollback is yours and the package never sees it (invariant 13 stands); and **renewing a relation expiry (delete+insert, R-15) rolls back to the previous expiry** — the census after the rollback holds the old row with its old `expires_at` and its old uuid.
226
+
227
+ #### The decision is not in your transaction: `resolveChain`
228
+
229
+ The *row* is inside your transaction; the *decision* that authorises writing it is not. `resolveChain` is your function and it reads your tree through your `db` — the engine's connection, not your transaction — so **the tree is judged against the state before your transaction** (`within` too): creating a scope and granting on it inside the same transaction is 422 `E_AUTHZ_UNKNOWN_SCOPE`, fail-closed, and that is the correct answer, pinned. It is the one limit of this port, and it is not fixed by handing the driver your transaction (that would put the authority back inside the caller's snapshot). Two recipes:
230
+
231
+ - **To grant on a scope you create in the same transaction**, use a `ScopeChainResolver` that closes over your transaction — `scopes.resolveChain` is a plain callback of yours and nothing stops it from reading your tree through `trx` (no case of this package exercises that form: it is your code). You are then telling the authority to trust your uncommitted tree — your call, said out loud. Otherwise commit the scope first and grant after.
232
+ - **If you move the tree *and* write facts in the same transaction, notify `authorization.scopes.*` first**, then write the facts. The notification validates the move against the tree as it stands (anti-cycles, existence), and with the outbox it **enqueues** in your transaction, so the tree change and your rows commit or vanish together; the facts you write after it are still judged against the pre-transaction tree, which is the fail-closed side.
233
+
234
+ #### `openfga`: the recipe by direction
235
+
236
+ `openfga` cannot put a tuple in your transaction and says so — 500 `E_AUTHZ_UNSUPPORTED` on every write that carries `{ transaction }`, through the manager **and through the driver itself** (`manager.driver()` re-validates: the message names the driver and the operation, nothing was called; the way out is the `database` driver or `requireTransactionalWrites`, and the driver's own letter adds that entering through `manager.driver()` is not a way out — for the **tree** the way out that exists is `scopes.outbox`; **for facts and relations there is no outbox**, on purpose: an outbox of facts was measured as fail-open during its lag and discarded by the panel, the numbers are in the changelog). What you *can* do with `openfga` is order your writes by **direction**, so that whichever of the two fails, the failure lands on the closed side:
237
+
238
+ | Direction | With `openfga` | If your transaction rolls back |
239
+ |---|---|---|
240
+ | **Permissive** — `grant`, `relate`, `removeDeny` | write **after the commit** (`trx.after('commit', …)`, or after `db.transaction()` returns) | nothing was granted ⇒ **fail-closed** |
241
+ | **Restrictive** — `revoke`, `deny`, `unrelate` | write **before, or inside, in line** (no `{ transaction }`) | something was revoked that your SQL still grants ⇒ **fail-closed**, and repairable (grant again) |
242
+
243
+ **This is not "both or neither"**: it is a write *order* that makes the failure fall on the closed side, and its limits are measured against a real server:
244
+
245
+ - **Its limit: it does not compose.** A `grant` and a `revoke` of the *same* fact in the same transaction are applied in inverted order — the revoke in line, on a fact that does not exist yet (a safe no-op), the grant after the commit — and **end up granting**. If your unit of work both grants and revokes the same fact, the recipe is not for it.
246
+ - **The package does not automate it.** `grant` with `{ transaction }` on `openfga` is the 500 above: the driver does not hook `trx.after('commit')` to write later, does not write in line "pretending", and nothing lands when you commit (pinned: zero hooks registered on the transaction, zero client calls, `onWrite` empty). The order is yours to write.
247
+ - **Lucid swallows what an `after('commit')` handler throws** (`TransactionClient.commit()` runs the hooks and catches). A `grant` that fails there — 503, 422 — fails in silence: still fail-closed, but unreported. Await the write after `db.transaction()` returns, or catch inside the handler and report it yourself.
248
+ - **"Inside" needs a pool of at least 2 even with `openfga`.** The write goes to the store, but the freeze barrier before it is read through the engine's SQL connection: a `revoke` in line *inside* your open transaction on a pool of 1 is the 503 of the barrier at `freezeTimeoutMs`, not a revoke. On a pool of 1 the restrictive write goes **before** you open the transaction.
249
+
250
+ #### Third-party drivers, and what the suite can and cannot prove
251
+
252
+ "Both or neither" is **a guarantee of this package's `database` driver**, measured on its own schema on three engines. For a driver of yours declaring `transactionalWrites: true`, the runner requires the census (the `transactions` hook: how to open a transaction of *your* connection and how to count *your* rows) and judges rollback ⇒ zero, commit ⇒ applied, the uncommitted write invisible to the authority and three foreign transactions ⇒ 500 with zero statements — but it **cannot prove that a third-party driver's write really lives and dies with the transaction it is handed**: the census it counts is the one your hook reports, and a driver that opens its own transaction underneath passes the answer checks. Two more honest boundaries: the `true` face only runs where a second connection exists (`sqlite-file`, PostgreSQL, MySQL — the engine jobs of CI are not optional for this capability), so a driver judged only on `:memory:` never sees those cases; and the 409/503 classification of the table above is the `database` driver's — a third-party driver that returns the raw engine error is not caught by the suite.
253
+
98
254
  ### Identity is validated, once and everywhere
99
255
 
100
- `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`, `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.
256
+ `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.
101
257
 
102
258
  ## Writes
103
259
 
@@ -118,10 +274,11 @@ A seeder or an onboarding that calls `grant` "to make sure they have the role" n
118
274
 
119
275
  `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.
120
276
 
121
- Since 2.2 `scopes.detached` also **returns** a `ScopeDetachOutcome` (`{ purgedRoles, truncated, reason? }`) instead of `void` — see [Scoped roles](#scoped-roles-22).
122
277
 
123
278
  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.
124
279
 
280
+ **Inside your transaction (2.4.0-alpha.2, lot L-3) the event also carries `transactional: true`**, and a deadline that elapses there is *still* `indeterminate: true`. The `{trx}` panel's auditor objected that inside a transaction "the rollback determines it" — it does, but the rollback is *yours* and the package never sees it: when the 503 reaches you the statement may or may not have landed inside your transaction (SQLite cannot cancel; MySQL kills the statement and your transaction lives on; PostgreSQL leaves your transaction aborted), and whether you commit is yours. `transactional: true` is on **every** event of a write inscribed in your transaction (`granted`/`extended`/`revoked`/`denied`/`deny_removed`): at that instant the row exists only inside your transaction and is a fact if and only if you commit. A sink that wants the last word hooks `trx.after('commit', …)`. Enqueueing through `scopes.*` is not writing and does not carry it.
281
+
125
282
  `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"*).
126
283
 
127
284
  ## Queries
@@ -160,7 +317,7 @@ await authorization.isWithin(unit, currentOrg) // the same question on its own
160
317
 
161
318
  **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).
162
319
 
163
- **`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.
320
+ **`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. **Grep for `relationsDriverFactory` and `relationsDriverKeys` too** (2.4.0-alpha.10): `manager.relationsDriverFactory(key)` returns the factory registered under `relations.drivers[key]` and `manager.relationsDriverKeys()` lists the keys — platform API next to `driver()`, used by `runRelationsReconcile` to resolve source and destination from the manager's own config. With the factory, any code holding the manager (a `forRequest()` view included) can build a relations driver that is **not** the active one and write to it with no `assertWrite`, no `requireActor`, no freeze and no `onRelationWrite`. It adds no real capability — `app.config` already hands out the factories — but it is one more thing to look for in a review. And `runRelationsReconcile(authorization, …)` takes the manager **structurally** (`Pick<AuthorizationManager, 'withFrozenWrites' | 'relationsDriverKey' | 'relationsDriverFactory'>`): an object that fakes `relationsDriverKey` or `relationsDriverFactory` decides which driver is active and which factories are compared, so pass it the service manager, never a hand-built or wrapped one.
164
321
 
165
322
  **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"*).
166
323
 
@@ -201,6 +358,7 @@ scopes: {
201
358
  descendantsOf: sqlDescendantsOf({ table: 'org_nodes', uuidColumn: 'uuid', parentColumn: 'parent_uuid', typeColumn: 'kind' }),
202
359
  maxScopes: 1000, // answer bound of authorizedScopes
203
360
  maxDescendants: 10000, // maxNodes handed to descendantsOf
361
+ maxDepth: 22, // optional (2.4.0-alpha.5): refuse attached/moved past this many parent hops from app — see Limits
204
362
  }
205
363
  ```
206
364
 
@@ -216,6 +374,54 @@ router
216
374
 
217
375
  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.
218
376
 
377
+ ### Per-resource enforcement: `resourceAccess` (2.5)
378
+
379
+ `appAccess` gates a route at the `app` scope. To gate one **resource** — *this document, in that
380
+ organization* — the `resourceAccess` middleware composes the same `authorize`: your code says how to
381
+ load the resource and which `{ scope }` it lives in, and the middleware asks the engine about that
382
+ scope. It is not a new model or a driver, it is the HTTP edge of a resource's scope.
383
+
384
+ ```ts
385
+ router
386
+ .get('/orgs/:orgId/documents/:id', [DocumentsController, 'show'])
387
+ .use(middleware.resourceAccess({
388
+ resource: 'document', // ctx.document = the loaded resource, for the controller
389
+ param: 'id', // ctx.params.id
390
+ containerParam: 'orgId', // optional: the tenant/parent in a nested route
391
+ permission: 'documents:write', // mutating methods
392
+ readPermission: 'documents:read',// safe methods (GET/HEAD); omitted ⇒ reads still need `permission`
393
+ load: (ctx, id) => Document.query().where('id', id).first(), // → { scope } | null
394
+ gate: (ctx) => ctx.auth.user!.isMemberOf(ctx.params.orgId), // optional pre-ability
395
+ }))
396
+ ```
397
+
398
+ **The order of the responses is the security property** — a 403 where a 404 belongs leaks which
399
+ resources exist (enumeration):
400
+
401
+ 1. **401** if there is no authenticated holder;
402
+ 2. **403** if your optional `gate(ctx)` (a prior ability, e.g. "is admin of this tenant") denies;
403
+ 3. **404** if the declared `containerParam` is absent from the route;
404
+ 4. **404** if `load` returns `null` — **the same body** as the container 404, so "does not exist" and
405
+ "not yours" are indistinguishable;
406
+ 5. `authorize` **once** over the scope `load` returned (with `readPermission` on safe methods): a
407
+ `false` here is **also a 404 with the same body**, never a 403 — that you cannot see it does not
408
+ reveal that it exists.
409
+
410
+ The non-negotiables mirror `appAccess`: `AuthorizationBackendError` (503) is **never disguised** as a
411
+ 404/403 (if `gate`/`load`/`authorize` throw, the error rises as-is — "denied" and "could not check"
412
+ stay distinct, so these calls are deliberately not wrapped in try/catch); **`role` is forbidden**
413
+ (`resourceAccess({ role })` is a 500 `E_AUTHZ_ROLE_IS_NOT_ACCESS` with the recipe, because membership
414
+ is not access and the deny does not govern it); and there is **no second `authorize`**. A throw from
415
+ `load` is "could not check" (503), never a 404. The middleware imports no consumer alias — `load`/
416
+ `gate` arrive injected in the route options.
417
+
418
+ > **Known limit — a timing channel.** The status and body of "does not exist" and "exists but is not
419
+ > yours" are identical, but the **time** is not: a non-existent id answers 404 without a round-trip to
420
+ > `authorize`, a foreign one answers the same 404 *after* that call. This is inherent to
421
+ > `load → authorize` (you cannot authorize the scope of something you have not loaded), not a defect;
422
+ > whoever needs to close the channel equalises the time in their own layer (a constant delay), not in
423
+ > the middleware.
424
+
219
425
  ## The catalog
220
426
 
221
427
  Roles and permissions are config-driven:
@@ -231,31 +437,42 @@ node ace authz:catalog:sync # sync every catalog in config.catalogs,
231
437
  node ace authz:catalog:sync --keep-links # 1.x additive mode
232
438
  node ace authz:catalog:diff # exit 1 on drift — run it in CI
233
439
  node ace authz:catalog:diff --fail-on-shadows # …and on roles shadowed by a more authoritative one
440
+ node ace authz:catalog:prune-orphans # list local roles whose owner scope is gone (--force to purge)
441
+ node ace authz:scopes:relay # drain the scope-tree outbox (see The scope tree)
442
+ node ace authz:reconcile --to=openfga --dry-run # verify a driver against authz_* and your tree (exit 1 on drift)
234
443
  ```
235
444
 
236
445
  `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"*).
237
446
 
238
447
  **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).
239
448
 
240
- `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). 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).
449
+ `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).
241
450
 
242
451
  ## Scoped roles (2.2)
243
452
 
244
- 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 without any write; moving it back restores it — the tree of *today* decides (*"un rol local de la organization A concede en A y sus units, no en B ni en app"*). 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).
453
+ 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).
245
454
 
246
- **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 with rank above it defines its own role and shadows it (below), and the platform can always `purgeRole`. If that trade is not acceptable for you, keep `maxDescendants` above the size of your biggest subtree and monitor the degradation through the `truncated` flag of `scopes.detached`.
455
+ **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.
247
456
 
248
457
  **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.
249
458
 
250
459
  **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.
251
460
 
252
- **When a scope disappears, its roles go with it.** `scopes.detached(child)` purges the local roles whose owner is that scope — before the facts, so a driver that cannot purge roles says 500 `E_AUTHZ_UNSUPPORTED` without having touched anything — and notifies `role_purged` for each (with the `actor` of that notification when there is one). A role whose owner is not in the tree is visible nowhere, so nothing is lost; what it fixes is that the row used to survive, `deleteScopedRole` answered 422 `E_AUTHZ_UNKNOWN_SCOPE` (it resolves the owner fresh) and that `(slug, level)` stayed blocked for the global catalog for ever. Three details make the promise real:
461
+ **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:
462
+
463
+ ```bash
464
+ node ace authz:catalog:prune-orphans # dry run: lists the local roles whose owner no longer resolves
465
+ node ace authz:catalog:prune-orphans --force # purges them (purgeRole each: assignments + links + row), notifies role_purged
466
+ node ace authz:catalog:prune-orphans --force --allow-mass-purge # ... even if that is *every* owner (read the next paragraph first)
467
+ ```
468
+
469
+ 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.
470
+
471
+ **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.
253
472
 
254
- - **The scope is canonicalised first.** The roles are purged under `chain[0]`, exactly like the facts (invariant 17), so notifying the same scope through an alias of its uuid — the dash-less form a PostgreSQL `uuid` column resolves to the same row — no longer purges the facts and leaves the roles alive.
255
- - **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, and it used to survive.
256
- - **The subtree goes too, if you declared `scopes.descendantsOf`.** Consumers notify the node they deleted, not one per leaf. With `descendantsOf` the roles owned by the descendants are purged as well; **without it the promise is bounded to the exact scope** and a `detached(parent)` leaves a child's roles orphaned — purge each node, or declare `descendantsOf`. If the subtree cannot be enumerated (more nodes than `maxDescendants`, or a `descendantsOf` that fails), the purge **degrades** to the exact scope and says so instead of failing: `scopes.detached` returns `{ purgedRoles, truncated: true }` and the `scope_purged` event carries `truncated: true`. **What the port asks of `descendantsOf` for a scope `resolveChain` no longer knows: nothing.** You own your table — a materialised path or a `where parent_id = ?` does not need the parent's row, so returning the children is fine, and so is returning `null`. The package assumes neither: if the scope does not resolve **and** nothing came back from below, the purge cannot be shown to be complete and `truncated` is `true` (audit P2 — it used to say `false`, "complete", while the child unit's role was alive and granting). What does come back is treated as the real subtree: those owners' roles are purged, each with its rank measured on its own owner's chain. Until 2.2 it was a 503 that purged *nothing* — neither roles nor facts — so declaring `descendantsOf` made the big tenant worse off than not declaring it (audit N3).
473
+ **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.
257
474
 
258
- **`scopes.detached` may hang off a tenant session, so it carries the same rank policy as `deleteScopedRole`** (`within` already contains it — invariant 15): with an `actor`, every role about to be purged must have a rank *below* the actor's, checked over all of them before touching any (422 `E_AUTHZ_RANK_EXCEEDED`, nothing purged); an admin with rank 5 cannot destroy through the tree the rank-40 role the delegation API denies them. Rank is measured **per role, on the chain of that role's own owner** — exactly like `deleteScopedRole`, which is the other door to the same thing — so if the tree **no longer knows a role's owner** there is nowhere to measure it and that role is purged with the check skipped, saying so in the return value and in the event (`reason: 'owner-detached-unknown'`, which also comes out when the notified scope itself is gone, even with `purgedRoles: 0`). Measuring it on the chain of the *notified* scope and applying it to roles owned by *other* scopes was a fail-open: with `descendantsOf` declared and the parent's row already deleted, `detached(parent)` destroyed the local roles of **live** descendants, of any rank, while both other doors refused with 422 (audit P1). `detached` is the operation that cleans up *after* you delete a scope: blocking it because the scope is gone left the role, its assignments and the scope's denies alive with no way out through the manager under `requireActor: true` (audit N2), and it opens nothing — a role whose owner is not in the tree is visible nowhere, and refusing only kept its `(slug, level)` blocked for the global catalog for ever. If the scope **is** still in the tree, the rank policy is enforced exactly as before. Without an `actor` it behaves as always — it is then a platform operation, and `requireActor` does not apply to it retroactively.
475
+ **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.
259
476
 
260
477
  ```ts
261
478
  // config/authorization.ts — the platform declares what may be delegated at all:
@@ -272,18 +489,18 @@ await authorization.deleteScopedRole(admin, lead.uuid, { within: orgA }) // pur
272
489
  await authorization.grant(bob, { uuid: lead.uuid }, unitA1, { within: orgA }) // the unambiguous form
273
490
  ```
274
491
 
275
- **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`). 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.
492
+ **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.
276
493
 
277
- 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* `scopes.detached` of that scope dead for ever, facts included. 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 — and the scope purges normally again. 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* every scope with a local role: `deleteScopedRole` is 500, `scopes.detached` is 500 **and the facts are not purged either** (`authorize` keeps answering `true`), and you cannot define another role there. The recipe, verified (audit N7):
494
+ 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):
278
495
 
279
496
  ```ts
280
497
  // One-off, from a command: purge with a driver that can, then carry on with the new one.
281
498
  const sql = new DatabaseAuthorizationDriver({ resolveChain }) // whatever your config/authorization.ts passes it
282
499
  for (const uuid of roleUuids) await sql.purgeRole(uuid) // assignments + links + row, atomically
283
- await authorization.scopes.detached(scope) // now the facts go too, under the new driver
500
+ await authorization.scopes.detached(scope) // the facts, with any driver
284
501
  ```
285
502
 
286
- Plan the driver switch like a fact migration (`authz:reconcile`, 3b), and until then 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.
503
+ 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.
287
504
 
288
505
  **`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.
289
506
 
@@ -291,6 +508,256 @@ Plan the driver switch like a fact migration (`authz:reconcile`, 3b), and until
291
508
 
292
509
  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.
293
510
 
511
+ ## Relations (ReBAC) (2.4)
512
+
513
+ Alongside role-based `authorize`, the package ships a **separate** relationship engine for
514
+ object-level sharing — the Drive case: *this document is shared with that user as `viewer`, with
515
+ that team as `editor`*. It is a distinct port (`RelationsDriver`), a distinct façade
516
+ (`RelationsManager`) and a distinct config (`defineRelationsConfig`); roles and relations never
517
+ answer each other's questions.
518
+
519
+ ```ts
520
+ const relations = defineRelationsConfig({
521
+ holderTypes: ['user', 'admin'],
522
+ objectTypes: [
523
+ { type: 'document', relations: [
524
+ { name: 'owner' },
525
+ { name: 'editor', includes: ['owner'] }, // includes, no `from` in v1
526
+ { name: 'viewer', includes: ['editor'] }, // editor ⊆ viewer
527
+ ] },
528
+ ],
529
+ database: { membersOf: true }, // membersOf is database-only (see below)
530
+ })
531
+
532
+ await rel.relate(user, 'viewer', { type: 'document', id }, tenant) // share with a user
533
+ await rel.relate({ object: team, relation: 'member' }, 'editor', doc, tenant) // share with a TEAM (userset)
534
+ await rel.relate(guest, 'viewer', doc, tenant, { expiresAt: new Date(Date.now() + 7 * 86_400_000) }) // time-boxed share
535
+ await rel.check(user, 'viewer', doc, tenant) // one Check; editor⊆viewer resolves server-side
536
+ ```
537
+
538
+ **Relation expiry (2.4.0-alpha.2, R-15).** A relation tuple may carry an `expiresAt`, with the
539
+ **same three states as `grant`** (invariant 10): omitted preserves a live expiry (an expired one
540
+ revives without expiry — it is a new share), `null` removes it, a `Date` sets it. Expiry is
541
+ **strict** — a share that expires *now* no longer grants — and is honoured by `check`,
542
+ `listObjects`, `listSubjects` and `membersOf` in both drivers, including a membership that expires
543
+ (`relate(u, 'member', group, tenant, { expiresAt })` stops granting through the userset at that
544
+ instant). Enforced in SQL by `database` (`expires_at > now`, the same `DATETIME(3)` column and codec
545
+ as assignments) and by the `not_expired` condition on every relation subject in the fused model for
546
+ `openfga` (`current_time` travels in every `Check`/`ListObjects`). The `RelationsManager` takes the
547
+ same `clock` as the roles manager (the provider passes `config.clock`), and both drivers implement
548
+ `withClock`. In `database`, **renewing an expiry is delete+insert, never an `UPDATE`** — the table
549
+ stays insert/delete-only, and the case that observes it is in the suite (the row changes its uuid).
550
+ A bad `expiresAt` (not a valid `Date`/`null`/omitted) is 422 `E_AUTHZ_INVALID_IDENTITY` before the
551
+ driver. `enumerateRelations` does **not** filter expired tuples: they reach `authz:relations:reconcile`
552
+ with their `expiresAt` and are counted in `skipped.expired`, never silently dropped. And a *live*
553
+ tuple whose object type or relation the **destination** does not declare is **not written** and is
554
+ counted in `skipped.undeclared` (L-0): with the destination config at hand the pass discards it
555
+ before the driver (so `--dry-run` and the real pass report the same numbers, and the type also shows
556
+ in `modelDrift`); without it, the destination driver's own F-05 422 funnels it and it is counted the
557
+ same. Unlike `expired`, that one is a live relation that did not arrive, so the command exits ≠ 0:
558
+ declare the type in the destination, republish its model, and run the pass again.
559
+
560
+ `group` is a **built-in** object type (the userset carrier: `group#member`, nesting allowed), so
561
+ teams work without declaring anything. Every operation takes a **`partition: ScopeRef`** — the
562
+ tenant — and it is **mandatory**: a relation in tenant A never resolves in tenant B (`APP_SCOPE` is
563
+ the mono-tenant value). The partition lives in the object id (`document:<partitionKey>|<uuid>`), not
564
+ in the model.
565
+
566
+ **The model is shared with the catalog, and so is its byte budget.** In the `openfga` driver,
567
+ relations fuse into the same `facts` model and the same store, so a single `Check` still answers
568
+ each question. The price is one budget: the 262,144-byte model holds **both** your permissions and
569
+ your object types. Measured (3 holder types, realistic permission names): the ceiling is **~450–470
570
+ permissions**, and since relation expiry (2.4.0-alpha.2) a three-relation object type costs about
571
+ **1.0 of a permission** (≈ 579 B vs ≈ 557 B; before the `not_expired` condition on relation subjects
572
+ it was ≈ 0.5) and `group` ≈ 0.34 (191 B; was 86 B). The condition adds `(holders + 1) × (type name +
573
+ "not_expired")` bytes per declared relation — ≈ 103 B per relation with three holders. So a catalog
574
+ of 447 realistic permissions has room for **24** three-relation object types (was 52), and one object
575
+ type costs the permission ceiling a single permission (472 → 471); a small catalog has room to
576
+ spare. The gate
577
+ watches the **fused** model — `defineRelationsConfig` that would push it over is 500
578
+ `E_AUTHZ_MODEL_TOO_LARGE` before anything is published (80 % warns), the same protection
579
+ `syncAuthzCatalog` already gives the permissions. A consumer that needs *many* object types **and**
580
+ is pinned to the permission ceiling is the documented case for a separate store; everyone else
581
+ shares.
582
+
583
+ **The boundary is enforced, not hoped for (the 🔴 the audit found, closed by construction).** In the
584
+ shared store a naive relations write could compose the id of a real `role_binding` and escalate to
585
+ `roles.authorize`. Two rules close it structurally: `defineRelationsConfig` **refuses** to declare a
586
+ reserved `facts` type or relation (`scope`/`role`/`role_binding`/`group`/`can_<P>`/`assignee`… → 422
587
+ `E_AUTHZ_RELATION_CONFIG`), and `relate`/`unrelate` **refuse** an object type or relation not
588
+ declared (422 `E_AUTHZ_RELATION_TYPE_UNKNOWN` / `E_AUTHZ_RELATION_UNKNOWN`) **before touching the
589
+ driver** — so the id of a `role_binding` is never composed by the relations driver, the collision
590
+ does not exist rather than being watched. This is F-05, and it is a **chokepoint**: every write path
591
+ funnels through it, and the published contract plants the exploit so a third-party relations driver
592
+ that does not enforce it **does not pass**. **It lives in the manager AND in both drivers** (L-0,
593
+ 2.4.0-alpha.2): `relate`/`unrelate` of `database` and `openfga` apply the very same function
594
+ (`assertRelationDeclared` — same class, same `code`, same message) **before touching the backend**
595
+ (zero `Write`, zero `INSERT`; the suite spies on the client and on the connection). Until L-0 the
596
+ port's docblock claimed the drivers re-validated and it was false in both: `manager.driver()` (the
597
+ platform escape hatch) and `authz:relations:reconcile` — which writes through the destination
598
+ *driver* — went straight past F-05, and in the shared store `driver.relate(evil, 'assignee',
599
+ { type: 'role_binding', id: <roleUuid> }, S)` turned `roles.authorize(evil)` from `false` to `true`
600
+ (measured against a real server; the bridge spec keeps that case, and removing the driver guard puts
601
+ it back in red). `manager.driver()` still skips the manager's *other* barriers (`actor`,
602
+ `assertWrite`, `onRelationWrite`), as documented — F-05 is no longer one of them.
603
+
604
+ **And since `2.4.0-alpha.3` F-05 covers **all eight operations of the port**, not only the two writes that
605
+ compose a tuple.** The alpha.3 audit found the chokepoint one call-site short (pre-existing in alpha.2, in
606
+ code alpha.3 had just rewritten): `purgeObject`/`purgeSubject`/`check`/`listObjects`/`listSubjects`/`membersOf`
607
+ never called `assertRelationDeclared`, so in the shared store `purgeObject({ type: 'role_binding', id: R }, S)`
608
+ composed — byte for byte — the id of a real binding and **deleted the real binding** with proof of zero
609
+ (`authorize` went from `true` to `false`, zero tuples left, and a clean `purgeObject` audit event), and
610
+ `listSubjects('assignee', role_binding)` enumerated the holders assigned to a role in a scope through the
611
+ relations port, with no catalog, no `within`, no `requireActor` and no role visibility. Now the same
612
+ check runs first on the eight, in the manager **and** in both drivers (`manager.driver()` is not an
613
+ exit, same pattern as L-0 and L-5) — and it is **two functions, not an optional**: `assertRelationDeclared`
614
+ is **strict** (object type *and* relation, and the relation is mandatory) for `relate`/`unrelate`/`check`/
615
+ `listObjects`/`listSubjects`/`membersOf` and for the userset of any subject, while `assertObjectTypeDeclared`
616
+ (type only) serves `purgeObject`, which names no relation (`purgeSubject` validates the userset of its
617
+ subject). The first closing lot had made `relation` optional on one shared function, and the auditor's second
618
+ pass showed what that opened: `relate(u, undefined, doc, p)` — `request.input('relation')` missing — went past
619
+ F-05 (it wrote on the doubles, came back as a 503 from the OpenFGA server, and was saved only by the
620
+ `database` driver's own grammar), and `listSubjects(undefined, doc, p)` returned the **union** of every
621
+ relation's holders. So a missing `relation` (`undefined`) is 422 `E_AUTHZ_RELATION_UNKNOWN` on the six that
622
+ name one, on both drivers, with zero calls — 422 `E_AUTHZ_RELATION_TYPE_UNKNOWN` / `E_AUTHZ_RELATION_UNKNOWN`,
623
+ **zero client calls, zero events**, everywhere. The exploit is pinned against the shared store on the real
624
+ server (*"por el MANAGER: purgeObject(role_binding:R) ⇒ 422 … authorize(U, read, S) SIGUE true"*, *"por el
625
+ DRIVER en directo"*, *"cierre-2 · relate/unrelate/check/listSubjects/listObjects con relation: undefined"*),
626
+ and the published runner plants it on the six for any third-party driver (*"F-05 ·
627
+ purgeObject/purgeSubject(userset)/check/listObjects/listSubjects/membersOf"*). `membersOf` runs F-05 **before**
628
+ its capability check, so a `role_binding` there is 422 even where `membersOf` itself would be 500.
629
+
630
+ **`membersOf` is `database`-only.** `membersOf(group, 'member', partition)` returns the **transitive**
631
+ membership (through nested groups). Only the `database` driver has it (a recursive CTE); in `openfga`
632
+ it is 500 `E_AUTHZ_UNSUPPORTED` naming it — the transitive form is `ListUsers`, which truncates
633
+ without a reliable signal, and we never return a silent partial. `listSubjects` (direct facts,
634
+ invariant 7) works in both. `listObjects` in `openfga` signals `truncated: true` when the server's
635
+ `ListObjects` cap cuts the page, never a mute partial list.
636
+
637
+ **The config is persisted, and republishing never mutilates the model.** `saveRelationsConfig(spec)`
638
+ stores the relations config in `authz_relations_config` under the shared version gate (invariant 14).
639
+ Because the catalog and the relations config share one model lifecycle, both `syncAuthzCatalog` and a
640
+ config save republish the fused model — and they race for the `modelId`. `republishFusedModel` reads
641
+ **both** persisted halves (catalog permissions + relation types) every time, so the published model
642
+ is never "the model of one, the tuples of another"; the `modelId` is pinned with a bounded CAS, and
643
+ contention that will not yield is 409 `E_AUTHZ_WRITE_CONFLICT`, never a half model.
644
+
645
+ **Migrating tuples between drivers** is `node ace authz:relations:reconcile --to=<key>` — the relations
646
+ analog of `authz:reconcile`, idempotent, bidirectional and never silent (it reports written / deleted
647
+ / unchanged / extra). `--to`/`--from` are keys of `relations.drivers` in `config/authorization.ts`;
648
+ `--dry-run` is the read-only verifier and also flags **model drift** (an object type in the source the
649
+ destination does not declare); `--prune` deletes what the source no longer backs. **Never towards the
650
+ ACTIVE relations driver** (2.4.0-alpha.10): once `relations.default ?? config.default` is `--to`, any
651
+ other source — with `--from` or picked by elimination — is 500 `E_AUTHZ_STALE_SOURCE_REFUSED` (it
652
+ would resurrect shares revoked since the cutover and, with `--prune`, delete the new ones); `--dry-run`
653
+ flags it, and `--allow-stale-source` is the human decision. It migrates **facts
654
+ only** — there is no tree or catalog in `relations/` — and works **per partition** (`--partition-type`
655
+ /`--partition-uuid`; default `app`). **From code, the one public way is `runRelationsReconcile`** —
656
+ `import { runRelationsReconcile } from '@jantstack/adonis-authz/commands/authz_relations_reconcile'` and
657
+ `runRelationsReconcile(authorization, { toKey, fromKey, partitions: [...] })` migrates several partitions in one
658
+ pass (see the sweep below), with the same guard and the same durable window as the command. Source and
659
+ destination are **keys of `relations.drivers`, resolved from the manager's own config**, and the guard compares
660
+ the *resolved* driver, not the label: two keys that point at the same factory are the same driver (an alias of the
661
+ active one is refused too; source = destination is 500 `E_AUTHZ_CONFIG`), and the active driver is always the
662
+ manager's — there is no `activeKey` to contradict it. **Declared limit: identity is the factory reference, not the
663
+ backend.** Two *different* factories that build the same store — `openfga: () => new OpenFgaRelationsDriver(…)` and an
664
+ alias `fga: () => new OpenFgaRelationsDriver(…)` with the same `OPENFGA_STORE_ID`, which is how an alias is naturally
665
+ written in a config — are **not** detected: `--to=fga --from=database` passes the guard and resurrects what was revoked.
666
+ Nothing is built before the guard, so there is no backend to compare. Register an alias with the *same* reference
667
+ (`const fga = () => …` and `{ openfga: fga, alias: fga }`) or do not register it; a backend identity declared by the
668
+ driver is on the 2.5 list. Until the closing lot of 2.4.0-alpha.10 the primitive
669
+ `reconcileRelations` was exported from the package root and this paragraph recommended it for K partitions: it
670
+ never saw the active driver nor took the window, so a loop written after the cutover resurrected revoked shares
671
+ with the fleet still writing. It is no longer public. **The guard reads the config of the process that runs it**:
672
+ `relations.default ?? config.default` *there* — a job or pod started with another environment (say, without
673
+ `AUTHZ_DRIVER`) believes another driver is active and the guard is off. Run it with the fleet's config; a durable
674
+ seal of the active driver is on the 2.5 list. **The pass that writes runs under the durable freeze** (2.4.0-alpha.2,
675
+ lot L-1), exactly like `authz:reconcile`: every write of the package — roles *and* relations — answers a
676
+ retryable 503 `E_AUTHZ_FROZEN` fleet-wide while it runs; a live **operator** window (`authz:freeze`) is its
677
+ own context (it runs inside, does not renew or lift it); a live freeze of another pass is 423; and the
678
+ report carries `frozen: { durable, lapsed, leaseMs, fence }` — **`lapsed: true` means the lease was lost
679
+ mid-pass and the pass is not certified** (exit ≠ 0). `--dry-run` does not freeze: the verifier is
680
+ read-only and is meant for a cron.
681
+
682
+ #### The source sweep, measured
683
+
684
+ In `openfga`, `enumerateRelations(partition)` — the *source* of the migration — is a `Read({})` of the
685
+ **whole store**, paginated by 100 (the server's cap) and filtered in memory by the whole partition key.
686
+ There is no cheaper `Read`: by type without `user` is a 400, and by **prefix** of the object id
687
+ (`document:unit|<uuid>|`) answers **200 with zero tuples** — silently, which in the source of a migration
688
+ means "this partition is empty" and in a purge "proved zero" without deleting anything. Never optimise it
689
+ that way; the suite spies the filter of every `read` and it is always `{}`. What *is* cheaper is doing the
690
+ sweep **once for K partitions**: `enumerateRelationsMany(partitions)` (optional port method, implemented by
691
+ `openfga`; `RelationsManager` exposes it and falls back to K calls for a driver without it) and
692
+ `runRelationsReconcile(authorization, { toKey, fromKey, partitions })`, which enumerates source and destination once, decides the K diffs
693
+ first (the mass-delete guard R-17 is evaluated for every partition **before** writing anything) and then
694
+ applies them; the report is the sum. Same output per partition, byte for byte, as K separate calls; `app`
695
+ does not pull `appliance|…`, `org|U` does not pull `organization|U`, and each spelling of a uuid is its own
696
+ partition. Measured against OpenFGA 1.19.0 (2.4.0-alpha.6):
697
+
698
+ | Store | Partitions asked | `enumerateRelations` × K (before) | `enumerateRelationsMany` (after) |
699
+ |---|---|---|---|
700
+ | 5,000 tuples (3 × 1,000 + 2,000 foreign) | 3 | 150 `Read`, 502–558 ms | **50 `Read`, 170–209 ms** |
701
+ | 15,000 tuples (panel measurement) | 3 | 453 `Read`, ≈ 1.4 s | **151 `Read`, ≈ 0.46 s** |
702
+ | 15,000 tuples | 50 | ≈ 23 s | ≈ 0.5 s (extrapolated from the same sweep) |
703
+
704
+ The cost is O(tuples in the store) **per sweep**, whatever the size of the partitions asked, and the sweep is
705
+ exhaustive or loud: a page with zero tuples *and* a continuation token is not the end; a token that
706
+ repeats, more than 10,000 pages, or a response without `tuples` is 503 `E_AUTHZ_BACKEND_UNAVAILABLE`,
707
+ never a shorter enumeration. Run it from a job or a cron, **never from a tenant request**.
708
+
709
+ **Writing relations inside your transaction (2.4.0-alpha.2, lot L-4).** `relate`/`unrelate`/`purgeObject`/
710
+ `purgeSubject` with **`{ transaction: trx }`** on the `database` relations driver write the tuple **inside
711
+ your open Lucid transaction** of the driver's connection — the same rule, the same guard, the same pool ≥ 2
712
+ requirement, the same census in the published suite and the same per-engine behaviour as facts, all in one
713
+ place: [Writing inside your transaction](#writing-inside-your-transaction-240-alpha2). What is specific to
714
+ tuples is there too: with a transaction present `relate` no longer opens its own, so the partition trigger
715
+ fires inside yours; renewing an expiry rolls back to the previous one; two crossed writes deadlock into a
716
+ 409. A pool-of-1 deployment declares `new DatabaseRelationsDriver(config, { transactionalWrites: false })`;
717
+ on `openfga` it is the 500 and [the recipe by direction](#openfga-the-recipe-by-direction). **And the unique
718
+ index guards holder tuples too (lot L-4b).** Until L-4b `subject_relation` was `NULL` for a holder and every engine treats two
719
+ `NULL`s as distinct in a unique index, so `authz_rel_tuple_uq` only guarded **userset** tuples: two concurrent
720
+ `relate`s of the *same holder tuple* in two open transactions both went in and committed **two identical
721
+ rows** (no over-grant, but `listSubjects`/`enumerateRelations` listed the subject twice and the reconcile
722
+ census counted it twice). Since L-4b the column is **`NOT NULL DEFAULT ''`** (`''` = holder; the only option
723
+ with parity on the three engines — MySQL has no partial index, `NULLS NOT DISTINCT` is PostgreSQL 15+) and the
724
+ driver writes `''`, so the same holder tuple in two transactions ends in **exactly one row**: the loser gets
725
+ the same **409 `E_AUTHZ_WRITE_CONFLICT`** as a userset (PostgreSQL/MySQL) or `SQLITE_BUSY` (SQLite file), pinned
726
+ on the three engines, with the mutant (column back to nullable ⇒ two rows) red. An installation that migrated
727
+ before L-4b runs [the recipe](#upgrading-from-240-alpha2-before-l-4b-authz_relationssubject_relation): the
728
+ driver tolerates old `NULL` rows on read meanwhile.
729
+
730
+ ### The relation write event (2.4.0-alpha.3)
731
+
732
+ `onRelationWrite` gives **the same guarantees as `onWrite`** — every one of them was missing or half-true on the relations port until `2.4.0-alpha.3`, and COGNITIV found the first two in real consumption. The event (`RelationWriteEvent`) mirrors the shape of `AuthzWriteEvent`: one type, optional fields, no discriminated union.
733
+
734
+ | `operation` | What the event carries | What it does not |
735
+ |---|---|---|
736
+ | `relate` | `subject`, `relation`, `object`, `partition`, `expiresAt` if you passed it, `actor` if you passed it | — |
737
+ | `unrelate` | `subject`, `relation`, `object`, `partition`, `actor` | — |
738
+ | `purgeObject` | `object`, `partition`, `actor` | no `subject`, no `relation` |
739
+ | `purgeSubject` | `subject`, `partition`, `actor` | no `object`, no `relation` |
740
+
741
+ A purge notifies **one event per call** — even when the driver sweeps both spellings of the partition uuid underneath, and even when it deletes many tuples — and it **does not count**: the port returns `void` (a count would break the port for third-party drivers, and inside `{ transaction }` it would be a number your rollback contradicts). If you need the "how many", call `enumerateRelations` (or `listSubjects`) before purging — inside your transaction, through your transaction. And a purge that deletes nothing notifies all the same: the auditable fact is *someone asked to delete the keys of X*, whether or not there were any. `scopes.detached` on the roles port emits one `scope_purged` per spelling because there the **manager** canonicalises; the relations manager does not, so the divergence is on purpose and pinned (*"E6 · UN evento por llamada aunque el driver barra DOS ortografías"*). A purge does not go through `assertWrite` — `RelationRef.operation` is `'relate' | 'unrelate'`, the published type of the R-13 hook — and that is pinned too (*"E9 · negativo declarado"*). But F-05 runs first on the purges too (2.4.0-alpha.3, closing the audit's critical finding): `purgeObject` validates the object type and `purgeSubject` the userset of its subject **before** the actor, the scope and the driver, so a purge of a `role_binding` is 422 with zero driver calls and zero events — see [F-05 on the seven operations](#relations-rebac-24) above. `purgeSubject` also validates its subject like `relate` does (a malformed holder or `{}` is 422 `E_AUTHZ_INVALID_IDENTITY` and never reaches the event).
742
+
743
+ **The four guarantees, on the four writes** (`relate`, `unrelate`, `purgeObject`, `purgeSubject`):
744
+
745
+ - **`transactional: true`** when the write went through your `{ transaction }` (lot L-4): at that instant the tuple exists only inside your transaction and is a fact if and only if you commit — the package never sees that; a sink that wants the last word hangs itself on `trx.after('commit')`, and the suite runs exactly that recipe: rollback ⇒ the deferred sink holds zero firm records and the census holds zero tuples, commit ⇒ one and one (*"B2 · rollback ⇒ ni tupla NI evento contabilizado como definitivo"*, *"E4 · purgeObject/purgeSubject dentro de una trx"*). Absent — not `false` — without a transaction, and `{ transaction: undefined }`/`null` count as none. The published runner judges it as the other face of the `transactionalWrites` capability: with `true`, the eight events of the four writes inside a transaction carry it; with `false`, a refused write emits nothing.
746
+ - **`indeterminate: true`** — invariant 13, now on this port too: a relation write that elapses its deadline notifies `onRelationWrite` with `indeterminate: true` before propagating the 503 `E_AUTHZ_BACKEND_TIMEOUT`, with the rest of the event intact (*"C1 · relate/unrelate que vencen el deadline notifican indeterminate: true"*, *"E5 · purgeObject/purgeSubject que vencen el deadline"*). Both drivers have a deadline to elapse: `database` through `guardSql` (`timeoutMs`), `openfga` through the SDK's `baseOptions.timeout` with `retryParams.maxRetry: 0` — measured against a socket that accepts and never answers, without a server (*"C4 · openfga (relations) contra un servidor MUDO"*). Inside your transaction the event carries `indeterminate: true` *and* `transactional: true` (PostgreSQL leaves your transaction aborted, MySQL kills the statement and leaves it alive, SQLite cannot elapse a deadline: `SQLITE_BUSY` is a 503 that is not a timeout and emits nothing — *"C3 · deadline vencido DENTRO de la trx externa"*); on `openfga` the event never carries `transactional` (gate 1 makes `{ transaction }` impossible there). A 422 (F-05, R-16, a bad `expiresAt`), an `assertWrite` that throws and the freeze emit **nothing** and call nothing: that write did not happen (*"C2"*). A 503 that is not a timeout emits nothing **when it arrives before the first statement or `Write`** — and that is the only case on `relate`/`unrelate` (one statement, one `Write`); on `database` a purge is one `DELETE`, so it holds there too; on `openfga` only when nothing had been deleted yet, because a purge on `openfga` is **not one request**: it is one `Read` + `deleteTuples` + `Read` (the proof of zero) per spelling of the partition uuid and, for `purgeSubject`, per declared type. When such a purge fails **after** it has already deleted part of its tuples — a connection error, or the 500 `E_AUTHZ_PURGE_INCOMPLETE` of the proof of zero — the driver marks the error (`markPartialWrite`, exported from the package: a property, the class and the `code` are unchanged) and the manager notifies `indeterminate: true` as well, before propagating, exactly like a deadline: *may have happened* is the honest word there (*"purgeObject: la demostración de cero falla tras el deleteTuples ⇒ … indeterminate: true"*, and its control: a failure on the first `Read` emits nothing). A third-party driver whose purge is multi-request has the same duty.
747
+ - **A hook that throws — sync or async — is logged and swallowed**: the write is already applied when the hook runs, so propagating would report a failure for an operation that did happen and invite a retry of something done. Until alpha.3 the hook was called with neither `await` nor `try/catch`: a failing audit sink turned an applied `relate` into an error for the caller, and an `async` hook that rejected vanished as an unhandled rejection. Now the returned promise is awaited, the failure is logged naming the operation (`logger.error`, or `console.error` outside an app), and the caller gets its result (*"D1 · un onRelationWrite SÍNCRONO que lanza no propaga"*, *"D2 · un onRelationWrite ASYNC que rechaza tampoco propaga"*). The flip side of that guarantee: the sink is **awaited**, so it is on the critical path of the write — its latency is the write's latency — and, with `{ transaction }`, inside your transaction: a sink that never resolves keeps it open (and, since `{ transaction }` requires a pool ≥ 2, a few of those exhaust the engine's connections and the freeze barrier starts answering 503 to everybody). There is no deadline on the hook, on purpose — parity with `hooks.onWrite`, and a deadline would be a design decision of its own — so defer anything slow to `trx.after('commit')` or to a queue, and never do I/O without a timeout in there.
748
+ - **`actor` is validated like any identity, then required**: a malformed `actor` is 422 `E_AUTHZ_INVALID_IDENTITY` before the driver, on the four writes, and never travels to the event (until alpha.3 only its *presence* was checked, so `{ uuid: 'A#B' }` poisoned the trail and `actor: {}` satisfied `requireActor`); `requireActor: true` + `actor: {}` is the identity 422, not "actor required" (*"D4"*). **Breaking**: `requireActor` now reaches `purgeObject`/`purgeSubject` — a mass deletion of keys is the write that most needs an actor — so a deployment with `requireActor: true` that purged without one gets 422 `E_AUTHZ_ACTOR_REQUIRED` (*"E3"*); the purges take `RelationPurgeOptions` (`transaction` + `actor`).
749
+
750
+ **`assertWrite`.** `assertWrite` is **synchronous, pure and has no `actor`** on purpose (R-13): it receives the full tuple reference (`subject`, `relation`, `object`, `partition`, `operation`, `expiresAt`) and throws to refuse; it runs after F-05 and before the driver, so a refusal is zero driver calls and zero events. Because a function returning `Promise<void>` is assignable to `void`, `assertWrite: async (ref) => { throw … }` compiles — and until alpha.3 the promise was discarded and the write went in: a policy gate that refused nothing, in silence. Since alpha.3 **returning a promise is 500 `E_AUTHZ_CONFIG`** before touching the driver — the message names `assertWrite`, "synchronous", and the recipe — whether the promise would have rejected or resolved (*"D3 · un assertWrite ASYNC no es un assertWrite"*). And more generally `assertWrite` **does not return verdicts**: it throws or it does not. `assertWrite: (ref) => allowed.has(ref.object.type)` compiles under `--strict` just the same, and until the alpha.3 closing lot returning `false` did not refuse anything — the write went in — so now returning **any value at all** — `false`, `true`, a thenable *function*, `null`, an object — is 500 `E_AUTHZ_CONFIG` before the driver, with zero calls and zero events (*"assertWrite que DEVUELVE algo (false, true, thenable-función, null) es 500"*). The policy that needs the actor, your database or an `await` belongs in your service, before calling `relate`/`unrelate`.
751
+
752
+ **Wiring.** The three live in `relations` of `defineConfig` — `relations.assertWrite`, `relations.onRelationWrite`, `relations.requireActor` — and not in `hooks`: one home for the port, no precedence rule (the published stub declares them there, and a case compiles it). Until alpha.3 the provider passed neither hook to the service manager (`authz.relations`), so through the service there was no policy gate and no audit trail at all; `requireActor` *was* wired (inherited from the root). `relations.requireActor` overrides the root per port, `relations.requireActor ?? requireActor`, exactly like `relations.requireTransactionalWrites`: unset inherits, `true` requires it here even if the root does not, `false` waives it here even if the root requires it (*"A3 · relations.requireActor anula el raíz por PUERTO, en los tres sentidos"*). Read the third direction as what it is: `relations.requireActor: false` under a root `requireActor: true` **overrides the root for relations, silently** — `warnOnOptInSecurity` does not cover it — so a template that carries the key waives the actor on the four relation writes without a warning; write it only on purpose. `manager.driver()` still skips all three, as documented above.
753
+
754
+ **Still not in 2.4.** `includes` with `from` (cross-object inheritance like `viewer from parent`),
755
+ which would add a TTU between object types and force re-measuring depth. Relation expiry (R-15)
756
+ **landed in 2.4.0-alpha.2** (see above); an installation that migrated 2.4.0-alpha.1 adds the column with
757
+ the recipe in [Upgrading](#upgrading-from-240-alpha1-authz_relationsexpires_at). `{ transaction }` on
758
+ `relate`/`unrelate`/`purge*` **landed with lot L-4** (above); on `openfga` it is the honest 500 and
759
+ [the recipe by direction](#openfga-the-recipe-by-direction).
760
+
294
761
  ## Errors
295
762
 
296
763
  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.
@@ -307,27 +774,41 @@ Every error the package raises carries `status` and `code`. A standard AdonisJS
307
774
  | `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) |
308
775
  | `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) |
309
776
  | `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) |
310
- | `E_AUTHZ_RANK_EXCEEDED` | 422 | a local role's rank outside `0 < rank < min(actor's rank, highest global rank)`, or touching a role of rank ≥ the actor's — `updateScopedRole`, `deleteScopedRole` and, with an `actor`, every role a `scopes.detached` would purge (2.2) |
777
+ | `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` |
311
778
  | `E_AUTHZ_UNKNOWN_SCOPE` | 422 | write on a scope the resolver does not know; unknown parent in `scopes.*` |
312
779
  | `E_AUTHZ_NO_SCOPE_RESOLVER` | 422 | driver without `resolveChain` asked about a non-`app` scope |
313
780
  | `E_AUTHZ_SCOPE_CYCLE` | 422 | `scopes.attached/moved` would close a cycle; `hierarchicalScopeResolver` met a cycle |
314
781
  | `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) |
315
782
  | `E_AUTHZ_WITHIN_REQUIRED` | 422 | `requireWithin` set and a write without `within` (2.1) |
316
783
  | `E_AUTHZ_WITHIN_ROOT_FORBIDDEN` | 422 | `requireWithin: 'non-root'` and `within: APP_SCOPE` (2.1) |
317
- | `E_AUTHZ_ACTOR_REQUIRED` | 422 | `requireActor: true` and a write without `actor` (2.1) |
784
+ | `E_AUTHZ_ACTOR_REQUIRED` | 422 | `requireActor: true` and a write without `actor` (2.1); on the relations port (`relations.requireActor ?? requireActor`) the four writes, `purgeObject`/`purgeSubject` included since 2.4.0-alpha.3 |
785
+ | `E_AUTHZ_SCOPE_DEPTH_LIMIT` | 422 | `scopes.attached`/`scopes.moved` with `scopes.maxDepth` declared: the child would end up more than `maxDepth` `parent` hops from `app` (the parent's chain + 1). Refused **before** writing or enqueueing; the gate is opt-in and partial — see [Limits](#limits) (2.4.0-alpha.5). Since 2.4.0-alpha.8 the `message` is written for the tenant that made the request — the depth reached, `scopes.maxDepth`, "nothing written or enqueued" — and names no backend: *why* the ceiling exists (`FACTS_MAX_RESOLVE_DEPTH`, `OPENFGA_RESOLVE_NODE_LIMIT`) travels in `error.details` for the operator's log |
318
786
  | `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) |
319
787
  | `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` |
320
788
  | `E_AUTHZ_BACKEND_TIMEOUT` | 503 | `timeoutMs` elapsed (subclass of the above) |
789
+ | `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) |
790
+ | `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 |
321
791
  | `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 |
322
- | `E_AUTHZ_STORE_NOT_EMPTY` | 409 | `openfga:import` on a store with tuples, without `--reconcile` |
323
- | `E_AUTHZ_CONFIG` | 500 | contradictory config (`holderTypes` not injective or a holder type not declared in it, `scopes.*` without resolver, `appAccess` without `permission`, `openfga:import --prune` without `--reconcile`, `catalog` together with `catalogRevalidate`, an invalid `maxAgeMs`); `bumpAuthzCatalogVersion` called without the writing transaction's client |
792
+ | `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. Also (2.4.0-alpha.2, lot L-3) a `database` `grant`/`deny` **inside your transaction** (`{ transaction }`) that hits the UNIQUE because another transaction committed the same fact while yours was open: outside a transaction the driver re-reads and keeps the winner's row; inside yours it cannot (PostgreSQL has already aborted your transaction — every later statement is `25P02` until you roll back — and under REPEATABLE READ the re-read would not see the winner), so it **poisons your transaction**: roll back and retry. Measured on all three engines, see [writing inside your transaction](#writing-inside-your-transaction-240-alpha2). The same for a `database` `relate`/`unrelate`/`purge*` inside your transaction (lot L-4) — on a UNIQUE clash of a tuple (userset, and since lot L-4b holder too: `subject_relation` is `''`, never `NULL`), and on a **deadlock** (two transactions writing two tuples in crossed order: PostgreSQL `40P01`, MySQL `1213`; the engine's victim gets this 409, never a hang nor the raw error) |
793
+ | `E_AUTHZ_CONFIG` | 500 | `relayScopeChanges({ only: [] })`, a `ScopeOutbox` that ignores `pending(limit, after, { ids })` and returns an entry that was not asked for, `requeueScopeChanges` with an id that is not parked (or an empty list), or a `scopes.maxDepth` that is not an integer ≥ 1 (2.4.0-alpha.5); 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; a `{ transaction }` handed to `sqlScopeOutbox` that is not an open Lucid transaction of the outbox's own connection (another connection, a `QueryClient`, or the whole `db` — the message names both connections), **or one that is already committed or rolled back** (`isCompleted`, 2.4.0-alpha.8 — the same check in the roles and relations drivers; it used to surface as a 503); `sqlScopeOutbox({ requireTransaction: true })` enqueueing without `{ transaction }` (2.4.0-alpha.8; the default becomes `true` in 2.5); `relayScopeChanges({ deadDetail })` with a value other than `'full'`; `requireTransactionalWrites: true` (root, or `relations.requireTransactionalWrites`) with an active driver that does not declare `transactionalWrites: true` — raised **when the driver is resolved**, so reads fail too and the deployment does not start (2.4); a `relations.assertWrite` that returns a promise — or any value at all: `false`, `true`, a thenable function (it must be synchronous and it does not return verdicts: it throws or it does not; the write is refused before the driver, 2.4.0-alpha.3) |
324
794
  | `E_AUTHZ_ROLE_IS_NOT_ACCESS` | 500 | `appAccess({ role })` |
325
795
  | `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) |
326
- | `E_AUTHZ_PURGE_INCOMPLETE` | 500 | `purgeScope` could not prove zero |
327
- | `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`, `scopes.detached` of a scope that owns local roles and, before writing anything, `defineScopedRole`; the `openfga` driver until 3b) |
796
+ | `E_AUTHZ_PURGE_INCOMPLETE` | 500 | `purgeScope` could not prove zero; on the relations port, `purgeObject`/`purgeSubject` of `openfga` could not prove zero after deleting — the error carries the `markPartialWrite` mark and the event was emitted with `indeterminate: true` (2.4.0-alpha.3) |
797
+ | `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 |
798
+ | `E_AUTHZ_STALE_SOURCE_REFUSED` | 500 | `authz:reconcile --to=<config.default>` (or `authz:relations:reconcile --to=<relations.default ?? default>`) with any resolved source other than the destination itself — `--from`, picked by elimination, or `authz_*` (2.4.0-alpha.10): the stale direction — the source stopped seeing the active driver's facts at the cutover, so the pass would resurrect every revocation made since. Nothing was read, frozen or written; for roles the maintenance pass is `--to=<active>` without `--from`, and `--allow-stale-source` is the human decision (`--dry-run` flags `report.staleSource` instead of throwing) |
799
+ | `E_AUTHZ_OPERATOR_WINDOW_EXPIRED` | 503 | `freeze()` — and with it `authz:reconcile`, `authz:relations:reconcile`, `withFrozenWrites`, `authz:freeze` — found the OPERATOR window expired (2.4.0-alpha.10): taking a freeze over it would erase the trace that writers resumed mid-cutover. Acknowledge it with `authz:unfreeze`, reopen with `authz:freeze` |
800
+ | `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 |
801
+ | `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 |
802
+ | `E_AUTHZ_TOO_MANY_LOCAL_ROLES` | 500 | more local roles than `maxLocalRoles` (10 000) in a `prune-orphans` pass; never a partial list |
803
+ | `E_AUTHZ_UNSUPPORTED` | 500 | a primitive needs an optional port method the active driver lacks: `listDenies` (2.1; also behind `defineScopedRole`), `purgeRole` (2.2 — behind `deleteScopedRole`, `authz:catalog:prune-orphans` and, before writing anything, `defineScopedRole`; the `openfga` driver until 2.3); `reconcile` (2.3 — `authz:reconcile --to=<driver>`); `enumerateFacts` (2.3 — being the *source* of `authz:reconcile`; the `database` driver does not implement it: its facts are `authz_*`. Also raised when `--to` is the **active** driver and declares `hierarchyFacts`: its facts are its own, so the pass has to be able to read them instead of rebuilding it from `authz_*`). Also `{ transaction }` on `grant`/`revoke`/`deny`/`removeDeny` or `relate`/`unrelate`/`purge*` when the active driver declares `transactionalWrites: false` (2.4 — the message names the driver and the operation, nothing was called; the way out is the `database` driver or `requireTransactionalWrites`; the `openfga` drivers raise it themselves too, so `manager.driver()` is not a way out — see [the recipe by direction](#openfga-the-recipe-by-direction)), and `{ transaction }` on the delegation API (`defineScopedRole`/`updateScopedRole`/`deleteScopedRole`: the catalog is written through `withAuthzCatalogWrite`, the cross-process serializer, which a caller's transaction would defeat) |
804
+ | `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 % |
805
+ | `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 |
806
+ | `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 |
807
+ | `E_AUTHZ_REQUEUE_REFUSED` | 500 | `requeueScopeChanges` / `authz:scopes:requeue`: an entry applied *after* the parked one shares a scope key with it, so requeueing would re-apply the old change on top of the new (the store reverted, with a single edge). Nothing was requeued; the error carries `conflicts` (the diff); `authz:reconcile --to=<driver>` is the remedy and `--force` (ace command only) the override (2.4.0-alpha.5) |
808
+ | `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 |
328
809
  | `E_AUTHZ_NO_DESCENDANTS_RESOLVER` | 500 | `authorizedScopes`/`expandExcludedSubtrees` without `scopes.descendantsOf` |
329
810
  | `E_AUTHZ_VIEW_EXPIRED` | 500 | a `forRequest()` view used to read (`expandExcludedSubtrees` included) after its `maxAgeMs` (default 30 s, monotonic clock) |
330
- | `E_AUTHZ_UNSUPPORTED_DIALECT` | 500 | `sqlDescendantsOf` on a dialect other than PostgreSQL / MySQL 8 / SQLite |
811
+ | `E_AUTHZ_UNSUPPORTED_DIALECT` | 500 | `sqlDescendantsOf` / `sqlScopeEdges` on a dialect other than PostgreSQL / MySQL 8 / SQLite |
331
812
  | `E_AUTHZ_SCOPE_TOO_DEEP` | 500 | `hierarchicalScopeResolver` over `maxDepth` (no truncated chain) |
332
813
 
333
814
  ## Driver options
@@ -340,6 +821,56 @@ Both take **`now`** (default `() => new Date()`): the wall clock every time-base
340
821
 
341
822
  `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.
342
823
 
824
+ **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".
825
+
826
+ 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.
827
+
828
+ 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.
829
+
830
+ **What the driver actually promises, and what it does not (2.3).** The exact claim, and nothing more:
831
+
832
+ > 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.
833
+
834
+ 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.
835
+
836
+ Every driver **declares** this through `driver.capabilities`, and each declared value has a case in the contract suite — never a skip:
837
+
838
+ | Capability | `database` | `openfga` | What the judge fixes |
839
+ |---|---|---|---|
840
+ | `hierarchyFacts` | `false` | **`true`** | the tree is the backend's; the manager then requires the outbox (or your signature) |
841
+ | `singleCheckAuthorize` | `false` | **`true`** | `authorize` makes one backend call and zero `resolveChain` |
842
+ | `roleInheritanceNative` | `false` | `false` | the five membership reads **do** consult your tree (a spy counts the calls) |
843
+ | `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) |
844
+ | `purgeRole` | `true` | **`true`** | with (c2) a binding points at its role, so a role's bindings *can* be enumerated |
845
+ | `countRoleAssignments` | `true` | **`true`** | how many live facts a role has — what makes `stillGranting` true instead of a guess (2.3; same enumeration `purgeRole` needs) |
846
+ | `canonicalScopeReads` | `true` | **`false`** | whether a read canonicalises the caller's scope spelling against your tree before looking for facts (see below) |
847
+ | `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 |
848
+ | `transactionalWrites` | **`true`** (default; declare `transactionalWrites: false` in the driver options on a pool of 1) | **`false`** | `grant`/`revoke`/`deny`/`removeDeny` with **`{ transaction }`** write the fact **inside the caller's transaction** — *both or neither*, never *"not lost"* (2.4, the `{trx}` panel). `database` does it since 2.4.0-alpha.2 (lot L-3): the write goes through your open Lucid transaction of the driver's connection (`assertCallerTransaction`: another connection, a `QueryClient` or the whole `db` are 500 `E_AUTHZ_CONFIG` before any statement), the authority never does, and the published suite pins **rollback ⇒ zero rows by census** for the four writes — see [writing inside your transaction](#writing-inside-your-transaction-240-alpha2). `openfga` cannot be anything but `false`: a tuple does not enter a SQL transaction and there is no 2PC; what it offers instead is [the recipe by direction](#openfga-the-recipe-by-direction). With `false`, `{ transaction }` is **500 `E_AUTHZ_UNSUPPORTED`** naming driver and operation, with **zero driver calls** (the manager's gate *and* the driver's own) — never ignored, never a warning; `requireTransactionalWrites: true` in the config makes it **500 `E_AUTHZ_CONFIG` when the driver is resolved** (the deployment does not start). Same name and same two gates on `RelationsDriverCapabilities` (`relate`/`unrelate`/`purgeObject`/`purgeSubject`; `relations.requireTransactionalWrites`, inherited from the root) — and since lot L-4 the `database` **relations** driver declares **`true`** too (`new DatabaseRelationsDriver(config, { transactionalWrites: false })` on a pool of 1; see [writing relations inside your transaction](#relations-rebac-24)). The only pair whose **two** faces are mandatory in the suite. Not to be confused with `scopes.*`'s `{ transaction }`, which **enqueues** in the outbox — enqueueing ≠ writing |
849
+
850
+ **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.
851
+
852
+ > **(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"*).
853
+
854
+ > **(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"*).
855
+
856
+ **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.
857
+
858
+ ### Reading capabilities at runtime (2.4.0-alpha.4)
859
+
860
+ The table above is what each driver **declares**; your service code can read it from the manager instead of guessing from the driver name — and instead of calling `driver()`:
861
+
862
+ ```ts
863
+ // The same service code serves both drivers, without a single driver() in it.
864
+ if ((await authorization.capabilities()).transactionalWrites) {
865
+ await authorization.grant(user, 'editor', unit, { transaction: trx }) // both or neither, with YOUR transaction
866
+ } else {
867
+ await authorization.grant(user, 'editor', unit) // openfga: the recipe by direction
868
+ }
869
+ if (relations.capabilities().transactionalWrites) { /* relate(…, { transaction: trx }) */ }
870
+ ```
871
+
872
+ `authorization.capabilities()` is `async` — it resolves the active driver through `driver()`, so it pays the driver factory once (cached from then on) and **inherits its two gates**: with `requireTransactionalWrites: true` and a driver that does not comply it is the same 500 `E_AUTHZ_CONFIG` the deployment already fails with, and a `facts` driver without `scopes.outbox` or the signature is the same 500 `E_AUTHZ_SCOPE_DRIFT_UNGUARDED`. That is deliberate: capabilities belong to the *instance* (`database` decides `transactionalWrites` from its options), and a reading that resolved the driver by any other path would leave `driver()` serving an instance that never passed the gates. Call it once at boot (a `booted`/`ready` hook), not per request. `relations.capabilities()` is synchronous: the relations driver is built eagerly and its gate already ran. Both return a **read-only, frozen, normalised copy** typed as `AuthorizationDriverCapabilities` / `RelationsDriverCapabilities` (both exported): every known key is present, only `true` is `true` — the same reading the write gates apply — so a third-party driver with partial or no `capabilities` reads as `false`, **never `undefined`**; mutating the copy throws in strict mode and changes nothing. The read does not soften anything: `{ transaction }` to a driver that declares `false` is still 500 `E_AUTHZ_UNSUPPORTED` whether or not you asked first, and **`driver()` is still platform API** — tenant code should not call it, and a code review can keep grepping for it.
873
+
343
874
  ## Performance
344
875
 
345
876
  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):
@@ -391,13 +922,16 @@ runAuthorizationDriverContract({
391
922
  level: '2.2', // '2.1' = up to Phase 2, '2.0' = up to Phase 1; omit for the 1.x cases only
392
923
  capabilities: { // what the driver declares; each one has its own cases
393
924
  hierarchyFacts: false,
394
- transactions: false,
925
+ transactionalWrites: false, // can the driver write grant/revoke/deny/removeDeny INSIDE the caller's transaction (2.4)? false ⇒ { transaction } is 500 E_AUTHZ_UNSUPPORTED, with zero driver calls; true ⇒ the suite opens a transaction and pins rollback ⇒ ZERO rows by census (the `transactions` hook below; default Lucid's db.transaction() + authz_assignments/authz_denies)
395
926
  truncationSignal: false,
396
927
  singleCheckAuthorize: false,
397
928
  injectableClock: false,
398
929
  exhaustiveLists: true, // false ⇒ also pass `limits: { listMaxResults }`
399
930
  listDenies: true, // the port's optional listDenies; judged at '2.1' and above (declare false below it)
400
931
  purgeRole: true, // purgeRole really purges (2.2); false ⇒ it must say so with 500 E_AUTHZ_UNSUPPORTED
932
+ countRoleAssignments: true, // the port's optional countRoleAssignments (2.3); false ⇒ pruneOrphanRoles reports `undefined`, never `false`
933
+ canonicalScopeReads: true, // reads canonicalise the caller's scope spelling against your tree (2.3); false ⇒ an alias finds no facts (fail-closed)
934
+ enumerateFacts: false, // the port's optional enumerateFacts (2.3): can this driver be the SOURCE of authz:reconcile?
401
935
  },
402
936
  // The suite builds the scope tree case by case; hand it to your driver.
403
937
  makeDriver: (tree) => new MyDriver({ resolveChain: resolveChainFrom(tree) }), // tree.chainOf(scope) = the canonical chain
@@ -405,6 +939,9 @@ runAuthorizationDriverContract({
405
939
  // process would be). Default: a prototype view of the driver with a fresh `CatalogCache` when it exposes `catalog`.
406
940
  makeTwin: (driver, tree) => new MyDriver({ resolveChain: resolveChainFrom(tree), sameBackendAs: driver }),
407
941
  seedCatalog: (catalog) => syncAuthzCatalog(catalog),
942
+ // Only with `transactionalWrites: true` (2.4): how the suite opens a transaction of YOUR driver's connection
943
+ // and counts a holder's facts in YOUR backend (the census). Omit it for Lucid's `db.transaction()` + `authz_*`.
944
+ transactions: { begin: async () => openMyTransaction(), census: async (subject) => countMyFacts(subject) },
408
945
  cleanup: () => wipeEverything(),
409
946
  })
410
947
  ```
@@ -413,23 +950,185 @@ Declaring a capability `true` that the suite has no case for makes registration
413
950
 
414
951
  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).
415
952
 
953
+ 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"*).
954
+
955
+ 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.
956
+
957
+ ### The migration contract: `runMigrationContract` (2.3)
958
+
959
+ 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:
960
+
961
+ ```ts
962
+ import { runMigrationContract } from '@jantstack/adonis-authz/testing'
963
+
964
+ runMigrationContract({
965
+ name: 'mine ⇄ theirs',
966
+ a: 'mine',
967
+ b: 'theirs',
968
+ seedCatalog: (catalog) => syncAuthzCatalog(catalog),
969
+ setup: async (tree) => ({ reconcile: (o) => manager.reconcile(o), drivers: { mine, theirs } }),
970
+ cleanup: () => wipeEverything(),
971
+ expectedLosses: [
972
+ { reason: 'expired', why: 'an assignment whose expiry has passed grants nothing, so it is not migrated' },
973
+ ],
974
+ })
975
+ ```
976
+
977
+ 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:
978
+
979
+ - **an answer that changes and that no declared loss explains fails the contract** — this is the whole point;
980
+ - **every reason the pass counts in `report.skipped` that you did not declare fails it too**;
981
+ - and a loss you declared that **never happens** fails as well: a loss that does not occur is a false line in your README.
982
+
983
+ 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:
984
+
985
+ - **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;
986
+ - **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.
987
+
988
+ 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.
989
+
416
990
  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.
417
991
 
418
- 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 (`openfga:import`), not a change at the call-sites the manager exposes.
992
+ 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.
419
993
 
420
994
  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).
421
995
 
996
+ ### The outbox contract: `runScopeOutboxContract` (2.4.0-alpha.5)
997
+
998
+ The `ScopeOutbox` port has duties nobody judged until alpha.5 — the receipt, oldest first, `after` as a continuation, an idempotent `markApplied`, a `markFailed` that counts and keeps the row, the actor that travels — and since alpha.5 every optional method is a **capability pair**: a `true` face that judges the duty and a `false` face that judges that the manager *says so* (or that the implementation degrades towards safe), never a skip. A third-party outbox proves itself the same way the package's does:
999
+
1000
+ ```ts
1001
+ import { runScopeOutboxContract } from '@jantstack/adonis-authz/testing'
1002
+
1003
+ runScopeOutboxContract({
1004
+ name: 'my-outbox',
1005
+ capabilities: {
1006
+ transactionalEnqueue: true, // enqueue writes through context.transaction: rollback ⇒ nothing pending (needs `transactions`)
1007
+ filter: true, // pending(limit, after, { ids }) filters in the port; false ⇒ `only` is 500 E_AUTHZ_CONFIG
1008
+ transient: true, // markFailed(..., { transient: true }) does not count; false ⇒ it counts anyway
1009
+ dead: true, // dead() exists: parked after limits.maxAttempts; false ⇒ nothing is ever parked
1010
+ acquire: true, // acquire() is a held, exclusive lease; false ⇒ the relay never says busy
1011
+ earlierPending: true, // the dependency probe of `only`; false ⇒ the manager pages pending(), same verdict
1012
+ requeue: true, // requeue + laterApplied (needs dead); false ⇒ requeueScopeChanges is 500 E_AUTHZ_UNSUPPORTED
1013
+ createdAt: true, // entries carry createdAt (the enqueue stamp): the relay reports the queue age; false ⇒ queue is { null, null } (2.4.0-alpha.8)
1014
+ },
1015
+ limits: { maxAttempts: 3 }, // with dead: true — what your outbox parks at (≥ 3)
1016
+ makeOutbox: () => myOutbox({ maxAttempts: 3 }), // fresh, over an empty queue
1017
+ cleanup: () => emptyMyQueue(),
1018
+ transactions: { begin: async () => openMyTransaction() }, // with transactionalEnqueue: true
1019
+ })
1020
+ ```
1021
+
1022
+ Seventeen cases per harness (2.4.0-alpha.8): nine core duties — including O-07b, the root `app` is not a scope key — and one face per each of the eight capabilities. Declaring `dead: true` without `limits.maxAttempts`, `requeue: true` without `dead: true`, or `transactionalEnqueue: true` without `transactions` throws at registration — a promise without a judge does not pass. The package runs it on `sqlScopeOutbox` over the four engines and on an in-memory double (`makeScopeOutbox`, exported too) with all capabilities and with none, so both faces of every pair run on every `npm test`.
1023
+
422
1024
  ## OpenFGA tooling
423
1025
 
424
1026
  ```bash
425
- node ace openfga:provision # creates a store + writes the model from your holderTypes
426
- node ace openfga:import --dry-run # counts what would be copied from the authz_* tables
427
- node ace openfga:import # empty store only
428
- node ace openfga:import --reconcile # non-empty store: compare tuple by tuple, rewrite what differs, count what SQL no longer has
429
- node ace openfga:import --reconcile --prune # ...and delete it: the run that converges
1027
+ node ace openfga:provision # creates a store + writes the model for your holderTypes AND your permissions
1028
+ node ace openfga:provision --store-id 01H… # writes a new model version into an existing store
1029
+ node ace authz:catalog:sync # …and projects the catalog into it (role→permission tuples + the root marker)
1030
+ ```
1031
+
1032
+ **`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.
1033
+
1034
+ **`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.
1035
+
1036
+ ### Migrating and verifying: `authz:reconcile` (2.3)
1037
+
1038
+ ```bash
1039
+ node ace authz:reconcile --to=openfga --dry-run # the VERIFIER: reads everything, writes nothing, exit 1 on drift
1040
+ node ace authz:reconcile --to=openfga --from=database # migrate: rebuild the store from authz_* and your tree
1041
+ node ace authz:reconcile --to=openfga --from=database --prune # …and delete the facts that source no longer backs
1042
+ node ace authz:reconcile --to=openfga # already serving from it? then it is the MAINTENANCE pass (see below)
1043
+ node ace authz:reconcile --to=database # the way back: rebuild authz_* from the store's facts
1044
+ node ace authz:reconcile --to=database --prune # …and delete the rows the store no longer backs
1045
+ ```
1046
+
1047
+ 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.
1048
+
1049
+ **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).
1050
+
1051
+ **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.
1052
+
1053
+ **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`.
1054
+
1055
+ **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`):
1056
+
1057
+ - **`--to` is the *active* driver and its facts live in its own backend** (`capabilities.hierarchyFacts`, which is what `openfga` declares): then its facts are its own and it is read from *itself*. This is the **maintenance pass**: it rebuilds everything **derived** — root marker, catalog projection, tree — and applies the visibility sweep of invariant 18 with the tree and the catalog of *today*, and **it writes no fact; with `--prune` it deletes the facts of scopes that no longer resolve**, the expired ones and those of roles or permissions the catalog or the model no longer has (and the leftovers) — without `--prune` it deletes no assignment or deny (only the role-visibility edges invariant 18 forbids) and reports them as `extra-fact`. (Until 2.4.0-alpha.9 this line, and the command's first line, claimed the pass deleted nothing while `--prune` deleted them — measured by a consumer.) It is also what makes `--dry-run` usable as the CI verifier of a `facts` deployment: a correct store now comes out **clean** instead of reporting every live fact as `extra-fact`. (A driver in that position that cannot enumerate its own facts is 500 `E_AUTHZ_UNSUPPORTED` naming `enumerateFacts` — reading `authz_*` for it would be the bug.)
1058
+ - **`--from=<driver>` is given**: you decide. If that driver's facts are `authz_*` (the package's `database`), `--to=openfga` is the **one-way migration** it always was, and it will happily overwrite the destination's facts with what those tables hold. That is what you want while migrating — and it is a loaded gun pointed at a store that is already live, which is why it now takes an explicit `--from` to fire it. **Except towards the active driver (2.4.0-alpha.10, breaking):** if `--to` is `config.default`, the only legitimate source is `--to` itself (the maintenance pass above); any other **resolved** source — named with `--from`, picked by elimination without `--from` (with `database` active, `--to=database` alone picks `openfga`), or the `authz_*` tables — makes the manager refuse with 500 `E_AUTHZ_STALE_SOURCE_REFUSED` before resolving the source, reading or freezing anything. That is the **stale direction**: once the engine serves from `--to`, its facts are written there and the source stopped seeing them, so the pass would re-write what was revoked since the cutover (with or without `--prune`; and the mass-deletion guard does not fire, because the source is stale, not empty). The guard looks at `--to` and at the source it resolves, never at the flag — and at the *resolved* driver, not the key: the same factory registered under two keys is the same driver, so an alias of `config.default` is refused too (**declared limit**: identity is the factory *reference*, so two different factories that build the same backend are not detected and such an alias passes; register an alias with the same reference, and a backend identity declared by the driver is on the 2.5 list) — so the four rows are: maintenance pass — runs; initial cutover (`--to=<new>` while the engine still serves from the old one) — runs; going back **before** switching `default` — runs; re-running either of the two **after** switching `default` — refused. `authz:relations:reconcile` applies the same rule with the active relations driver (below). `--allow-stale-source` (`allowStaleSource: true`) is the human decision; `--dry-run` never throws, it flags `report.staleSource = { to, from, allowed }`, prints a warning and exits non-zero. **The guard is only as reliable as the `config.default` of the process that runs the command**: it has no durable record of what the fleet serves from, so a migration job or pod started with a different environment (`default: env.get('AUTHZ_DRIVER', 'database')` without that variable) believes another driver is active and the guard is silently off. Run it with the fleet's config; a durable seal of the active driver is on the 2.5 list.
1059
+ - **Otherwise** (the destination is not the active driver): the migration of always, facts read from `authz_*`.
1060
+
1061
+ **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.
1062
+
1063
+ **`sqlScopeEdges` covers ONE table, and concatenating several does not cover a tree that lives in more than one (2.4.0-alpha.4).** The helper does a `LEFT JOIN` of the table *against itself* on `parentColumn = uuidColumn`, and a row whose declared parent has no match in *that* table is **skipped in silence** — that is the documented "a parent that does not exist is not emitted", and it has no way to tell "orphan" from "the parent lives in another table". So if your tree is spread over several tables (say `organizations` → `organization_units` → `ia_spaces`), the obvious composition — one `sqlScopeEdges` per table, drained one after the other — **does not emit the edges between tables**: every `ia_space → organization_unit` and `organization_unit → organization` edge is dropped without a word, and the parent's *type* would come out wrong anyway (it is the child table's fixed `scopeType`). With `authz:reconcile --to=openfga` that is not "some edges are missing": the destination sees them as surplus and deletes them, and by invariant 9 (`can_<P>` requires `rooted`) **the whole subtree denies**. Fail-closed, noisy, nobody escalates — and a day lost. Write your own `enumerateEdges` instead: one query that concatenates the tables (`UNION ALL` of `(child_uuid, child_type, parent_uuid, parent_type)` across the tables, `parent IS NULL` meaning `app`), ordered by a total and stable key (the child uuid works) with the cursor over that key, and emitting an edge only when the parent exists in *any* of the tables; then compare with `authz:reconcile --to=<driver> --dry-run` before the first real pass. Alternatively chain the tables in a fixed order and encode *which table* inside the opaque cursor — the contract only asks for a total, stable order across calls. *Recipe not verified by the suite* (it has no multi-table fixture); a composition helper (`composeScopeEdges`, or `parentTable` on `sqlScopeEdges`) is deferred to 2.6.
1064
+
1065
+ 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`, …).
1066
+
1067
+ **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.
1068
+
1069
+ **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.
1070
+
1071
+ **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.
1072
+
1073
+ **The store is SHARED, and this pass only governs its own four object types (2.4.0-alpha.9).** With `openfga`, the facts of this engine and the tuples of [`relations/`](#relations-rebac-24) live in the **same store**, and this pass has to read it whole (that is the only way to see what is left over). Until 2.4.0-alpha.8 everything it did not recognise was classified as a leftover *fact*, so **`--prune` deleted every relation tuple in the store** — 60 out of 60 measured by a consumer, the built-in `group` type included — and the destructive pass reported `clean = true` with exit 0, because it had just deleted the very thing that made it dirty. What this pass governs is now an explicit, frozen list of **object types**: `scope`, `role`, `role_binding` and the obsolete `deny_binding` (the deny of the ≤ 2.2 `resolver` mode, which `enumerateFacts` still reads and emits — so the promise that *one pass cleans a store written by the previous version* is kept whole). **Anything else is foreign**: it is not deleted, not even with `--prune`, it is not drift, and it does not enter `skipped` (which is why `clean` can be `true` again on a shared store). The classification **reads nothing at runtime** — not your relations config, not the published model, not a table: the process that runs a cutover is exactly the one that may not have that config, and a failed read must never turn somebody else's data into something deletable.
1074
+
1075
+ The report carries them in a field of their own, `foreign: { tuples, types, relations?, unknown? }` (`phases` does not grow: it is a published type), and the command prints them with their type: `ajenas: 60 tupla(s) de tipos que esta pasada NO gobierna [document (40), group (10), space (10)]`, plus `--prune ha respetado 60 tupla(s) ajenas` on the destructive pass. When `relations.config` is wired, `foreign` is split into `relations` (your declared types, plus `group`) and `unknown` (nobody claims them) — **for the report only**. The way back has no symmetric defect and never had one: the relations reconcile has always enumerated by an allowlist of types and cannot see, let alone delete, a fact (measured). Two limits worth writing down: foreign tuples still count against `--max-tuples` (the snapshot is of the *store*, not of this pass), and **the tuples of a relation type you no longer declare are swept by nobody** — take `document` out of `objectTypes` and its tuples stay in the store (`enumerateRelations` filters by the allowlist, `unrelate`/`purgeObject` are 422 by F-05, and this pass now respects them), so they **grant again if you ever re-declare that type**. The way out today is to declare the type again and run `authz:relations:reconcile --prune` **for that partition** — and two caveats before you do (audit of alpha.9): the pass refuses with `E_AUTHZ_MASS_RECONCILE_REFUSED` when the source (`database`) holds none of those tuples, and the flag that lifts that refusal (`--allow-mass-delete`) will happily empty the **live** relations of the partition you point it at, so check `--partition-type`/`--partition-uuid` twice; and the command reconciles **one partition per pass**, so a multi-tenant cleanup is a loop you write. A `--prune-undeclared` is on the 2.5 list. There is deliberately **no `--prune-foreign`**: a mass deletion of another subsystem's data, triggerable from CI, is not a flag.
1076
+
1077
+ **`--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.
1078
+
1079
+ **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.
1080
+
1081
+ **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).
1082
+
1083
+ 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.
1084
+
1085
+ **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.
1086
+
1087
+ **What it DOES freeze since 2.4.0-alpha.2 (lot L-1): the relations engine too.** `RelationsManager.relate`/`unrelate`/`purgeObject`/`purgeSubject` pass **the same barrier** (the same row, the same function, the same retryable 503 `E_AUTHZ_FROZEN`) before validating anything; `check`/`listObjects`/`listSubjects`/`membersOf` keep answering. Until L-1 nothing in `relations/` looked at the freeze: during a cutover role writes got 503 and relation writes went in. And **the barrier is read through the engine's own connection, never through a caller's `transaction`** — that reading, introduced in 2.3 "so a pool of 1 would not deadlock", let the caller's snapshot decide whether the engine was frozen; it is gone, and `{ transaction }` now requires a pool of at least 2 (see [the tree outbox](#the-tree-outbox-and-the-relay-lag-you-are-accepting)). 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).
1088
+
1089
+ **And `{ transaction }` neither shortens nor skips the window (2.4.0-alpha.2, lot L-6).** The authority is read through the engine's own connection, so **a snapshot taken before the freeze does not skip it** — a transaction of yours opened before the freeze gets the same retryable 503 as everyone else (pinned on the three engines, with the barrier's own deadline on a pool of 1). What changes is the *other* arm of the sentence above: a write that had passed its barrier before the freeze landed still lands, and with `{ transaction }` it lands **when your transaction commits** — the window between barrier and commit is now **as long as your transaction**, minutes if you keep it open, at your discretion. The freeze still promises "another process gets a retryable 503", never "no write enters the window", and what it does NOT freeze does not change: `syncAuthzCatalog`, `manager.driver()` and your own scope-tree tables.
1090
+
1091
+ ### The cutover window: `authz:freeze` / `authz:unfreeze` (2.3)
1092
+
1093
+ 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**:
1094
+
1095
+ ```bash
1096
+ node ace authz:freeze --reason="cutover to openfga" # open the window: fleet-wide 503 on writes
1097
+ node ace authz:reconcile --to=openfga --from=database # the pass RECOGNISES the operator window
1098
+ # if you use relations/: one pass PER PARTITION, inside the same window
1099
+ node ace authz:relations:reconcile --to=openfga --from=database [--partition-type=<t> --partition-uuid=<id>]
1100
+ # VERIFY HERE, before switching config.default: openfga is not active yet, so this is a plain verifier
1101
+ node ace authz:reconcile --to=openfga --from=database --dry-run
1102
+ node ace authz:relations:reconcile --to=openfga --from=database --dry-run [--partition-type=<t> --partition-uuid=<id>]
1103
+ # … only when both come out clean: switch config.default (and relations.default, if you set it), redeploy …
1104
+ node ace authz:reconcile --to=openfga --dry-run # after the switch: the MAINTENANCE verifier, no --from
1105
+ node ace authz:unfreeze # close the window
430
1106
  ```
431
1107
 
432
- The import **copies**, it doesn't move: your `authz_*` tables stay intact, so rolling back is setting `AUTHZ_DRIVER=database` again. Already-expired assignments are skipped and counted. A store that already has tuples is refused (409 `E_AUTHZ_STORE_NOT_EMPTY`) unless `--reconcile`, which reads each fact, then reads the **whole store** (paginated `Read({})`) and reports `{ written, updated, unchanged, extra, deleted, skippedExpired }` — `extra` being the `role_binding`/`deny_binding` tuples SQL does not have (a grant revoked in SQL, a holder that never existed): they **keep granting until you pass `--prune`**, which deletes them and reports them as `deleted`; a report with `extra: 0` after `--prune` means the store equals SQL (*"reconcile converge: las tuplas que SQL no tiene se cuentan como extra y --prune las borra"*). Never `onDuplicateWrites: Ignore`, which left old expiries in place while reporting success (*"reconcile: la tupla permanente pasa a llevar la caducidad de SQL"*). `--prune` without `--reconcile` is 500 `E_AUTHZ_CONFIG`. This is the 2.0 tool; the bidirectional `authz:reconcile` (drivers as peers, catalog projection included) is phase 3b.
1108
+ **Verify before the switch, never with `--from` after it (2.4.0-alpha.10).** Once `config.default` is `openfga`,
1109
+ `--to=openfga --from=database` is the stale direction by definition — even inside the same live operator window,
1110
+ where nobody has written since the migration — so its `--dry-run` comes out as drift (exit 1) and the real pass is
1111
+ 500 `E_AUTHZ_STALE_SOURCE_REFUSED`. There is deliberately **no exception for a live window**: a check that is only
1112
+ green in one narrow moment teaches operators to reach for `--allow-stale-source`, which is the one flag that must stay
1113
+ rare. So the runbook verifies while the destination is not active yet, and after the switch the roles verifier is the
1114
+ maintenance pass (`--to=openfga --dry-run`, no `--from`: it reads the store itself). Relations have no maintenance
1115
+ pass: their verification is the pre-switch `--dry-run`.
1116
+
1117
+ **Relations move with `config.default` (2.4.0-alpha.10).** The relations service resolves its driver as **`relations.default ?? config.default`**: if you did not set `relations.default`, switching `config.default` switches `relations/` too — and a store with zero relation tuples denies every `check` (fail-closed, total), while `authz:reconcile --to=openfga --dry-run` comes out clean (it does not govern relation tuples). So the relations pass belongs to the cutover window, **one partition per pass** (`authz:relations:reconcile` is per partition; a multi-tenant migration is a loop you write), before the switch. If you set `relations.default`, it is that key — not `config.default` — that decides which relations driver serves, and the stale-direction guard of `authz:relations:reconcile` uses the same resolution: once `relations.default ?? config.default` is the destination, no other source is accepted (500 `E_AUTHZ_STALE_SOURCE_REFUSED`, with or without `--from`, unless `--allow-stale-source`; `--dry-run` flags it). There is no maintenance pass for relations: after the cutover the active relations driver *is* the source of truth.
1118
+
1119
+ `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).
1120
+
1121
+ **An expired operator window is evidence, not free space (2.4.0-alpha.10).** A `reconcile` (roles or relations) — or any `freeze()` — that finds the operator window **expired** no longer takes a freeze of its own over it: until 2.4.0-alpha.9 it certified itself with its own 15 s window and, on release, cleared the row, erasing the only trace that writers had resumed mid-cutover. It is now 503 `E_AUTHZ_OPERATOR_WINDOW_EXPIRED` naming when the window expired; acknowledge it with `authz:unfreeze` and reopen it with `authz:freeze`. (An expired freeze of a *pass* is still taken over — that is the lease after a `SIGKILL`.) And both reconcile commands now always say **which window** the pass ran in: inside the operator window (fence N), or a window of its own because no operator window was live — until alpha.9 those two outputs were identical.
1122
+
1123
+ **`authz:unfreeze` on a window that already EXPIRED is an error, not a no-op (2.4.0-alpha.10).** A window opened with `--lease-ms` that expired before `authz:unfreeze` used to look exactly like "there was no window" and the command said success — the last step of the cutover hid that the writers had resumed halfway, i.e. writes the new engine may not have seen. The row tells them apart (lifting clears the reason; an expired window keeps it, with its expiry in the past), so `authz:unfreeze` now prints an error naming when the window expired (ISO) and for how long the fleet has been writing, **exits non-zero**, and clears the row — a second `authz:unfreeze` is a plain success, and "there never was a window / already lifted" is still success. `manager.lastFreezeStatus()` is the reader that also returns the expired row (`expired: true`); `freezeStatus()` still returns only a live freeze. Don't call the cutover done: repeat the migration inside a window without expiry. And a pass that runs inside an operator window with an expiry now says so: `report.frozen.untilMs` is the window's expiry and `frozen.leaseMs` what it had left when the pass adopted it (`null` only for a window that does not expire).
1124
+
1125
+ **`--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) and, when `--from` points at a stale source towards the active driver, `E_AUTHZ_STALE_SOURCE_REFUSED`.
1126
+
1127
+ **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".
1128
+
1129
+ **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.
1130
+
1131
+ **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 — 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. **Run it while `database` is still `config.default`** (2.4.0-alpha.10): if `openfga` is already the active driver, that command is the stale direction and the manager refuses it with 500 `E_AUTHZ_STALE_SOURCE_REFUSED` before reading or freezing anything — `authz_*` stopped seeing the store's writes at the cutover, so rebuilding from them would resurrect every revocation since (and `--prune` would delete what was granted since). If the store is already live *and* you know for a fact that nothing has been written to it since — a store written by the previous version that nobody served from — `--allow-stale-source` is the explicit human decision; run the same command with `--dry-run` first, which flags `report.staleSource` instead of throwing.
433
1132
 
434
1133
  ### Operational notes for this driver
435
1134
 
@@ -437,11 +1136,68 @@ Choosing it adds a **second runtime dependency to every authorization check**: t
437
1136
 
438
1137
  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*:
439
1138
 
440
- - **Binding ids carry the catalog uuid, never the slug (2.2).** Tuples are `role_binding:<scopeKey>|<roleUuid>#assignee` and `deny_binding:<scopeKey>|<permissionUuid>#denied`, with `<scopeKey>` = `app` or `<type>|<uuid>`; 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 `openfga:import --reconcile` reports them as `extra` (`--prune` deletes them). There is no migration command — re-import (*"un store con ids 1.x (slug en el id) no es leído por 2.2"*).
441
- - **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` 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).
1139
+ - **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"*).
1140
+ - **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).
442
1141
  - **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"*).
443
1142
  - **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.
444
- - **There is no distributed transaction with your database.** A `grant` validates the role against the local catalog and then writes the tuple. Remove that role from the catalog afterwards and the tuple is orphaned — `authorize()` finds no permission→role mapping and denies, `hasRole`/`list*` filter by the catalog and do not report it, so it fails closed in every read — but `purgeScope` 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 those is the job of `authz:reconcile` (3b). `openfga:import` is likewise not atomic; it is idempotent, so a run that dies half-way is fixed by running it again with `--reconcile --prune`.
1143
+ - **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`.
1144
+
1145
+ #### The model ceiling is bytes, not permissions
1146
+
1147
+ 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).
1148
+
1149
+ 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:
1150
+
1151
+ | catalog | permissions that fit |
1152
+ |---|---|
1153
+ | 1 holder type, slugs `p0`…`pN` | **800** |
1154
+ | 3 holder types (`user`/`admin`/`integration`), slugs `p0`…`pN` | **691** |
1155
+ | 1 holder type, slugs `docs:readN` | **576** |
1156
+ | 3 holder types, slugs `recursoN:accion` (**realistic**) | **447** |
1157
+ | 3 holder types, slugs of 40 characters | **272** |
1158
+
1159
+ 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.
1160
+
1161
+ #### The scope-chain depth ceiling (facts)
1162
+
1163
+ The `facts` model answers `authorize` in a single `Check` that walks the scope chain to the root
1164
+ (`can_<P>` unions two tuple-to-userset rewrites and subtracts the deny), and OpenFGA bounds how deep
1165
+ a `Check` resolves (`--resolve-node-limit`, 25 by default). Measured against OpenFGA v1.19 with 500
1166
+ resolutions per side, the `facts` model resolves reliably to **22 `parent` hops** and no further: at
1167
+ 23 the same question answers *almost* always and fails between 4 % and 26 % of the time (the node
1168
+ budget is consumed non-deterministically resolving the union), at 24 it always fails. So the driver
1169
+ declares **22** — `FACTS_MAX_RESOLVE_DEPTH`, the depth that resolves *every* time, not the first that
1170
+ fails. Past the ceiling the server returns 400 ("resolution required too many rewrite rules") and the
1171
+ package propagates it as **503, never a `false`** (invariant 5): fail-closed, but a chain that deep is
1172
+ legal for the `database` driver, which has no such ceiling — the same tree is fine in one driver and a
1173
+ 503 in the other, and it is a DoS within reach of whoever can nest sub-scopes. Raise
1174
+ `OPENFGA_RESOLVE_NODE_LIMIT` on the server if your tree is deeper.
1175
+
1176
+ ### Limits
1177
+
1178
+ Every bound the package enforces or measured, in one place, each with the constant or option that carries it — the suite imports those constants and asserts that **these numbers are the ones in the code** (a constant that changes without this table turns red). What lies past each bound is written next to it, because "past the limit" is never a silent `false` here.
1179
+
1180
+ | Limit | Constant / option | Value | Where | Past it |
1181
+ |---|---|---|---|---|
1182
+ | Scope-chain depth the `facts` model resolves reliably (`parent` hops from a scope to `app`) | `FACTS_MAX_RESOLVE_DEPTH` | 22 | `openfga` only, `database` has no ceiling | `authorize` is 503, never `false`, for the whole subtree below (measured: 23 fails between 4 % and 26 % of the time, 24 always). Raise `OPENFGA_RESOLVE_NODE_LIMIT` on the server if your tree is deeper — and declare `scopes.maxDepth`, below |
1183
+ | Depth gate on writes (opt-in, 2.4.0-alpha.5) | `scopes.maxDepth` | none by default; set it to 22 for `facts` | the manager, `scopes.attached`/`scopes.moved`, every driver | 422 `E_AUTHZ_SCOPE_DEPTH_LIMIT` **before** writing or enqueueing; see below |
1184
+ | Authorization-model size | `FACTS_MODEL_MAX_BYTES` | 262,144 | `openfga`, `syncAuthzCatalog` | 500 `E_AUTHZ_MODEL_TOO_LARGE` before writing, warning past 80 % (`FACTS_MODEL_WARN_RATIO`); how many permissions those bytes are is [not a property of the model](#the-model-ceiling-is-bytes-not-permissions) |
1185
+ | Checks per `batchCheck` | `FGA_MAX_BATCH_CHECK` | 50 | `openfga`, `authorizeMany` | the driver splits into batches of 50 (the SDK's cap), nothing to do |
1186
+ | `Read` page size (enumerations, `enumerateFacts`, `enumerateRelations`) | — | 100 | `openfga` | the server's hard cap: `pageSize: 1000` is a 400; the driver pages until the continuation token is empty — a page with zero tuples **and** a token is not the end; a token that repeats, more than 10,000 pages, or a response without `tuples` is 503 `E_AUTHZ_BACKEND_UNAVAILABLE`, never a shorter enumeration |
1187
+ | `enumerateRelations(partition)` / `enumerateRelationsMany(partitions)` cost | — | O(tuples in the store) **per sweep** — one sweep per `enumerateRelations` call, **one sweep for K partitions** with `enumerateRelationsMany` / `runRelationsReconcile(…, { partitions })` (2.4.0-alpha.6) | `openfga`, the *source* of `authz:relations:reconcile` | there is no prefix `Read` in OpenFGA (a prefix object answers 200 with **zero** tuples, measured: never "optimise" it that way — the suite spies the filter of every `read`, it is always `{}`); measured 15,000 tuples ⇒ 151 pages ≈ 0.5 s per sweep whatever the partition size; 3 partitions in a 5,000-tuple store: **150 reads / 502–558 ms** as three sweeps → **50 reads / 170–209 ms** as one ([the table](#the-source-sweep-measured)). Run it from a job or a cron, **never from a tenant request** |
1188
+ | Hops the `only` probe walks back to inherit a block's origin | `RELAY_ORIGIN_MAX_HOPS` | 100 | the manager, `relayScopeChanges({ only })` | the origin is taken as a real block and the entry stays `deferred` (the purge waits for a full pass); one `earlierPending` query per hop |
1189
+ | Interval between `acquire()` retries under `waitForLeaseMs` | `RELAY_LEASE_POLL_MS` | 25 ms | the manager, `relayScopeChanges({ waitForLeaseMs })` and `requeueScopeChanges({ apply, waitForLeaseMs })` | the budget is spent in polls of this size; past it the pass reports `busy` (default `waitForLeaseMs` is 0: one attempt) |
1190
+ | Scopes a `pending()` pass reads per round trip | `DEFAULT_RELAY_BATCH` | 100 | `authz:scopes:relay` (`--batch-size`) | the pass keeps paging |
1191
+ | Changes one relay pass applies | `DEFAULT_RELAY_LIMIT` | 10,000 | `authz:scopes:relay` (`--limit`) | the rest stays pending (`remaining: true`); run again |
1192
+ | Failed attempts before an outbox entry is parked | `maxAttempts` | 5 | `sqlScopeOutbox` | the entry leaves `pending()`, shows in `dead()` on every pass and the command exits ≠ 0; a 503 of the backend does not count; `authz:scopes:requeue --id` brings it back |
1193
+ | Longest wait for the relay lease (`waitForLeaseMs`) | `RELAY_WAIT_LEASE_MAX_MS` | 60,000 | `relayScopeChanges` / `requeueScopeChanges({ apply })` | 500 `E_AUTHZ_CONFIG` above it — a wait without a cap hangs the tenant's request; shorten the pass (`only`, `limit`) instead. The wait is counted in polls of `RELAY_LEASE_POLL_MS`, so it ends even under a frozen injected clock |
1194
+ | Parked outbox entries the relay can census per pass | `PARKED_CENSUS_MAX` | 50,000 | the manager, every relay pass (read in full, whatever `--limit`) | 500 `E_AUTHZ_CONFIG` before anything is applied — the relay of **every** tenant stops, loudly, never a blind plug. Measured cost of the census under the lease: 1,000 parked ⇒ 16 ms, 10,000 ⇒ 69 ms, 49,000 ⇒ 199 ms per pass. The way out is below (*When the parked set grows*) |
1195
+ | Answer size of `authorizedScopes` / `expandExcludedSubtrees` | `maxScopes` | 1,000 | the manager | 422 `E_AUTHZ_TOO_MANY_SCOPES`, never a partial list (per call it can only be lowered) |
1196
+ | Nodes one `descendantsOf` call may return | `maxDescendants` | 10,000 | the manager (`maxNodes` handed to your resolver) | 422 `E_AUTHZ_TOO_MANY_SCOPES`; both bounds are capped at `MAX_SCOPE_BOUND` (10,000,000) |
1197
+ | Local roles a `prune-orphans` pass may read | `maxLocalRoles` | 10,000 | `pruneOrphanRoles` | 500 `E_AUTHZ_TOO_MANY_LOCAL_ROLES`, never a partial list |
1198
+ | Ancestors `hierarchicalScopeResolver` walks | `maxDepth` (resolver option) | 64 in the README example | your `resolveChain` | 500 `E_AUTHZ_SCOPE_TOO_DEEP`, never a truncated chain |
1199
+
1200
+ **`scopes.maxDepth` — the depth gate (2.4.0-alpha.5).** Declare it in `config.scopes` and `scopes.attached`/`scopes.moved` refuse, with **422 `E_AUTHZ_SCOPE_DEPTH_LIMIT`**, any child that would end up more than `maxDepth` `parent` hops from `app` — the parent's chain plus one — **before writing or enqueueing** (zero driver calls, zero outbox rows) and at zero extra I/O: the parent's chain is already resolved for the cycle check. Without the option there is no gate: a tree of any depth stays legal, and the `database` driver pays nothing. It lives in the config and not in `capabilities()` because a capability is a boolean by doctrine (`capabilities()` normalises with `=== true`); and it is the same knob `authz:reconcile` uses to report chains above the ceiling as drift (`drift.tooDeep`, exit ≠ 0 — a scope legal in `database` and a 503 in `openfga` belongs in CI, not in the first tenant that reaches it; without the option the verifier uses `FACTS_MAX_RESOLVE_DEPTH`). Set it to `22` for the `facts` model: then every question stays within the measured ceiling whichever ancestor carries the binding. **How to size it: `maxDepth` counts `parent` hops from the child to `app`, so a tree of N levels below `app` needs `scopes.maxDepth = N`** — not N + 1: the parent's chain includes `app`, but a child at exactly `maxDepth` hops *enters* (`chain.length > maxDepth` is the refusal). Pinned by an executable case (`scope_depth.spec.ts`): a tree of exactly three levels (organization → unit → sub-unit), `maxDepth: 2` refuses the third level with 422, `maxDepth: 3` lets it in. The 422's `message` is for the tenant (depth, `scopes.maxDepth`, nothing written or enqueued); the reason the ceiling exists goes to `error.details` (2.4.0-alpha.8). **The gate is **partial**, and sold as such**: it looks at the *destination* of the child, not at the height of the subtree a `moved` drags along — a subtree of height *h* moved under a parent at depth *d* leaves its leaves at *d + h + 1*, and the gate does not see them; measuring that would need `descendantsOf` on the write path, which is forbidden. The reconcile report is what catches those afterwards. Why a gate at all: past the ceiling the model answers 503 for the whole subtree (a DoS within reach of whoever can nest sub-scopes), and the cost of each relayed `attached` grows with the depth of its neighbour (measured ×5.8 from depth 1 to 34) on the queue every tenant shares.
445
1201
 
446
1202
  ### Operational notes for the SQL engines
447
1203
 
@@ -453,11 +1209,51 @@ The published migration (`stubs/migration.stub`) carries three decisions that we
453
1209
 
454
1210
  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).
455
1211
 
456
- **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; `openfga:import` reads it the same way; 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.
1212
+ **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.
457
1213
 
458
1214
  **`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"*).
459
1215
 
460
- 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.
1216
+ ## Upgrading from 1.x to 2.x
1217
+
1218
+ 2.x is a breaking release with **no compatibility flags** (the [CHANGELOG summary](./CHANGELOG.md)
1219
+ groups every breaking change by risk); this section is the whole upgrade path.
1220
+
1221
+ **The schema jump.** 1.x had `authz_permissions`, `authz_roles`, `authz_role_permissions`,
1222
+ `authz_assignments` and `authz_denies`, with `uuid` identity columns, `timestamp` for `expires_at`,
1223
+ the default collation, a `(slug, scope_type)` unique on roles, and no version row. 2.x adds and
1224
+ changes:
1225
+
1226
+ - **`authz_catalog_version`** — the cross-process catalog version (row `id = 1`) and the durable,
1227
+ fleet-wide freeze (row `id = 2`). Without a readable version row every write is 503 "migration 2.0
1228
+ not applied" (invariant 14), so both rows are seeded at version 0.
1229
+ - **`authz_roles.owner_scope_key`** (`varchar(80)`, `DEFAULT 'global'`) plus the new
1230
+ `(slug, scope_type, owner_scope_key)` unique — roles are global or local to an owner scope (2.2). A
1231
+ 1.x role stays **global** and the next `authz:catalog:sync` recognises it as the same role (same
1232
+ uuid), without duplicating it.
1233
+ - **`authz_permissions.assignable_at`** (`varchar(500)`, nullable) — the levels a permission may be
1234
+ composed at (2.2).
1235
+ - **`authz_relations` and `authz_relations_config`** — the ReBAC tables of 2.4. A fresh install gets
1236
+ them from the published forward migration (`node ace configure` publishes all eight tables); the
1237
+ ALTER recipe below does **not** create them, because they are new tables, not a transformation of
1238
+ 1.x ones. `authz_relations.expires_at` (`DATETIME(3)`, nullable) arrived in **2.4.0-alpha.2**: see
1239
+ [the alpha.1 → alpha.2 recipe](#upgrading-from-240-alpha1-authz_relationsexpires_at).
1240
+ - Identity columns become **`varchar(64)` `collate utf8mb4_bin`** (not `uuid`), so a non-UUID id
1241
+ (`user-42`, a ULID) is valid and case is compared byte-wise; `expires_at` becomes **`DATETIME(3)`**
1242
+ (millisecond-exact, valid past 2038). Each was a red test first — see [Operational notes for the SQL
1243
+ engines](#operational-notes-for-the-sql-engines).
1244
+ - **The scope-tree outbox** (`authz_scope_outbox`) is **opt-in** and not part of this recipe: `node
1245
+ ace configure` offers to publish its migration, or copy `stubs/scopes_outbox_migration.stub`
1246
+ yourself (see [the tree outbox](#the-tree-outbox-and-the-relay-lag-you-are-accepting)).
1247
+
1248
+ **There is no id-migration command, and 2.x does not read a 1.x OpenFGA store.** A store written by
1249
+ 1.x/2.0–2.1 carried the role **slug** in the binding id, under the old `resolver`-mode tuple shapes;
1250
+ 2.2+ carries the role **uuid** and the `facts` model does not even declare those shapes, so those
1251
+ tuples grant nothing and are no membership. The way across is **`authz:reconcile --to=openfga
1252
+ --from=database --prune`**, which rebuilds the store from `authz_*` and your tree and clears the
1253
+ leftovers in one pass — there is no `openfga:import` (removed in 2.3). See [Migrating and
1254
+ verifying](#migrating-and-verifying-authzreconcile-23).
1255
+
1256
+ **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.
461
1257
 
462
1258
  ```sql
463
1259
  -- PostgreSQL: upgrading a 1.x schema to 2.x
@@ -471,9 +1267,14 @@ ALTER TABLE authz_denies
471
1267
  CREATE TABLE authz_catalog_version (
472
1268
  id integer NOT NULL PRIMARY KEY,
473
1269
  version bigint NOT NULL DEFAULT 0,
474
- updated_at timestamptz NOT NULL
1270
+ updated_at timestamptz NOT NULL,
1271
+ freeze_reason varchar(255),
1272
+ freeze_holder varchar(120),
1273
+ freeze_until_ms bigint,
1274
+ freeze_fence bigint NOT NULL DEFAULT 0
475
1275
  );
476
1276
  INSERT INTO authz_catalog_version (id, version, updated_at) VALUES (1, 0, now());
1277
+ INSERT INTO authz_catalog_version (id, version, updated_at) VALUES (2, 0, now());
477
1278
  ALTER TABLE authz_roles ADD COLUMN owner_scope_key varchar(80) NOT NULL DEFAULT 'global';
478
1279
  ALTER TABLE authz_roles DROP CONSTRAINT authz_roles_slug_scope_uq;
479
1280
  ALTER TABLE authz_roles ADD CONSTRAINT authz_roles_slug_scope_owner_uq UNIQUE (slug, scope_type, owner_scope_key);
@@ -502,9 +1303,14 @@ ALTER TABLE authz_denies
502
1303
  CREATE TABLE authz_catalog_version (
503
1304
  id int NOT NULL PRIMARY KEY,
504
1305
  version bigint NOT NULL DEFAULT 0,
505
- updated_at timestamp NOT NULL
1306
+ updated_at timestamp NOT NULL,
1307
+ freeze_reason varchar(255) NULL,
1308
+ freeze_holder varchar(120) NULL,
1309
+ freeze_until_ms bigint NULL,
1310
+ freeze_fence bigint NOT NULL DEFAULT 0
506
1311
  );
507
1312
  INSERT INTO authz_catalog_version (id, version, updated_at) VALUES (1, 0, CURRENT_TIMESTAMP);
1313
+ INSERT INTO authz_catalog_version (id, version, updated_at) VALUES (2, 0, CURRENT_TIMESTAMP);
508
1314
  ALTER TABLE authz_roles
509
1315
  ADD COLUMN owner_scope_key varchar(80) COLLATE utf8mb4_bin NOT NULL DEFAULT 'global',
510
1316
  DROP INDEX authz_roles_slug_scope_uq,
@@ -513,6 +1319,94 @@ ALTER TABLE authz_roles
513
1319
  ALTER TABLE authz_permissions ADD COLUMN assignable_at varchar(500) NULL;
514
1320
  ```
515
1321
 
1322
+ ### Upgrading from 2.4.0-alpha.1: `authz_relations.expires_at`
1323
+
1324
+ An installation that migrated 2.4.0-alpha.1 runs **both** recipes below, **in this order** — `expires_at` first (additive, R-15), then `subject_relation` (lot L-4b) — in the **same deploy** as the package upgrade: until the second one is applied, every holder `relate` is refused by the old partition trigger (measured, below). Both are executed verbatim by the suite on the three engines.
1325
+
1326
+ Relation expiry (R-15) adds **one nullable column** to `authz_relations`; nothing else changes and
1327
+ existing rows keep granting (NULL = no expiry). It is the same type decision as
1328
+ `authz_assignments.expires_at` (millisecond precision, valid past 2038 — 2.5 · J3). A Lucid
1329
+ migration does it engine-agnostically:
1330
+
1331
+ ```ts
1332
+ this.schema.alterTable('authz_relations', (table) => {
1333
+ table.datetime('expires_at', { precision: 3 }).nullable()
1334
+ })
1335
+ ```
1336
+
1337
+ Or by hand:
1338
+
1339
+ ```sql
1340
+ -- PostgreSQL
1341
+ ALTER TABLE authz_relations ADD COLUMN expires_at timestamptz(3) NULL;
1342
+ -- MySQL
1343
+ ALTER TABLE authz_relations ADD COLUMN expires_at datetime(3) NULL;
1344
+ -- SQLite
1345
+ ALTER TABLE authz_relations ADD COLUMN expires_at datetime NULL;
1346
+ ```
1347
+
1348
+ The `openfga` driver needs **no store migration**: the fused model gains `with not_expired` on every
1349
+ relation subject, so republish it (`node ace openfga:provision --store-id <id>` with your
1350
+ `relations.config` loaded, or any `saveRelationsConfig`/catalog sync that republishes the fused model)
1351
+ — existing tuples have no condition and keep granting without expiry.
1352
+
1353
+ ### Upgrading from 2.4.0-alpha.2 (before L-4b): `authz_relations.subject_relation`
1354
+
1355
+ Lot L-4b makes `subject_relation` **`NOT NULL DEFAULT ''`** (`''` = holder, a value = userset) so the unique
1356
+ index `authz_rel_tuple_uq` guards holder tuples too — with `NULL` it only guarded usersets, because every
1357
+ engine treats two `NULL`s as distinct, and two concurrent `relate`s of the same holder committed two identical
1358
+ rows (see [writing relations inside your transaction](#relations-rebac-24)). The `database` driver now writes
1359
+ `''` and **tolerates an old `NULL` row on read** (it is a holder: `check`/`list*`/`membersOf` count it, `relate`
1360
+ of the same holder finds it and is a no-op, `unrelate`/`purgeSubject` delete it). **But the recipe belongs to the
1361
+ same deploy as the package upgrade, not "later"**: until step 4 the *old* partition trigger (`IS NOT NULL`)
1362
+ fires on every holder the new driver inserts (`''` with `subject_partition` NULL), so **every holder `relate`
1363
+ is refused** — 503 `E_AUTHZ_BACKEND_UNAVAILABLE` carrying the trigger's message, fail-closed, the driver's
1364
+ internal transaction undone (measured on the three engines). Reads and deletes keep working; the census counts
1365
+ the old duplicates until you run it. The recipe is **idempotent** (the suite runs it twice). Four steps, **in
1366
+ this order**: **re-create the partition trigger first** — its "is a userset" test becomes
1367
+ `subject_relation <> ''`; the old `IS NOT NULL` fires on `''`, so with it in place *the backfill itself is
1368
+ refused* by the `BEFORE UPDATE` trigger (measured on the three engines) —, then **de-duplicate** the holder
1369
+ rows the old index let through (keeps the oldest `uuid` per tuple; without it the backfill hits the unique
1370
+ index), then **backfill** `NULL` → `''`, then **alter** the column. One statement per line; the trigger bodies
1371
+ carry `;` inside, so run each line **whole** (never through a runner that splits on `;`). The suite executes
1372
+ each block verbatim on its engine (`upgrade_recipe.spec`).
1373
+
1374
+ ```sql
1375
+ -- L-4b · PostgreSQL
1376
+ CREATE OR REPLACE FUNCTION authz_relations_partition_guard() RETURNS trigger LANGUAGE plpgsql AS $authz$ BEGIN IF NEW.subject_relation <> '' AND NEW.subject_partition IS DISTINCT FROM NEW.partition_key THEN RAISE EXCEPTION 'authz_relations: un userset no puede pertenecer a otra partición que su tupla (subject_partition=%, partition_key=%)', NEW.subject_partition, NEW.partition_key; END IF; RETURN NEW; END; $authz$;
1377
+ DELETE FROM authz_relations WHERE subject_relation IS NULL AND CAST(uuid AS CHAR(36)) NOT IN (SELECT keep FROM (SELECT MIN(CAST(uuid AS CHAR(36))) AS keep FROM authz_relations WHERE subject_relation IS NULL GROUP BY partition_key, object_type, object_uuid, relation, subject_type, subject_uuid) AS k);
1378
+ UPDATE authz_relations SET subject_relation = '' WHERE subject_relation IS NULL;
1379
+ ALTER TABLE authz_relations ALTER COLUMN subject_relation SET DEFAULT '', ALTER COLUMN subject_relation SET NOT NULL;
1380
+ ```
1381
+
1382
+ ```sql
1383
+ -- L-4b · MySQL
1384
+ DROP TRIGGER IF EXISTS authz_relations_partition_bi;
1385
+ DROP TRIGGER IF EXISTS authz_relations_partition_bu;
1386
+ CREATE TRIGGER authz_relations_partition_bi BEFORE INSERT ON authz_relations FOR EACH ROW BEGIN IF NEW.subject_relation <> '' AND NOT (NEW.subject_partition <=> NEW.partition_key) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'authz_relations: un userset no puede pertenecer a otra partición que su tupla'; END IF; END;
1387
+ CREATE TRIGGER authz_relations_partition_bu BEFORE UPDATE ON authz_relations FOR EACH ROW BEGIN IF NEW.subject_relation <> '' AND NOT (NEW.subject_partition <=> NEW.partition_key) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'authz_relations: un userset no puede pertenecer a otra partición que su tupla'; END IF; END;
1388
+ DELETE FROM authz_relations WHERE subject_relation IS NULL AND CAST(uuid AS CHAR(36)) NOT IN (SELECT keep FROM (SELECT MIN(CAST(uuid AS CHAR(36))) AS keep FROM authz_relations WHERE subject_relation IS NULL GROUP BY partition_key, object_type, object_uuid, relation, subject_type, subject_uuid) AS k);
1389
+ UPDATE authz_relations SET subject_relation = '' WHERE subject_relation IS NULL;
1390
+ ALTER TABLE authz_relations MODIFY subject_relation varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '';
1391
+ ```
1392
+
1393
+ ```sql
1394
+ -- L-4b · SQLite
1395
+ DROP TRIGGER IF EXISTS authz_relations_partition_bi;
1396
+ DROP TRIGGER IF EXISTS authz_relations_partition_bu;
1397
+ CREATE TRIGGER authz_relations_partition_bi BEFORE INSERT ON authz_relations FOR EACH ROW WHEN NEW.subject_relation <> '' AND NEW.subject_partition IS NOT NEW.partition_key BEGIN SELECT RAISE(ABORT, 'authz_relations: un userset no puede pertenecer a otra partición que su tupla'); END;
1398
+ CREATE TRIGGER authz_relations_partition_bu BEFORE UPDATE ON authz_relations FOR EACH ROW WHEN NEW.subject_relation <> '' AND NEW.subject_partition IS NOT NEW.partition_key BEGIN SELECT RAISE(ABORT, 'authz_relations: un userset no puede pertenecer a otra partición que su tupla'); END;
1399
+ DELETE FROM authz_relations WHERE subject_relation IS NULL AND CAST(uuid AS CHAR(36)) NOT IN (SELECT keep FROM (SELECT MIN(CAST(uuid AS CHAR(36))) AS keep FROM authz_relations WHERE subject_relation IS NULL GROUP BY partition_key, object_type, object_uuid, relation, subject_type, subject_uuid) AS k);
1400
+ UPDATE authz_relations SET subject_relation = '' WHERE subject_relation IS NULL;
1401
+ ```
1402
+
1403
+ **SQLite has no `ALTER COLUMN`**: after the backfill the column stays nullable there (the suite measures that
1404
+ this is the *only* difference from the published schema). The driver never writes `NULL` and the unique index
1405
+ already guards `'' = ''`, so the guarantee holds; if you want the constraint itself, recreate the table with the
1406
+ DDL of the published migration (`stubs/migration.stub`) and copy the rows. The de-duplication keeps the oldest
1407
+ row per tuple: if the two rows raced with different expiries, the survivor is the first written. The `openfga`
1408
+ driver needs nothing — a tuple is unique by construction.
1409
+
516
1410
  ## Compatibility
517
1411
 
518
1412
  | | |