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

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