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

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 (275) hide show
  1. package/README.md +743 -28
  2. package/build/commands/authz_catalog_diff.d.ts +0 -1
  3. package/build/commands/authz_catalog_diff.js +0 -1
  4. package/build/commands/authz_catalog_prune_orphans.d.ts +0 -1
  5. package/build/commands/authz_catalog_prune_orphans.js +0 -1
  6. package/build/commands/authz_catalog_sync.d.ts +0 -1
  7. package/build/commands/authz_catalog_sync.js +0 -1
  8. package/build/commands/authz_freeze.d.ts +0 -1
  9. package/build/commands/authz_freeze.js +0 -1
  10. package/build/commands/authz_reconcile.d.ts +0 -1
  11. package/build/commands/authz_reconcile.js +0 -1
  12. package/build/commands/authz_relations_reconcile.d.ts +6 -1
  13. package/build/commands/authz_relations_reconcile.js +163 -5
  14. package/build/commands/authz_scopes_relay.d.ts +0 -1
  15. package/build/commands/authz_scopes_relay.js +49 -5
  16. package/build/commands/authz_scopes_requeue.d.ts +0 -1
  17. package/build/commands/authz_scopes_requeue.js +14 -2
  18. package/build/commands/authz_unfreeze.d.ts +0 -1
  19. package/build/commands/authz_unfreeze.js +0 -1
  20. package/build/commands/main.d.ts +0 -1
  21. package/build/commands/main.js +0 -1
  22. package/build/commands/openfga_provision.d.ts +72 -1
  23. package/build/commands/openfga_provision.js +236 -4
  24. package/build/configure.d.ts +0 -1
  25. package/build/configure.js +0 -1
  26. package/build/index.d.ts +11 -9
  27. package/build/index.js +12 -5
  28. package/build/providers/authz_provider.d.ts +0 -1
  29. package/build/providers/authz_provider.js +0 -1
  30. package/build/services/main.d.ts +0 -1
  31. package/build/services/main.js +0 -1
  32. package/build/services/relations.d.ts +0 -1
  33. package/build/services/relations.js +0 -1
  34. package/build/src/catalog/catalog.d.ts +0 -1
  35. package/build/src/catalog/catalog.js +0 -1
  36. package/build/src/catalog/catalog_cache.d.ts +13 -1
  37. package/build/src/catalog/catalog_cache.js +25 -1
  38. package/build/src/clock.d.ts +0 -1
  39. package/build/src/clock.js +0 -1
  40. package/build/src/define_config.d.ts +0 -1
  41. package/build/src/define_config.js +24 -1
  42. package/build/src/drivers/database_driver.d.ts +6 -1
  43. package/build/src/drivers/database_driver.js +22 -2
  44. package/build/src/drivers/database_relations_driver.d.ts +46 -5
  45. package/build/src/drivers/database_relations_driver.js +297 -59
  46. package/build/src/drivers/openfga_driver.d.ts +159 -26
  47. package/build/src/drivers/openfga_driver.js +980 -231
  48. package/build/src/drivers/openfga_facts.d.ts +112 -1
  49. package/build/src/drivers/openfga_facts.js +351 -11
  50. package/build/src/drivers/openfga_relations_driver.d.ts +109 -8
  51. package/build/src/drivers/openfga_relations_driver.js +817 -53
  52. package/build/src/errors.d.ts +233 -1
  53. package/build/src/errors.js +283 -1
  54. package/build/src/expiry.d.ts +51 -2
  55. package/build/src/expiry.js +96 -2
  56. package/build/src/freeze.d.ts +0 -1
  57. package/build/src/freeze.js +0 -1
  58. package/build/src/hierarchical_resolver.d.ts +0 -1
  59. package/build/src/hierarchical_resolver.js +0 -1
  60. package/build/src/http/app_access_middleware.d.ts +0 -1
  61. package/build/src/http/app_access_middleware.js +0 -1
  62. package/build/src/http/resource_access_middleware.d.ts +0 -1
  63. package/build/src/http/resource_access_middleware.js +0 -1
  64. package/build/src/identity.d.ts +2 -1
  65. package/build/src/identity.js +4 -1
  66. package/build/src/manager.d.ts +0 -1
  67. package/build/src/manager.js +164 -31
  68. package/build/src/memoize_ancestors.d.ts +0 -1
  69. package/build/src/memoize_ancestors.js +0 -1
  70. package/build/src/models/authz_assignment.d.ts +5 -6
  71. package/build/src/models/authz_assignment.js +0 -1
  72. package/build/src/models/authz_deny.d.ts +5 -6
  73. package/build/src/models/authz_deny.js +0 -1
  74. package/build/src/models/authz_permission.d.ts +5 -6
  75. package/build/src/models/authz_permission.js +0 -1
  76. package/build/src/models/authz_role.d.ts +5 -6
  77. package/build/src/models/authz_role.js +0 -1
  78. package/build/src/models/authz_role_permission.d.ts +5 -6
  79. package/build/src/models/authz_role_permission.js +0 -1
  80. package/build/src/openfga.d.ts +3 -4
  81. package/build/src/openfga.js +5 -3
  82. package/build/src/reconcile.d.ts +0 -1
  83. package/build/src/reconcile.js +0 -1
  84. package/build/src/relation_partition_trigger.d.ts +0 -1
  85. package/build/src/relation_partition_trigger.js +0 -1
  86. package/build/src/relations/check_many.d.ts +1 -2
  87. package/build/src/relations/check_many.js +6 -5
  88. package/build/src/relations/define_relations_config.d.ts +140 -5
  89. package/build/src/relations/define_relations_config.js +205 -6
  90. package/build/src/relations/deny_reach.d.ts +102 -0
  91. package/build/src/relations/deny_reach.js +230 -0
  92. package/build/src/relations/group_depth.d.ts +215 -0
  93. package/build/src/relations/group_depth.js +453 -0
  94. package/build/src/relations/manager.d.ts +46 -8
  95. package/build/src/relations/manager.js +178 -16
  96. package/build/src/relations/page.d.ts +19 -0
  97. package/build/src/relations/page.js +74 -0
  98. package/build/src/relations/reconcile.d.ts +274 -2
  99. package/build/src/relations/reconcile.js +1006 -162
  100. package/build/src/relations/tuple_key.d.ts +0 -1
  101. package/build/src/relations/tuple_key.js +0 -1
  102. package/build/src/relations/tuple_order.d.ts +127 -0
  103. package/build/src/relations/tuple_order.js +599 -0
  104. package/build/src/relations_config_store.d.ts +57 -2
  105. package/build/src/relations_config_store.js +155 -5
  106. package/build/src/relations_reconcile.d.ts +0 -1
  107. package/build/src/relations_reconcile.js +0 -1
  108. package/build/src/scope_outbox.d.ts +0 -1
  109. package/build/src/scope_outbox.js +0 -1
  110. package/build/src/shared/backend_guard.d.ts +60 -1
  111. package/build/src/shared/backend_guard.js +179 -2
  112. package/build/src/shared/sql_expiry.d.ts +0 -1
  113. package/build/src/shared/sql_expiry.js +0 -1
  114. package/build/src/shared/transaction_guard.d.ts +0 -1
  115. package/build/src/shared/transaction_guard.js +0 -1
  116. package/build/src/sql_descendants.d.ts +0 -1
  117. package/build/src/sql_descendants.js +0 -1
  118. package/build/src/testing/contract.d.ts +7 -1
  119. package/build/src/testing/contract.js +77 -1
  120. package/build/src/testing/main.d.ts +1 -2
  121. package/build/src/testing/main.js +1 -2
  122. package/build/src/testing/migration_contract.d.ts +0 -1
  123. package/build/src/testing/migration_contract.js +0 -1
  124. package/build/src/testing/relations_contract.d.ts +25 -1
  125. package/build/src/testing/relations_contract.js +996 -49
  126. package/build/src/testing/relations_reconcile_contract.d.ts +9 -1
  127. package/build/src/testing/relations_reconcile_contract.js +207 -1
  128. package/build/src/testing/scope_outbox_contract.d.ts +0 -1
  129. package/build/src/testing/scope_outbox_contract.js +0 -1
  130. package/build/src/testing/scope_tree.d.ts +0 -1
  131. package/build/src/testing/scope_tree.js +0 -1
  132. package/build/src/testing/test_clock.d.ts +0 -1
  133. package/build/src/testing/test_clock.js +0 -1
  134. package/build/src/traits/authz_scopes.d.ts +0 -1
  135. package/build/src/traits/authz_scopes.js +0 -1
  136. package/build/src/traits/has_uuid.d.ts +5 -6
  137. package/build/src/traits/has_uuid.js +0 -1
  138. package/build/src/types.d.ts +170 -15
  139. package/build/src/types.js +6 -1
  140. package/build/stubs/config/authorization.stub +22 -6
  141. package/build/stubs/main.d.ts +0 -1
  142. package/build/stubs/main.js +0 -1
  143. package/package.json +4 -3
  144. package/build/commands/authz_catalog_diff.d.ts.map +0 -1
  145. package/build/commands/authz_catalog_diff.js.map +0 -1
  146. package/build/commands/authz_catalog_prune_orphans.d.ts.map +0 -1
  147. package/build/commands/authz_catalog_prune_orphans.js.map +0 -1
  148. package/build/commands/authz_catalog_sync.d.ts.map +0 -1
  149. package/build/commands/authz_catalog_sync.js.map +0 -1
  150. package/build/commands/authz_freeze.d.ts.map +0 -1
  151. package/build/commands/authz_freeze.js.map +0 -1
  152. package/build/commands/authz_reconcile.d.ts.map +0 -1
  153. package/build/commands/authz_reconcile.js.map +0 -1
  154. package/build/commands/authz_relations_reconcile.d.ts.map +0 -1
  155. package/build/commands/authz_relations_reconcile.js.map +0 -1
  156. package/build/commands/authz_scopes_relay.d.ts.map +0 -1
  157. package/build/commands/authz_scopes_relay.js.map +0 -1
  158. package/build/commands/authz_scopes_requeue.d.ts.map +0 -1
  159. package/build/commands/authz_scopes_requeue.js.map +0 -1
  160. package/build/commands/authz_unfreeze.d.ts.map +0 -1
  161. package/build/commands/authz_unfreeze.js.map +0 -1
  162. package/build/commands/main.d.ts.map +0 -1
  163. package/build/commands/main.js.map +0 -1
  164. package/build/commands/openfga_provision.d.ts.map +0 -1
  165. package/build/commands/openfga_provision.js.map +0 -1
  166. package/build/configure.d.ts.map +0 -1
  167. package/build/configure.js.map +0 -1
  168. package/build/index.d.ts.map +0 -1
  169. package/build/index.js.map +0 -1
  170. package/build/providers/authz_provider.d.ts.map +0 -1
  171. package/build/providers/authz_provider.js.map +0 -1
  172. package/build/services/main.d.ts.map +0 -1
  173. package/build/services/main.js.map +0 -1
  174. package/build/services/relations.d.ts.map +0 -1
  175. package/build/services/relations.js.map +0 -1
  176. package/build/src/catalog/catalog.d.ts.map +0 -1
  177. package/build/src/catalog/catalog.js.map +0 -1
  178. package/build/src/catalog/catalog_cache.d.ts.map +0 -1
  179. package/build/src/catalog/catalog_cache.js.map +0 -1
  180. package/build/src/clock.d.ts.map +0 -1
  181. package/build/src/clock.js.map +0 -1
  182. package/build/src/define_config.d.ts.map +0 -1
  183. package/build/src/define_config.js.map +0 -1
  184. package/build/src/drivers/database_driver.d.ts.map +0 -1
  185. package/build/src/drivers/database_driver.js.map +0 -1
  186. package/build/src/drivers/database_relations_driver.d.ts.map +0 -1
  187. package/build/src/drivers/database_relations_driver.js.map +0 -1
  188. package/build/src/drivers/openfga_driver.d.ts.map +0 -1
  189. package/build/src/drivers/openfga_driver.js.map +0 -1
  190. package/build/src/drivers/openfga_facts.d.ts.map +0 -1
  191. package/build/src/drivers/openfga_facts.js.map +0 -1
  192. package/build/src/drivers/openfga_relations_driver.d.ts.map +0 -1
  193. package/build/src/drivers/openfga_relations_driver.js.map +0 -1
  194. package/build/src/errors.d.ts.map +0 -1
  195. package/build/src/errors.js.map +0 -1
  196. package/build/src/expiry.d.ts.map +0 -1
  197. package/build/src/expiry.js.map +0 -1
  198. package/build/src/freeze.d.ts.map +0 -1
  199. package/build/src/freeze.js.map +0 -1
  200. package/build/src/hierarchical_resolver.d.ts.map +0 -1
  201. package/build/src/hierarchical_resolver.js.map +0 -1
  202. package/build/src/http/app_access_middleware.d.ts.map +0 -1
  203. package/build/src/http/app_access_middleware.js.map +0 -1
  204. package/build/src/http/resource_access_middleware.d.ts.map +0 -1
  205. package/build/src/http/resource_access_middleware.js.map +0 -1
  206. package/build/src/identity.d.ts.map +0 -1
  207. package/build/src/identity.js.map +0 -1
  208. package/build/src/manager.d.ts.map +0 -1
  209. package/build/src/manager.js.map +0 -1
  210. package/build/src/memoize_ancestors.d.ts.map +0 -1
  211. package/build/src/memoize_ancestors.js.map +0 -1
  212. package/build/src/models/authz_assignment.d.ts.map +0 -1
  213. package/build/src/models/authz_assignment.js.map +0 -1
  214. package/build/src/models/authz_deny.d.ts.map +0 -1
  215. package/build/src/models/authz_deny.js.map +0 -1
  216. package/build/src/models/authz_permission.d.ts.map +0 -1
  217. package/build/src/models/authz_permission.js.map +0 -1
  218. package/build/src/models/authz_role.d.ts.map +0 -1
  219. package/build/src/models/authz_role.js.map +0 -1
  220. package/build/src/models/authz_role_permission.d.ts.map +0 -1
  221. package/build/src/models/authz_role_permission.js.map +0 -1
  222. package/build/src/openfga.d.ts.map +0 -1
  223. package/build/src/openfga.js.map +0 -1
  224. package/build/src/reconcile.d.ts.map +0 -1
  225. package/build/src/reconcile.js.map +0 -1
  226. package/build/src/relation_partition_trigger.d.ts.map +0 -1
  227. package/build/src/relation_partition_trigger.js.map +0 -1
  228. package/build/src/relations/check_many.d.ts.map +0 -1
  229. package/build/src/relations/check_many.js.map +0 -1
  230. package/build/src/relations/define_relations_config.d.ts.map +0 -1
  231. package/build/src/relations/define_relations_config.js.map +0 -1
  232. package/build/src/relations/manager.d.ts.map +0 -1
  233. package/build/src/relations/manager.js.map +0 -1
  234. package/build/src/relations/reconcile.d.ts.map +0 -1
  235. package/build/src/relations/reconcile.js.map +0 -1
  236. package/build/src/relations/tuple_key.d.ts.map +0 -1
  237. package/build/src/relations/tuple_key.js.map +0 -1
  238. package/build/src/relations_config_store.d.ts.map +0 -1
  239. package/build/src/relations_config_store.js.map +0 -1
  240. package/build/src/relations_reconcile.d.ts.map +0 -1
  241. package/build/src/relations_reconcile.js.map +0 -1
  242. package/build/src/scope_outbox.d.ts.map +0 -1
  243. package/build/src/scope_outbox.js.map +0 -1
  244. package/build/src/shared/backend_guard.d.ts.map +0 -1
  245. package/build/src/shared/backend_guard.js.map +0 -1
  246. package/build/src/shared/sql_expiry.d.ts.map +0 -1
  247. package/build/src/shared/sql_expiry.js.map +0 -1
  248. package/build/src/shared/transaction_guard.d.ts.map +0 -1
  249. package/build/src/shared/transaction_guard.js.map +0 -1
  250. package/build/src/sql_descendants.d.ts.map +0 -1
  251. package/build/src/sql_descendants.js.map +0 -1
  252. package/build/src/testing/contract.d.ts.map +0 -1
  253. package/build/src/testing/contract.js.map +0 -1
  254. package/build/src/testing/main.d.ts.map +0 -1
  255. package/build/src/testing/main.js.map +0 -1
  256. package/build/src/testing/migration_contract.d.ts.map +0 -1
  257. package/build/src/testing/migration_contract.js.map +0 -1
  258. package/build/src/testing/relations_contract.d.ts.map +0 -1
  259. package/build/src/testing/relations_contract.js.map +0 -1
  260. package/build/src/testing/relations_reconcile_contract.d.ts.map +0 -1
  261. package/build/src/testing/relations_reconcile_contract.js.map +0 -1
  262. package/build/src/testing/scope_outbox_contract.d.ts.map +0 -1
  263. package/build/src/testing/scope_outbox_contract.js.map +0 -1
  264. package/build/src/testing/scope_tree.d.ts.map +0 -1
  265. package/build/src/testing/scope_tree.js.map +0 -1
  266. package/build/src/testing/test_clock.d.ts.map +0 -1
  267. package/build/src/testing/test_clock.js.map +0 -1
  268. package/build/src/traits/authz_scopes.d.ts.map +0 -1
  269. package/build/src/traits/authz_scopes.js.map +0 -1
  270. package/build/src/traits/has_uuid.d.ts.map +0 -1
  271. package/build/src/traits/has_uuid.js.map +0 -1
  272. package/build/src/types.d.ts.map +0 -1
  273. package/build/src/types.js.map +0 -1
  274. package/build/stubs/main.d.ts.map +0 -1
  275. package/build/stubs/main.js.map +0 -1
package/README.md CHANGED
@@ -5,7 +5,7 @@ Driver-based **authorization engine for AdonisJS 7 + Lucid**: hierarchical scope
5
5
  Ships two drivers that pass **the same executable contract suite**, case for case:
6
6
 
7
7
  - **`database`** (default) — self-contained, engine-agnostic SQL over its own `authz_*` tables. Zero extra infrastructure.
8
- - **`openfga`** — facts live in an [OpenFGA](https://openfga.dev) server (Zanzibar model); the catalog and your scope hierarchy stay local, so switching drivers is a facts migration, not a rewrite.
8
+ - **`openfga`** — facts live in an [OpenFGA](https://openfga.dev) server (Zanzibar model); the catalog and your scope hierarchy stay local, so switching drivers is a facts migration, not a rewrite. **Experimental in 2.5.x — not production-ready, and outside the semver guarantee** (only its experimental parts — the opt-in token and the drivers' behaviour — may change in 2.6, a minor, without a major; the rest of the package keeps semver): it refuses to be built without `experimental: 'openfga@2.5'` (500 `E_AUTHZ_CONFIG`), its open races are declared in [Limits](#limits) with figure, recipe and detector, and it closes in **2.6**. In production, use `database`.
9
9
 
10
10
  ```ts
11
11
  import authorization from '@jantstack/adonis-authz/services/main'
@@ -45,7 +45,7 @@ These are the eight invariants of the contract. Every one of them is a case in t
45
45
  2. **Explicit deny wins.** A deny anywhere in the scope chain blocks the permission even if a role grants it; removing the deny restores it; the order of writes does not matter (*"deny explícito gana sobre el rol"*, *"deny antes del grant también bloquea"*). A deny governs `authorize` only — **it does not affect `hasRole`**, which is a membership fact, not an access decision (*"un deny NO afecta a hasRole"*). That is why no middleware in this package accepts a role.
46
46
  3. **Expiry is observable, without a scheduler.** An assignment past its `expiresAt` grants nothing — enforced in SQL by `database`, by an FGA *condition* in `openfga`, and filtered client-side in enumerations (*"asignación expirada no concede; expiración futura sí"*).
47
47
  4. **Polymorphic holders.** `{ type: morphName, uuid }`. Two holders with the same uuid and a different type never cross (*"holder polimórfico"*). The `openfga` driver refuses a `holderTypes` map that would merge two morph names into one FGA type (*"holderTypes tiene que ser inyectivo"*).
48
- 5. **Deny by default, and three distinguishable outcomes.** No permission, unknown permission, no live assignment, unknown scope → `false`, never a throw. A malformed question (unknown role or permission in `grant`/`deny`/`revoke`/`removeDeny`, invalid identity, bad slug, a `RoleQuery` object where a slug is expected, an `expiresAt` that is not a valid `Date`/`null`, unknown scope on a write) → **422** with an `E_AUTHZ_*` code. A dependency that does not answer — the facts backend, the SQL catalog **in both drivers**, or your own resolver (throwing *or* answering a malformed ancestor) — → **503**, never a silent `false`; in `openfga` a per-check `error` inside a 200 `batchCheck` is a 503 too, in the deny phase and in the role phase (*"un resolutor de ancestros que lanza ⇒ 503"*, *"authorize con el catálogo inaccesible lanza AuthorizationBackendError"*, *"authorize: error en un check de rol ⇒ 503"*).
48
+ 5. **Deny by default, and three distinguishable outcomes.** No permission, unknown permission, no live assignment, unknown scope → `false`, never a throw. A malformed question (unknown role or permission in `grant`/`deny`/`revoke`/`removeDeny`, invalid identity, bad slug, a `RoleQuery` object where a slug is expected, an `expiresAt` that is not a valid `Date`/`null`, unknown scope on a write) → **422** with an `E_AUTHZ_*` code. A dependency that does not answer — the facts backend, the SQL catalog **in both drivers**, or your own resolver (throwing *or* answering a malformed ancestor) — → **503**, never a silent `false`; in `openfga` a per-check `error` inside a 200 `batchCheck` is classified exactly like the same failure thrown by a single `Check`, in the deny phase and in the role phase (*"un resolutor de ancestros que lanza ⇒ 503"*, *"authorize con el catálogo inaccesible lanza AuthorizationBackendError"*, *"authorize: error en un check de rol ⇒ 503"*). **Since 2.5.0-alpha.4 (R3) `openfga` tells the dependency that does not answer from the one that answers "no"**: a deployment the config does not match — store or model that does not exist, credentials (401/403), a type, relation or context the pinned model rejects — is **500 `E_AUTHZ_CONFIG` "not transient"** (retrying fails the same way until the deployment is fixed; the tree relay still treats it as transient, so it never parks an entry), an unknown 4xx is 500 with the server's error as `cause`, and a graph deeper than the engine resolves is **503 `E_AUTHZ_RESOLUTION_LIMIT`** (see [Limits](#limits)) — see [How OpenFGA failures are classified](#how-openfga-failures-are-classified-250-alpha4).
49
49
  6. **Idempotent writes.** Re-grant does not duplicate; `expiresAt` has three states (below); re-revoke / re-deny / re-removeDeny are safe no-ops, and a repeated deny needs one `removeDeny` (*"grant duplicado es idempotente"*, *"deny repetido no se duplica"*).
50
50
  7. **`list*` return direct facts, complete, from the catalog's point of view.** `listSubjects`, `listRoles`, `listRoleScopes`, `listScopes` return *direct*, live assignments of the exact scope — never inherited descendants (that set would be open-ended; ask `authorize` about a concrete scope) — and they return **all of them**: 1,200 direct assignments come back whole in both drivers, and a `listScopes` subtracts a deny even when the holder carries 150 denies of other permissions (more than one `Read` page, more than an OpenFGA server's `ListObjects` cap) (*"listas exhaustivas: 1.200 asignaciones directas"*, *"listScopes resta el deny aunque el sujeto tenga más denies de OTROS permisos que el tope del backend"*). CI runs the judge against a second OpenFGA whose cap is 3. Membership is what the **catalog** says it is: a role removed from `authz_roles` is no longer a membership in either driver, even if `openfga` still holds its tuple (*"rol borrado del catálogo: la tupla sigue en el store pero authorize deniega"*), and a scope the tree no longer knows lists nothing (*"listRoles y listRoleScopes tampoco responden por un scope que el árbol no conoce"*).
51
51
  8. **`rank` is metadata.** The engine stores it and never evaluates it (*"rank es metadata"*). "Nobody grants a role at or above their own rank" is your assignment policy.
@@ -66,7 +66,9 @@ export default defineConfig({
66
66
 
67
67
  drivers: {
68
68
  database: () => new DatabaseAuthorizationDriver({ resolveChain: resolveScopeAncestors }),
69
- openfga: () => new OpenFgaAuthorizationDriver({ apiUrl, storeId, holderTypes, resolveChain: resolveScopeAncestors }),
69
+ // Experimental in 2.5.x: without `experimental: 'openfga@2.5'` it is 500 at construction. The opt-in is left commented
70
+ // on purpose — write it yourself once you have read «OpenFGA tooling» and Limits.
71
+ openfga: () => new OpenFgaAuthorizationDriver({ /* experimental: 'openfga@2.5', */ apiUrl, storeId, modelId, holderTypes, resolveChain: resolveScopeAncestors }),
70
72
  },
71
73
 
72
74
  // 3. Your catalogs (one per module), for authz:catalog:sync / diff
@@ -99,7 +101,7 @@ Your resolver's *answer* is validated too: an element that is not a well-formed
99
101
 
100
102
  `ScopeType` is an open `string`, so define your own union for type safety. The engine never queries your tables.
101
103
 
102
- **How deep can the tree go?** In `database`, as deep as your resolver walks. With the `openfga` driver the `facts` model resolves a chain reliably up to **22 `parent` hops** (`FACTS_MAX_RESOLVE_DEPTH`) and answers 503 — never `false` — past it, so design the tree with that number in front of you and, if you want the engine to refuse the 23rd level before it is written, declare `scopes.maxDepth` (422 `E_AUTHZ_SCOPE_DEPTH_LIMIT`, partial: it does not see the height of a moved subtree). Every bound the package enforces is in one table: [Limits](#limits).
104
+ **How deep can the tree go?** In `database`, as deep as your resolver walks. With the `openfga` driver the `facts` model resolves a chain reliably up to **22 `parent` hops** (`FACTS_MAX_RESOLVE_DEPTH`) and answers 503 `E_AUTHZ_RESOLUTION_LIMIT` — never `false` — past it, so design the tree with that number in front of you and, if you want the engine to refuse the 23rd level before it is written, declare `scopes.maxDepth` (422 `E_AUTHZ_SCOPE_DEPTH_LIMIT`, partial: it does not see the height of a moved subtree). Every bound the package enforces is in one table: [Limits](#limits).
103
105
 
104
106
  #### The tree outbox, and the relay lag you are accepting
105
107
 
@@ -115,7 +117,7 @@ const outbox = sqlScopeOutbox() // or your own implementati
115
117
  export default defineConfig({
116
118
  scopes: { resolveChain, outbox },
117
119
  drivers: {
118
- openfga: () => new OpenFgaAuthorizationDriver({ /* … */ outbox }),
120
+ openfga: () => new OpenFgaAuthorizationDriver({ experimental: 'openfga@2.5', /* … */ outbox }),
119
121
  },
120
122
  })
121
123
 
@@ -168,7 +170,7 @@ The relay is resumable and never silent: the report says *which* changes were ap
168
170
  **What the outbox does not fix, in plain words.** Between your commit and the relay pass there is a lag of **seconds during which FGA decides with the old tree**. What that costs you depends on the change, and the two directions have **opposite signs** (2.0, the `rooted` relation of the `facts` model — see [What a scope that is not attached grants](#what-a-scope-that-is-not-attached-grants)):
169
171
 
170
172
  - after a `moved` or a `detached` it is a **temporary fail-open**: the **old tenant keeps access** to the moved subtree, and the scope you deleted is only purged when the relay runs;
171
- - after an `attached` it is a **temporary fail-CLOSED**: **a newly created scope grants nothing at all until the relay runs.** Its chain does not reach the root in the store yet, so `can_<P>` is false there for everyone — including the tenant admin you just created it for — and `database` would answer `true`. **The recipe: drain the queue in the same request, right after your commit** (`await authorization.relayScopeChanges()`) on the interactive "create a tenant" path; that shrinks the window to one relay cycle for whatever failed, and nothing else. **Without an outbox the window is zero**: `authorization.scopes.*` calls the driver inline, in your transaction — which is the trade the outbox exists to make.
173
+ - 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. (Zero lag, not zero races: two inline `moved` of a parent and its child at the same time can leave a local role visible outside its owner in `openfga` — provisional row [races between writers](#limits).)
172
174
 
173
175
  This is a **breaking change of observable behaviour in 2.0**: until then a not-yet-relayed `attached` *granted*, and **did not inherit the denies above it** — the fail-open the audit found, closing which is exactly what `rooted` buys. Denying for seconds is availability; granting for seconds is the defect this package spent two releases hunting.
174
176
 
@@ -202,7 +204,7 @@ The capability behind the first two rows is **`transactionalWrites`** (same name
202
204
 
203
205
  **A transaction that is already committed or rolled back is refused before any statement (2.4.0-alpha.8).** Lucid keeps `isTransaction === true` on a finished transaction, so until alpha.7 `assertCallerTransaction` let it through and the statement blew up inside knex as **503 `E_AUTHZ_BACKEND_UNAVAILABLE`** ("the backend did not answer") — which is false: it is the caller's wiring (a `trx` reused after `await trx.commit()`). It is now the fourth check of the shared guard (`isCompleted === true` ⇒ **500 `E_AUTHZ_CONFIG`** naming the operation, "confirmada o revertida", zero statements), in the three places that take `{ transaction }`: `sqlScopeOutbox.enqueue`, the `database` roles driver and the `database` relations driver — pinned in the four engines.
204
206
 
205
- **Through your transaction: the write, and only the write** — the `INSERT`/`UPDATE`/`DELETE`, plus the "does it already exist?" read that belongs to it (a `grant` and its re-grant, a `relate` and its renewal, inside the same transaction see each other). **Through the engine's own connection, always: the authority** — the freeze barrier (row `id = 2` of `authz_catalog_version`), the catalog memo, `resolveChain`, F-05 and `assertWrite`. They are decided **before the first statement on your transaction** (the suite spies on the transaction and counts zero), and a transaction opened *before* a freeze does not get to decide it is not frozen (its snapshot would say so under InnoDB's REPEATABLE READ, PostgreSQL at that level and SQLite in WAL — the suite reproduces the bypass on the three engines and pins the 503). Until 2.4.0-alpha.2 the barrier *was* read through the caller's transaction "so a pool of 1 would not deadlock"; that traded authority for availability and it is gone.
207
+ **Through your transaction: the write, and only the write** — the `INSERT`/`UPDATE`/`DELETE`, plus the "does it already exist?" read that belongs to it (a `grant` and its re-grant, a `relate` and its renewal, inside the same transaction see each other). **One more read goes through it, and only one: the group nesting depth gate of `database`** (2.5.0-alpha.4, R1; only with `database.enforceGroupDepth`). It reads the nesting graph through your transaction because it is data of the write, like the "does it already exist?" — it decides no permission — and one transaction that writes `A ⊂ B` and then `B ⊂ C` must see its own first edge. The race it leaves is declared: two transactions that each add half of a chain both pass, and **under MySQL's REPEATABLE READ the window of that race is the whole life of your transaction** (its snapshot is taken at its first read), not just two writes at the same instant. A test pins the closed list of reads that go through your transaction — statement by statement and with its table, raw SQL included — and fails if the transaction's client is handed to anything it cannot follow. **Through the engine's own connection, always: the authority** — the freeze barrier (row `id = 2` of `authz_catalog_version`), the catalog memo, `resolveChain`, F-05 and `assertWrite`. They are decided **before the first statement on your transaction** (the suite spies on the transaction and counts zero), and a transaction opened *before* a freeze does not get to decide it is not frozen (its snapshot would say so under InnoDB's REPEATABLE READ, PostgreSQL at that level and SQLite in WAL — the suite reproduces the bypass on the three engines and pins the 503). Until 2.4.0-alpha.2 the barrier *was* read through the caller's transaction "so a pool of 1 would not deadlock"; that traded authority for availability and it is gone.
206
208
 
207
209
  Hence the deployment rule, said out loud: **`{ transaction }` requires a pool of at least 2.** With a pool of 1 (SQLite `:memory:`, the suite's default mode) the authority cannot get a connection while you hold the only one, and the write is **503 `E_AUTHZ_BACKEND_TIMEOUT` at `freezeTimeoutMs`** (config, default 5000 ms — the barrier's own total deadline, pool wait included; knex's own `acquireConnectionTimeout` would have made it 60 s) with **zero statements on your transaction**: fail-closed, never a hang, never a bypass. A pool-of-1 deployment declares it — `new DatabaseAuthorizationDriver({ transactionalWrites: false })`, `new DatabaseRelationsDriver(config, { transactionalWrites: false })` — and gets gate 1's immediate 500 instead; the driver declares what its deployment can do, the package does not guess the pool. This also applies to `scopes.*` with `{ transaction }` (the outbox): a single-connection deployment that used it has to raise the pool (breaking of deployment, lot L-1). While the engine is **frozen**, `{ transaction }` is the retryable 503 `E_AUTHZ_FROZEN` before the first statement on your transaction.
208
210
 
@@ -249,7 +251,13 @@ The *row* is inside your transaction; the *decision* that authorises writing it
249
251
 
250
252
  #### Third-party drivers, and what the suite can and cannot prove
251
253
 
252
- "Both or neither" is **a guarantee of this package's `database` driver**, measured on its own schema on three engines. For a driver of yours declaring `transactionalWrites: true`, the runner requires the census (the `transactions` hook: how to open a transaction of *your* connection and how to count *your* rows) and judges rollback ⇒ zero, commit ⇒ applied, the uncommitted write invisible to the authority and three foreign transactions ⇒ 500 with zero statements — but it **cannot prove that a third-party driver's write really lives and dies with the transaction it is handed**: the census it counts is the one your hook reports, and a driver that opens its own transaction underneath passes the answer checks. Two more honest boundaries: the `true` face only runs where a second connection exists (`sqlite-file`, PostgreSQL, MySQL — the engine jobs of CI are not optional for this capability), so a driver judged only on `:memory:` never sees those cases; and the 409/503 classification of the table above is the `database` driver's — a third-party driver that returns the raw engine error is not caught by the suite. The same honesty for relations: the `listObjectsTruncation` pair of `runRelationsDriverContract` judges the **cap** only (`limits.listMaxResults`); **the runner does not judge the deadline** signal of a third-party `listObjects` (there is no way to observe it without sleeping), so a driver that does not implement it passes — the deadline cause is a property of this package's `openfga` driver, pinned by its own cases and by the surface guard. And the B3 case (a userset other than `group#member` as subject) observes your backend **through the port** — `listSubjects` and, under `enumerateRelations: true`, the whole partition — so a driver that stores the inert row and then throws the 422 goes red only through a port it declares: the minimal harness (`usersetSubjects: false`, `enumerateRelations: false`) has no port through which the runner can see that row, and such a driver passes (measured with that mutant on the double).
254
+ "Both or neither" is **a guarantee of this package's `database` driver**, measured on its own schema on three engines. For a driver of yours declaring `transactionalWrites: true`, the runner requires the census (the `transactions` hook: how to open a transaction of *your* connection and how to count *your* rows) and judges rollback ⇒ zero, commit ⇒ applied, the uncommitted write invisible to the authority and three foreign transactions ⇒ 500 with zero statements — but it **cannot prove that a third-party driver's write really lives and dies with the transaction it is handed**: the census it counts is the one your hook reports, and a driver that opens its own transaction underneath passes the answer checks. Two more honest boundaries: the `true` face only runs where a second connection exists (`sqlite-file`, PostgreSQL, MySQL — the engine jobs of CI are not optional for this capability), so a driver judged only on `:memory:` never sees those cases; and the 409/503 classification of the table above is the `database` driver's — a third-party driver that returns the raw engine error is not caught by the suite. The same honesty for relations: the `listObjectsTruncation` pair of `runRelationsDriverContract` judges the **cap** only (`limits.listMaxResults`); **the runner does not judge the deadline** signal of a third-party `listObjects` (there is no way to observe it without sleeping), so a driver that does not implement it passes — the deadline cause is a property of this package's `openfga` driver, pinned by its own cases and by the surface guard. And the B3 case (a userset other than `group#member` as subject) observes your backend **through the port** — `listSubjects` and, under `enumerateRelations: true`, the whole partition — so a driver that stores the inert row and then throws the 422 goes red only through a port it declares: the minimal harness (`usersetSubjects: false`, `enumerateRelations: false`) has no port through which the runner can see that row, and such a driver passes (measured with that mutant on the double). **`denyPhase` (2.5.0-alpha.3) is judged by the driver
255
+ runner**: with `relationDenies: true`, `runRelationsDriverContract` asks your driver, built on the contract's deny config,
256
+ for the phase of each type (`document` 'write', `workspace` 'read', `note` none) and checks that it is the truth about
257
+ `deny()` ('write' ⇒ the deny lands and `check` is false; 'read' and none ⇒ 422 `E_AUTHZ_RELATION_NOT_DENYABLE`, nothing
258
+ written) plus F-05 (undeclared type, undeclared or missing relation ⇒ 422); with `relationDenies: false`, a `denyPhase` you
259
+ still expose must claim no phase (`null`). It is the gate `authz:relations:reconcile` uses before writing a deny into your
260
+ driver; what the runner cannot see is a driver whose `deny()` and `check` agree with each other and both ignore the opt-in.
253
261
 
254
262
  ### Identity is validated, once and everywhere
255
263
 
@@ -275,7 +283,7 @@ A seeder or an onboarding that calls `grant` "to make sure they have the role" n
275
283
  `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.
276
284
 
277
285
 
278
- One exception, on purpose: when a write **times out** (503 `E_AUTHZ_BACKEND_TIMEOUT`) the outcome is *unknown* — the request may still land on the backend after you received the error. Before propagating, the manager notifies the same event with **`indeterminate: true`**, so your audit records "may have happened" instead of nothing (*"una escritura que vence el deadline notifica onWrite con indeterminate: true ANTES de propagar el 503"*). A 503 that is not a timeout (connection refused) means the write did not happen and emits nothing. The `openfga` driver also stops the SDK from retrying on its own (`retryParams.maxRetry: 0` by default): a background retry after your 503 is exactly the phantom write this is about; enabling retries is opting into it. If you retry a timed-out write yourself, remember it is idempotent.
286
+ 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 means the write did not happen and emits nothing — **with one family carved out on `openfga` (2.5.0-alpha.4, R3 closure)**: on a **write**, the server's own deadline (`deadline_exceeded`) and a **504** from a gateway or proxy in front of it are timeouts too (503 `E_AUTHZ_BACKEND_TIMEOUT`, `indeterminate: true`): the `Write` may have committed upstream after the response was cut. Every other 5xx on a write (500, 502, 503, 501) keeps "did not happen": the server answered that it failed. On a read, a 504 stays 503 `E_AUTHZ_BACKEND_UNAVAILABLE` (there is nothing to audit). On `openfga` a refused connection is not one of those since 2.5.0-alpha.4: the SDK hands it over as the same bare `FgaError` as a client timeout, without a code, and the package does not read messages — so it is classified as the conservative one, a timeout (`indeterminate: true`): "may have happened" said once too often, never once too few. 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.
279
287
 
280
288
  **Inside your transaction (2.4.0-alpha.2, lot L-3) the event also carries `transactional: true`**, and a deadline that elapses there is *still* `indeterminate: true`. The `{trx}` panel's auditor objected that inside a transaction "the rollback determines it" — it does, but the rollback is *yours* and the package never sees it: when the 503 reaches you the statement may or may not have landed inside your transaction (SQLite cannot cancel; MySQL kills the statement and your transaction lives on; PostgreSQL leaves your transaction aborted), and whether you commit is yours. `transactional: true` is on **every** event of a write inscribed in your transaction (`granted`/`extended`/`revoked`/`denied`/`deny_removed`): at that instant the row exists only inside your transaction and is a fact if and only if you commit. A sink that wants the last word hooks `trx.after('commit', …)`. Enqueueing through `scopes.*` is not writing and does not carry it.
281
289
 
@@ -450,7 +458,7 @@ node ace authz:reconcile --to=openfga --dry-run # verify a driver against auth
450
458
 
451
459
  ## Scoped roles (2.2)
452
460
 
453
- A role has an **owner**: `global` — declared in config and synced with `syncAuthzCatalog` — or the scope that defined it with `defineScopedRole(actor, ownerScope, spec)`. One rule, in both drivers: *an assignment in scope S of role R counts if and only if R is global or R's owner is in chain(S)* (S inclusive). Outside its owner a local role does not exist: it grants nothing, is no membership (`hasRole`, `listRoles`, `listSubjects`, `listRoleScopes`, `listScopes`, `effectivePermissions`, `authorizedScopes` all apply the rule) and cannot be granted (422 `E_AUTHZ_ROLE_NOT_VISIBLE`, nothing written). Moving a unit out of the owner's subtree **retires** what the local role granted there, and moving it back restores it — the tree of *today* decides. **How that retirement happens depends on the driver (2.3, breaking):** in `database` it costs no write at all (the rule is evaluated on every question). In `openfga` the model has no `owner`, so a `role_binding` would keep granting while its scope is reachable — a fail-open — and the package therefore **writes**: `scopes.moved` sweeps the `scope#binding` edges of the local roles whose owner is no longer in the chain, across the **whole moved subtree**, and rewrites them when the owner is in the chain again. Global roles are never touched, and neither is a local role whose owner is still an ancestor. That write follows the same path as any other tree change: with `scopes.outbox` it is applied by `authz:scopes:relay`, so it inherits the [temporary fail-open of the relay lag](#the-tree-outbox-and-the-relay-lag-you-are-accepting), and `authz:reconcile --to=<the driver you are serving from>` reconciles it if the relay was lost — that pass reads the facts from the store itself, recomputes the same rule with the tree and the catalog of *today*, deletes the `scope#binding` edges the rule forbids and writes the ones it requires, and counts them in `drift.roleVisibility` (see [whoever owns the facts](#migrating-and-verifying-authzreconcile-23); before 2.3's last cut it rebuilt from `authz_*`, which in a `facts` deployment hold no facts at all, so the sweep never ran and the report said `0`) (*"un rol local de la organization A concede en A y sus units, no en B ni en app"*). **The same is true of a role's level** (2.3): the (c2) model does not carry `scope_type` either, so changing the level of a role — an assignment stays where it is, but the role is no longer declared for that kind of scope — is retired in `database` by evaluating the rule again, and in `facts` by the same sweep, run from `projectCatalogRole` (the package calls it after `defineScopedRole`/`updateScopedRole`; a process that writes `authz_*` by hand owes it the same call it already owed for the permission mirror). **What this makes the `scope#binding` edge mean, plainly: "the role is visible here", not "this assignment exists".** The assignment itself is the `assignee` tuple, which no sweep ever touches — which is why `hasRole`, `listRoles` and `listSubjects` still enumerate assignments and filter them through the catalog, and answer exactly as they did before. Two tenants may each define `lead@unit` with different permissions: the slug no longer identifies a role, the uuid does, and nothing crosses tenants (*"dos tenants definen el mismo slug"*). A deny anywhere in the chain still wins over a local role (invariant 2).
461
+ 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`, after the [relay lag](#the-tree-outbox-and-the-relay-lag-you-are-accepting), during which the store still has the old tree — but **not only the old tree decides**: a local role granted during the lag was checked for visibility against *your* (new) tree and written to the store's (old) one, so until 2.5.0-alpha.4 a `grant` made right after a unit ENTERS the owner granted past a deny of that owner until the relay landed; since the settle of 2.5.0-alpha.4 the `grant` first decides that visibility against the store's tree, so it does not grant there before the relay lands (fail-closed), and what is left of the races between writers is the declared limit `openfga.races.settle-window` in [Limits](#limits) — and **since 2.5.0-alpha.4 (F4) a `moved` cut between its own writes never leaves a state that grants** (the sweep alone, with no other writer running; concurrent writers are the declared limit just cited): `scopes.moved` (and a `scopes.attached` of a scope the tree already knows, which is a move) **deletes the `scope#binding` edges the NEW chain forbids before writing the tree edge**, and runs the full sweep — delete what the rule forbids, write what it requires — after it. Until then the order was edge first, sweep after, and the state in between (the moved unit under its new parent with the old owner's role still visible) was a real state of the store that granted past a deny of the old owner — measured on 15.9 % of concurrent checks; this paragraph called that "the temporary fail-open of the relay lag", which was false: that state gained access a deny had removed. A move cut between its writes now fails **closed** — the user loses the local role early, never gains it — and is completed by the relay's retry or by a reconcile. What F4 removes is that intermediate STATE; a single check that spans two writes can still read one before and one after (the [torn read](#limits) row). `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 the rule never lets a role cross tenants (*"dos tenants definen el mismo slug"*) — in `openfga` (experimental in 2.5.x), with the declared exception of the races between writers (`openfga.races.settle-window` and `openfga.catalog.permits-twin` in [Limits](#limits)), which can leave a local role visible in another tenant's subtree for the window of a writer's settle, or a removed permission granting until an `authz:reconcile`. A deny anywhere in the chain still wins over a local role (invariant 2; on `openfga`, with the torn-read exception of [Limits](#limits)).
454
462
 
455
463
  **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.
456
464
 
@@ -563,6 +571,107 @@ tenant — and it is **mandatory**: a relation in tenant A never resolves in ten
563
571
  the mono-tenant value). The partition lives in the object id (`document:<partitionKey>|<uuid>`), not
564
572
  in the model.
565
573
 
574
+ **How deep groups may nest — each driver declares its ceiling (2.5.0-alpha.4, R1).** A nesting edge is
575
+ `relate({ object: { type: 'group', id: child }, relation: 'member' }, 'member', { type: 'group', id: parent }, tenant)`;
576
+ its depth is the number of `group ⊂ group` hops of the **longest** path that crosses it, upwards *and* downwards (a
577
+ short path and a long one to the same top group count as the long one: OpenFGA answers 503 to non-members along the
578
+ long one). **`openfga` has a ceiling and enforces it by default**: it **calibrates** it against your server on first
579
+ use — read-only, one `Check` per probe with the whole chain as contextual tuples on ids that do not exist in the store,
580
+ under `HIGHER_CONSISTENCY` — as the **minimum** of a member probe (answers `true`) and a non-member probe that has
581
+ memberships of its own (answers `false`; a probe without memberships is blind, it answers `false` at any depth). On
582
+ OpenFGA v1.19 with the default `--resolve-node-limit` that is **22** (a member resolves 24 hops, a non-member with
583
+ memberships 22; measured 200/200 at both edges, independent of `includes` and of the deny). Only success is
584
+ remembered, once per process: a calibration that fails (503, a timeout, a blind probe's 500) gives the same kind of error
585
+ to the nesting writes of the next `GROUP_DEPTH_CALIBRATION_BACKOFF_MS` (2,000 ms) without probing again — a new instance
586
+ per call, with the calibration's error as its `cause`; a calibration **timeout** reaches them as a plain 503
587
+ `E_AUTHZ_BACKEND_UNAVAILABLE`, because they did not touch the network and their write did not happen (no `indeterminate`
588
+ event) —, and the first one after that calibrates again. **Lowering the server's limit no longer needs a restart**: a read answered 503
589
+ `E_AUTHZ_RESOLUTION_LIMIT` forgets the remembered ceiling — at most once every `GROUP_DEPTH_RECALIBRATION_MIN_INTERVAL_MS`
590
+ (60,000 ms) per process — and the next nesting write measures it again. **Raising it does**: nothing answers 503 when the
591
+ server resolves more, so the process keeps the old (lower, safe) ceiling until it restarts. **`database` has no ceiling by default**
592
+ (its recursive query deduplicates and terminates, cycles included) and enforces one only when you opt in with a
593
+ separate switch, `database: { enforceGroupDepth: true }`, which then requires a number. The number is
594
+ **`maxGroupDepth`** in `defineRelationsConfig` — an integer from 1 to 97, or `false` to switch the `openfga` ceiling
595
+ off explicitly; anything else (`0`, `NaN`, `''`, `'24'`, `24.5`, `Infinity`, `null`) is 500 `E_AUTHZ_CONFIG` **when the
596
+ config is defined**, at boot. The package never reads environment variables: validate yours
597
+ (`Env.schema.number.optional()` in `start/env.ts`) and pass the number. In `openfga` the config can only **lower** the
598
+ calibrated ceiling — a larger value is 500 `E_AUTHZ_CONFIG` naming both numbers when the driver calibrates — and the
599
+ `maxGroupDepth` you set for an `openfga` CI does **not** switch the gate on in a `database` production (that is what the
600
+ separate switch is for). Both drivers re-check the pair when they are built (and on every use): a config extended with
601
+ a spread (`{ ...defineRelationsConfig(spec), maxGroupDepth: Number(env.get('X')) }`) that skipped the validation is the
602
+ same 500, never a gate switched off by `NaN`.
603
+
604
+ On a **write**, an edge that would leave a chain longer than the ceiling, that would close a cycle, or whose
605
+ neighbourhood exceeds `GROUP_DEPTH_MAX_NODES` groups is **422 `E_AUTHZ_GROUP_DEPTH_LIMIT`** with zero writes and no
606
+ `onRelationWrite` event — renewals and revivals of an edge included (`unrelate` is never gated; a `relate` identical to
607
+ an edge that already exists is still a no-op, even inside a chain that already exceeds the ceiling). In `openfga` the
608
+ check reads one `Read` per group of the walk, and the downward `Read` also brings the group's holders (the server cannot
609
+ filter a `Read` by user type), so it runs on a **budget** per edge — `GROUP_DEPTH_MAX_READS` requests,
610
+ `GROUP_DEPTH_MAX_TUPLES` tuples read and a total deadline of `GROUP_DEPTH_DEADLINE_MS` —; past the requests or the
611
+ tuples the edge is the same 422 (`details.reason: 'too-many-groups'`, `details.budget` says which bound), never "passes".
612
+ **Past the deadline it is a 503, not a 422**: running out of time says nothing about your data — the same graph passes
613
+ with the server unloaded, and on a shared store another tenant's load can be what slows it — so it is
614
+ `E_AUTHZ_BACKEND_UNAVAILABLE`, transient (retry), nothing written and **no `onRelationWrite` event** (it is not the
615
+ timeout subclass: the write did not happen); never the 422 that tells the tenant to flatten a nesting that is fine. Its `message` is for
616
+ the tenant (the chain, the ceiling, "nothing was written"); *why* the ceiling exists and the server knob
617
+ (`OPENFGA_RESOLVE_NODE_LIMIT`) travel in `error.details`, not enumerable — the same rule as `E_AUTHZ_SCOPE_DEPTH_LIMIT`.
618
+ On a **read**, a chain that is already deeper (written before 2.5.0-alpha.4, or through one of the gaps below) is 503
619
+ `E_AUTHZ_RESOLUTION_LIMIT`, never `false`. **The rule of the letter: the 422 is neutral, the 503 names the backend.** The
620
+ 422 answers the tenant's question and both drivers share it, so its `message` names no backend (a `database` deployment
621
+ with `enforceGroupDepth` would otherwise read about an OpenFGA knob it does not have); the 503 of a read only ever comes
622
+ from `openfga` — and every 503 already names its driver — so its `message` names `maxGroupDepth` and the server knob,
623
+ `OPENFGA_RESOLVE_NODE_LIMIT`. In **`authz:relations:reconcile`** the plan computes the chain the pass
624
+ would leave in each partition — the source's live edges plus what the destination keeps (without `--prune`, every
625
+ live edge it already has) — against the destination's ceiling (`groupDepthLimit()`, calibrated, also in `--dry-run`),
626
+ and a partition that exceeds it is **refused** (`partitionsRefused` with cause `too-deep`, `report.tooDeep`, zero
627
+ writes in it, exit ≠ 0): skipping the edge would drop the denies it carries and grant in the destination what the
628
+ source denies. A cycle that already exists does not refuse a partition by itself (OpenFGA evaluates it); its *k*
629
+ groups count, conservatively, as *k − 1* hops, and the pass migrates it — the plan has already judged the partition, so
630
+ the pass does not ask the destination's write gate again (which would refuse the edge that closes the cycle halfway
631
+ through the pass, or an intermediate state the pass never certifies). **What the gate does not close, declared**: two writes running at the
632
+ same time that each add half of a chain both pass (each reads the graph without the other; in `database` the count
633
+ reads through the caller's transaction, so one transaction does see its own earlier edges, but two transactions do
634
+ not see each other — there is no lock); and an `openfga`-only deployment has no pass that scans chains already in the
635
+ store (no maintenance pass for relations; a read-only verifier is planned for 2.6). `listObjects` is not bound by the
636
+ ceiling: past it (measured up to 100 hops) it still lists the objects correctly while `check` of the same pair is 503.
637
+
638
+ **Calibrating at boot (optional) — log, never rethrow.** "At boot" in the package means *the config is validated* (the
639
+ 500s above, when `defineRelationsConfig` runs); the calibration itself happens once per process, on the first nesting
640
+ write. If you want it earlier — to see a `maxGroupDepth` above what the server resolves in your boot log instead of on the
641
+ first write — call it from the `ready` hook of one of your providers and **record the error, do not throw it**:
642
+
643
+ ```ts
644
+ // providers/app_provider.ts
645
+ export default class AppProvider {
646
+ constructor(protected app: ApplicationService) {}
647
+
648
+ async ready() {
649
+ if (this.app.getEnvironment() !== 'web') return
650
+ try {
651
+ const relations = await this.app.container.make('authz.relations')
652
+ const ceiling = await relations.driver().groupDepthLimit?.()
653
+ logger.info({ ceiling }, 'authz: group nesting ceiling')
654
+ } catch (error) {
655
+ logger.error({ err: error }, 'authz: could not calibrate the group nesting ceiling; the first nesting write will retry')
656
+ }
657
+ }
658
+ }
659
+ ```
660
+
661
+ A `ready` that **throws** turns an OpenFGA hiccup at boot into an app that does not start — and with it
662
+ `node ace authz:relations:reconcile --to=database`, which boots the app and is the way back from `openfga`. Do not
663
+ rethrow. A failed calibration here costs nothing: the write path calibrates on its own (with the wait above).
664
+
665
+ **Planning to move from `database` to `openfga`? Switch the `database` ceiling on first.** With the default (no ceiling)
666
+ a tenant can build a chain deeper than what `openfga` resolves, and `authz:relations:reconcile --to=openfga` will then
667
+ **refuse that tenant's partition** (`too-deep`, exit ≠ 0). The runbook requires a green verification before the switch,
668
+ so one such partition **blocks the cutover of every tenant** until someone flattens that tenant's groups — and,
669
+ meanwhile, that partition keeps the destination's old snapshot (later revokes and denies are not migrated into it, the
670
+ same policy as `renewal-unsafe`). So, in every deployment that may migrate, set
671
+ `database: { enforceGroupDepth: true }` with `maxGroupDepth` at the `openfga` number (22 on v1.19 by default) **before**
672
+ tenants write nested groups; and run `authz:relations:reconcile --to=openfga --dry-run` early — it names each refused
673
+ partition with its chain.
674
+
566
675
  **Spelling is identity (2.5.0, D4).** Relations have no resolver chain to canonicalise against (roles do,
567
676
  invariant 17), so the partition key is a string compared byte for byte — and until 2.5.0 the same tenant
568
677
  uuid written **without dashes** (32 hex, what PostgreSQL's `uuid` type or a `*_ci` collation fold into the
@@ -597,6 +706,165 @@ nothing asked); a per-item `error` inside a 200 `batchCheck` is a **503 for the
597
706
  `decideMany` (2.5.0, lot D). A third-party driver's answer is validated: anything but a `boolean[]` with one
598
707
  position per object is 500 `E_AUTHZ_INTERNAL` naming the driver.
599
708
 
709
+ **Deny on relations (2.5.0).** A relation can be **denied** to a subject on an object: `deny(subject, relation,
710
+ object, partition)` writes the tuple `object#denied_<relation>@subject` (in `authz_relations` on `database`, in the store
711
+ on `openfga`) and **every read honours it** — `check`, `checkMany`, `decideMany`, `listObjects`. It is opt-in **per
712
+ type and in two phases** (`deny: 'read' | 'write'` on the object type in `defineRelationsConfig`; there is no `deny:
713
+ true` — that would be the single switch with no safe rollout order):
714
+
715
+ - `deny: 'read'` (phase 1): the published model declares `denied_<rel>` (`<rel> … but not denied_<rel>`) and all reads
716
+ honour it; `deny()` on that type is still 422 `E_AUTHZ_RELATION_NOT_DENYABLE`.
717
+ - `deny: 'write'` (phase 2): writes are accepted too.
718
+
719
+ The rollout is two deployments: (1) the whole fleet on ≥ 2.5.0-alpha.3 with the type in `'read'`, the model
720
+ republished (`openfga:provision`) and **every pod pinned to the new `modelId`**; (2) verify; (3) a second deployment
721
+ moves the type to `'write'`. Both `openfga` drivers **require a pinned `modelId`, always** (2.5.0-alpha.4, with or
722
+ without deny: 500 `E_AUTHZ_CONFIG` at construction otherwise) and check, once per process, that the pinned model **is
723
+ the one your config generates** — [the model gate](#the-pinned-model-is-the-one-your-config-generates-250-alpha4) —:
724
+ in particular that it declares `denied_<rel>` for every relation of those types **and that `<rel>` subtracts it**
725
+ (`<rel> … but not denied_<rel>`, with the closure of the path cut); a pinned model without it is 500 `E_AUTHZ_CONFIG`
726
+ on first use, never a grant over a denied object. **`decideMany` on `openfga` requires a pinned `modelId` always**, like
727
+ every operation since 2.5.0-alpha.4, and asks the `denied_*` of exactly the types the gate saw in the deny form. A relation of a deniable type measures **at most 43 characters**
728
+ (`DENIABLE_RELATION_NAME_MAX`: `denied_<rel>` has to fit FGA's 50); a type without `deny` keeps the 50.
729
+
730
+ **The semantics is a path cut** (decision D5, the 64-state table T1 in the suite): a deny on `Y` cuts every grant of
731
+ `T` with `Y ∈ up*(T)` (everything that includes `T`), with all that derives from it. With `owner ⊆ editor ⊆ viewer`:
732
+
733
+ | allows | deny | `viewer` / `editor` / `owner` | case |
734
+ |---|---|---|---|
735
+ | owner | viewer | F / F / F | *"D-06 · un deny en viewer bloquea al owner"* |
736
+ | owner | editor | F / F / F | *"D-07 · un deny en editor anula el grant de owner ENTERO"* |
737
+ | owner + viewer | editor | T / F / F | *"D-08 · …salvo un viewer directo"* |
738
+ | viewer | editor | T / F / F | *"D-11 · un deny en una relación MÁS FUERTE no toca un grant más débil"* |
739
+
740
+ A deny reaches the subject **directly or through `group#member`** (nested groups too), and a group membership that
741
+ expires stops carrying it (a deny itself does not expire). Rules that go with it, each with its case: **no expiry**
742
+ (`expiresAt` on `deny`/`removeDeny` is 422 `E_AUTHZ_DENY_EXPIRY_UNSUPPORTED` — measured on OpenFGA 1.19: a live
743
+ deny's condition cannot be changed without a window in which it stops blocking); no `*` subject and no deny on
744
+ `group` (422 `E_AUTHZ_RELATION_NOT_DENYABLE`); a userset subject must be `group#member`; `deny`/`removeDeny` go
745
+ through the same gates as `relate` (`{ transaction }`, the freeze, F-05, `actor`, `assertWrite` with `operation:
746
+ 'deny' | 'removeDeny'`, invariant 13) and notify `onRelationWrite`; `removeDeny` and `listDenies` accept **any declared
747
+ type** (retiring the opt-in must be reversible); `listSubjects` **does not** subtract (it lists facts) and
748
+ `listDenies(relation, object, partition)` lists the direct denies; `listObjects` **does** subtract (on `openfga` the
749
+ server does, so a page cut at the server's cap is a subset — never a denied object), and so does `membersOf` on
750
+ `database` (one `check` per candidate, only when the object has a deny row — at most `RELATIONS_MEMBERS_DENY_CHECK_MAX`
751
+ candidates, 1,000, past it 500 `E_AUTHZ_MEMBERS_DENY_LIMIT` and never a partial list, under one total deadline, 503);
752
+ the purges delete denies too. **On `openfga` a purge that fits in one `Write` is one `Write`**
753
+ (`RELATIONS_PURGE_WRITE_MAX`, 100 tuples — the server's `--max-tuples-per-write` default, measured: 101 is a 400 and
754
+ nothing applies), which FGA applies atomically for the writer — not for a concurrent reader: a check running during that `Write` can see part of it (the [torn read](#limits) row; what this paragraph guarantees is about a purge CUT, i.e. prefixes) —; a larger one deletes the allows first, then **the subject's group
755
+ memberships in a `Write` of their own** (a membership carries denies too), then the denies — so **a purge cut halfway
756
+ grants nothing that neither the state before nor the state after granted**
757
+ (*"purgeSubject cortada, deny por grupo (holder)"*, *"purgeSubject cortada, deny anidado (userset g#member)"*, and the
758
+ symmetric row — the membership carries the allow, the deny is direct —
759
+ *"purgeSubject cortada, allow por grupo × deny DIRECTO (holder)"*; the same table runs without a server in the surface
760
+ guard, with the five order mutants in `npm run check:guard-mutants`).
761
+ **Declared row:** with more than 100 group memberships of the subject, a membership that carries a deny can fall in an earlier `Write` than one that carries an
762
+ allow, and a purge cut between the two grants until it is repeated (it stays marked `indeterminate: true`; the purge is
763
+ not refused before deleting — a purge does not wait) (*"FILA DECLARADA · purgeSubject con más de 100 membresías de grupo"*).
764
+ **The order of the purge is the same function as the reconcile's** (`relationTransitionOrder`,
765
+ `src/relations/tuple_order.ts`; closure 2 of D2): allows, then memberships (by the precedence graph), then denies; one
766
+ ordering point for every multi-request write or delete of relations.
767
+ **Renewing a membership that carries a deny is refused on `openfga`** (closure 2 of sublot D2, owner's decision): `relate`
768
+ on an EXISTING `group:*#member` tuple with another expiry is, on `openfga`, two `Write`s (delete, write: FGA does not
769
+ replace the same key in one `Write`), and between them the membership is not there — if that group, or a group that
770
+ contains it, is the subject of a live deny of the partition, the subject would be granted (for good, if the second
771
+ `Write` failed). So that renewal is **409 `E_AUTHZ_RENEWAL_UNSAFE` with zero writes** (always zero: closure 4) and an
772
+ honest recipe in the message (closure 5 of D2): on `openfga` **there is no window-free way** to change that expiry (see
773
+ *The limit* below); the recommended pattern is memberships WITHOUT expiry in groups that carry a deny, ended with
774
+ `unrelate`; if the expiry must change, `unrelate` + `relate` **accepting the window**, said as such. (Until closure 5 the
775
+ message also offered "retire the deny first, renew, write it again" as if it were the safe way out: it opens the same
776
+ window.) Cost: only on a membership renewal, one `Read` per deny type and one for the parents, per group up the nesting.
777
+ Every other renewal stays two `Write`s, and if the second fails the error is marked as a **partial write**
778
+ (`isPartialWrite`; `onRelationWrite` gets `indeterminate: true`) — until closure 2 it was a 503 that said nothing was
779
+ written while the tuple was gone. **Declared window** (closure 3 of D2): the carrier check reads, then the two `Write`s
780
+ follow; a `deny` on that group (or one containing it) written CONCURRENTLY, between the check and the delete, does not
781
+ reach the subject during the two `Write`s — and for good if the second one fails, because the membership is gone. The
782
+ driver re-checks after the two `Write`s (and after a failed second one) and the window stops being silent: if the renewal
783
+ went through, it throws **409 `E_AUTHZ_RENEWAL_RACED`** (`RenewalRacedError`; its own code since closure 4 of D2 —
784
+ until then it shared `E_AUTHZ_RENEWAL_UNSAFE`, and automating that code's recipe, `unrelate` + `relate`, here would
785
+ reopen the window with the deny already inside the group) marked partial with `renewalApplied: true` and `carriedDeny`
786
+ naming the deny (the final state is right: there is nothing to rewrite; review that subject's decisions in the window);
787
+ if the second `Write` failed, the 503
788
+ keeps its class and code, is marked partial and carries `carriedDeny` and the recipe (write the membership again: it is
789
+ gone, so `relate` is ONE `Write`). It does not close the window: FGA has no primitive to serialise a `deny` against a
790
+ renewal. A membership that is ALREADY EXPIRED carries nothing (it connects nothing), so rewriting it is not refused,
791
+ and neither is a membership whose only path to a deny is an expired nesting edge. `database` renews in one transaction
792
+ and does not refuse.
793
+ The same rule holds for the role facts' twin: `grant` with another expiry on `openfga` is `deleteTuples` +
794
+ `writeTuples`; if the second fails the `assignee` is already gone (fail-closed: the grant is lost), and since closure 3
795
+ of D2 the 503 is marked partial and `onWrite` gets `indeterminate: true` (until then: "not written", no event); so is
796
+ `purgeScope` of roles on `openfga` (several `Write`s: structure, facts, denies, batches of 100) cut after its first one
797
+ (closure 4 of D2; `indeterminate: true`, as its relations twin). The capability is
798
+ **`atomicRenewal`** (optional, normalised to `false`; `database` `true`, `openfga` `false`): a driver without it must
799
+ refuse that renewal the same way — the runner judges both faces — and `authz:relations:reconcile` does not ask it to renew
800
+ a carrying membership: the plan lists it as the destination residue `renewal-unsafe` (same numbers in `--dry-run` and the
801
+ real pass, a line starting with `sin renovar (renewal-unsafe) destino:` with the recipe, exit ≠ 0). **Not renewing is not
802
+ neutral over time**: the destination would keep the OLD expiry, and from it (an extension) the membership no longer
803
+ carries its deny, or until it (a shortening) it still grants what the source stopped granting. **So the PARTITION is
804
+ refused** (closure 5 of D2, owner's decision: option A): a partition with at least one `renewal-unsafe` or
805
+ `deny-conditional` (below) gets **zero writes and zero deletes** — with `--prune` too, expired leftovers included —, its
806
+ destination stays **exactly as it was**, with its previous drift in view, and **every other partition migrates**. The plan
807
+ decides it, so `--dry-run` and the real pass give the same numbers; `report.partitionsRefused` lists each one with its
808
+ causes, its recipe and what is pending (`write`/`update`/`delete`), its row in `report.partitions` has `status:
809
+ 'refused'`, and the command prints one line starting with `partición NEGADA <partition>:` that names the partition and
810
+ the causes and says that its destination stays as it was (exit ≠ 0 while it exists). Refusing does not make the
811
+ destination more restrictive than the source: it leaves it as it was — what it already granted, it still grants, and
812
+ the line shows what is pending. **And "as it was" is not static** (closure 6 of D2): a refused partition IS "not
813
+ renewing", so the drift over time stays — from the old expiry of an extended carrying membership the destination no
814
+ longer carries its deny and **grants what that deny cuts** (if another path grants it) while the source still denies.
815
+ The line says when and what (`a partir de <date> el destino concederá a <subject> lo que cortan <denies>`; in the report,
816
+ `partitionsRefused[].evolves`). Ways out for a refused partition, none free: `unrelate` the membership in the SOURCE and
817
+ repeat; or `unrelate` + `relate` it in the DESTINATION with the source's expiry (or none), accepting the window; or wait
818
+ for the destination's old expiry — then the tuple is absent for the plan, the rewrite is a plain write and the partition
819
+ migrates on its own, with the interval above in between. Making the membership expiry-less in the SOURCE does NOT unlock
820
+ it (the destination's still expires: that is still an expiry change). (Closure 4 withheld, tuple by tuple, what the lost deny would let grant, with a
821
+ pass-wide refusal `carrier-conflict`; measured, that left grants the destination already gave in place without saying so,
822
+ and one tenant could block every other partition with `--prune`. Both are gone.)
823
+ **The limit: changing the expiry of a deny-carrying membership on `openfga`.** FGA cannot replace the same key in one
824
+ `Write` (no upsert: [openfga/openfga#2151](https://github.com/openfga/openfga/issues/2151)), so a renewal is two `Write`s,
825
+ and between them the subject does not get the group's deny. Therefore, on `openfga`: `relate` with another expiry on a
826
+ membership whose group (or a group containing it) carries a live deny is **409 `E_AUTHZ_RENEWAL_UNSAFE`, zero writes**;
827
+ the reconcile refuses that partition when its plan counts that deny (a deny EXPIRED at the pass's `now` carries nothing,
828
+ for the plan and for the driver alike — closure 6 of D2; until then the driver counted it and the real pass aborted with
829
+ a 409 the dry run had not announced). A deny the plan does NOT see can still reach the driver's 409 and abort the pass
830
+ instead (not measured; e.g. a leftover deny on a relation the destination no longer declares).
831
+ **There is no window-free way to do it.** The recommended pattern, for the future: memberships
832
+ **without expiry** in groups that carry a deny, ended with `unrelate`. If an expiry must change: `unrelate` + `relate`,
833
+ **accepting the window** — while it lasts the subject does not get the group's deny and is granted whatever another path
834
+ gives it and that deny cuts. **Declared window (TOCTOU of N1):** a NEW deny on a group written while ANOTHER membership of
835
+ that group is being renewed (its two `Write`s) does not reach that subject in between — the driver re-checks after the
836
+ renewal and says so (`E_AUTHZ_RENEWAL_RACED`), but it cannot close it. `database` renews in one transaction and has
837
+ none of this. **When OpenFGA offers an upsert, the renewal will be one `Write` and a candidate for `atomicRenewal: true`**
838
+ on `openfga`, without touching the model, once a concurrent-reader measurement confirms it. **A destination tuple
839
+ that is expired at the pass's `now` is ABSENT for the plan** (closure 3 of D2): rewriting it is a plain write (never a
840
+ renewal, never `renewal-unsafe`), it is not part of the order's context (no carrier, no edge of the self-cut, no
841
+ "renewal" in phase 4), and with `--prune` the expired leftovers are deleted first and outside the order (they connect
842
+ nothing). Until closure 3 an expired carrying membership was taken as live: the pass refused to "renew" it while it
843
+ wrote the new allow, and the destination granted to a subject the source denied, pass after pass.
844
+ **`database` honours a `denied_*` row always**, also on a type without the opt-in (a deny row is a fact): retiring
845
+ `deny` from a type with live denies does not switch them off — remove them with `removeDeny`. Both drivers declare the
846
+ capability `relationDenies` (optional, normalised to `false`); a driver without it answers the four deny operations
847
+ with 500 `E_AUTHZ_UNSUPPORTED` and zero calls.
848
+
849
+ **`decideMany` is the chain primitive.** `decideMany(subject, relation, objects, partition)` →
850
+ `('allow' | 'deny' | 'none')[]` by position, with the same guarantees as `checkMany` (1..50 objects, `[]` and 51 are 422
851
+ `E_AUTHZ_CHECK_MANY_BATCH`, F-05 per item, one `now` per batch, a per-item error is a 503 for the whole batch — never
852
+ `none`). `allow` if `check` grants on that object; otherwise `deny` if the subject has a live deny (direct or through a
853
+ group) on any relation of `up*(down*(R))` on it; otherwise `none`. *`decideMany` is the chain primitive: walk from the
854
+ leaf up and stop at the first non-`none`. `deny` means "do not inherit past this object"; it is broader than `check` on
855
+ purpose (fail-closed). A deny given to `org#member` does not reach a guest who is not a member: to make a folder grant
856
+ only what it holds directly, break inheritance in your evaluator (`inherits = false`).* It answers **by the partition of
857
+ the object**: a guest holder with an allow in organisation A and a deny on the same object id in organisation B gets
858
+ `allow` in A and `deny` in B (and `check`/`checkMany` agree). Do not use `checkMany` for a chain: with the deny on the
859
+ middle object and the allow on its parent, `checkMany` says `[false, false, true]` and `decideMany` says `[none, deny,
860
+ allow]`.
861
+
862
+ **Writes that remove access can GIVE access back — declared rows, each with its case.** With a deny on `g#member`:
863
+ `unrelate(u, member, g)` gives `u` back the allow it holds directly (S8), `purgeObject(group:g)` does the same (S9), and
864
+ `purgeSubject(g#member)` deletes the group's denies (S10) (*"S8–S10 · FILAS DECLARADAS"*). A policy that treats "leave a
865
+ group" or "purge" as safe has to know it. And an `openfga` pod still pinned to a model **without** `denied_*` grants
866
+ over a denied object (S11, *"S11 · FILA DECLARADA"*): that is why the rollout above pins every pod before phase 2.
867
+
600
868
  **A pure, stable key for a tuple (2.5.0).** `relationTupleKey({ partition, object, relation, subject, kind? })`
601
869
  (root export) is deterministic and injective over exactly those five components — the expiry is not part of it
602
870
  (renewing a share keeps its key, even though `database` rewrites the row with a new uuid) and neither is any
@@ -647,8 +915,9 @@ that grammar (2.5.0 · B3)**: the only userset the fused model admits as a subje
647
915
  that takes a subject, through the same single function in the manager, both drivers and the published double — until
648
916
  2.5.0 `database` stored that row inert and `openfga` surfaced the server's 400 as a 503.
649
917
 
650
- **And since `2.4.0-alpha.3` F-05 covers **all nine operations of the port** — the eight of alpha.3 plus
651
- `checkMany` (per item) since 2.5.0 —, not only the two writes that compose a tuple.** The alpha.3 audit found the chokepoint one call-site short (pre-existing in alpha.2, in
918
+ **And since `2.4.0-alpha.3` F-05 covers **all thirteen operations of the port** — the eight of alpha.3 plus
919
+ `checkMany` (per item) since 2.5.0 and `deny`, `removeDeny`, `listDenies` and `decideMany` (per item) since
920
+ 2.5.0-alpha.3 —, not only the two writes that compose a tuple.** The alpha.3 audit found the chokepoint one call-site short (pre-existing in alpha.2, in
652
921
  code alpha.3 had just rewritten): `purgeObject`/`purgeSubject`/`check`/`listObjects`/`listSubjects`/`membersOf`
653
922
  never called `assertRelationDeclared`, so in the shared store `purgeObject({ type: 'role_binding', id: R }, S)`
654
923
  composed — byte for byte — the id of a real binding and **deleted the real binding** with proof of zero
@@ -712,7 +981,23 @@ the `modelId` goes to every pod by hand, see [the cutover window](#the-cutover-w
712
981
  `syncAuthzCatalog` nor a config save republishes anything. The persisted copy has one reader today:
713
982
  `authz:relations:reconcile`, which uses it as the **destination's** config to flag model drift — and since
714
983
  2.5.0 a *failed* read of that table stops the command before any write (an unreadable config is not "no
715
- config"; `null` is).
984
+ config"; `null` is). **Since 2.5.0-alpha.4 a save is also a guard (decision (8)):** `saveRelationsConfig` refuses a
985
+ config that shrinks the reach of a live deny of `authz_relations` — narrowing an `includes`, directly or transitively,
986
+ or withdrawing a relation or a type that has denies — and, while denies live, it refuses any save without a usable
987
+ baseline (no row, or a row it cannot type), with 500 `E_AUTHZ_DENY_REACH_REFUSED` and nothing written. Two named ways
988
+ out, apart: `{ establishBaseline: true }` adopts the baseline (only when there is none; it still judges orphans) and
989
+ `{ allowDenyReachShrink: ['note#editor'] }` exempts only the findings it names, and a name that exempts nothing in
990
+ that save is 500 `E_AUTHZ_CONFIG` — remove the list after the save that needs it (see the row in [Limits](#limits)).
991
+ **If you use deny, persisting the config is a mandatory release step**, in this order: save the config the release
992
+ serves (the guard) → `checkRelationsConfigDenyReach(served)` (it reads the row and the live denies, writes nothing,
993
+ and returns `ok: false` with one line per finding — fail the job on it) → serve. Run that check in CI after the save
994
+ and before serving, and in a cron; never as a hard failure in `ready` (an old pod restarting mid-deploy serves the
995
+ narrow config against the new row and would loop). Adopt the baseline once, with the spec the fleet serves today, in a
996
+ release that does **not** change `relations.config` (adopting in a release that narrows certifies the narrowing). Save
997
+ with the **same `connection`** as the `database` relations driver: the guard reads `authz_relations` through the
998
+ connection it is given. The persisted copy is also the **baseline** of the deny-reach detector of
999
+ `authz:relations:reconcile --dry-run`, which needs **two** relations drivers (source ≠ destination); in a
1000
+ `database`-only deploy that command cannot run, so `checkRelationsConfigDenyReach` is your detector.
716
1001
 
717
1002
  **Migrating tuples between drivers** is `node ace authz:relations:reconcile --to=<key>` — the relations
718
1003
  analog of `authz:reconcile`, idempotent, bidirectional and never silent (it reports written / deleted
@@ -720,9 +1005,88 @@ analog of `authz:reconcile`, idempotent, bidirectional and never silent (it repo
720
1005
  `--dry-run` is the read-only verifier and also flags **model drift** (an object type in the source the
721
1006
  destination does not declare); `--prune` deletes what the source no longer backs — except a destination row the API
722
1007
  does not admit (`details`: `invalid-identity`, `userset-unsupported`, `undeclared` — a relation or type the
723
- destination no longer declares), which no flag touches: listed, exit ≠ 0 in every mode, deleted by hand or, for
1008
+ destination no longer declares —, `invalid-condition` — a tuple whose condition the package does not write or cannot
1009
+ read, 2.5.0-alpha.4: present and untouchable in the destination, never rewritten from the source, and never migrated
1010
+ from the source as "permanent"), which no flag touches: listed, exit ≠ 0 in every mode, deleted by hand or, for
724
1011
  `undeclared`, declared again ([Upgrading, 6](#upgrading-to-250)); a 422 of the relations family from the destination's
725
- `unrelate` is classified the same way and never aborts the pass. **Never towards the
1012
+ `unrelate` is classified the same way and never aborts the pass. **Denies migrate as denies (2.5.0-alpha.3):**
1013
+ `enumerateRelations` marks every tuple with `kind` (`denied_viewer` is enumerated as `viewer` + `kind: 'deny'`), the key
1014
+ carries it, and the pass applies the WHOLE transition of each partition —writes, renewals and, with `--prune`, deletes—
1015
+ as **one sequence**, in the order of ONE function shared with the `openfga` purges (`relationTransitionOrder`,
1016
+ `src/relations/tuple_order.ts`), in five phases: (1) new denies (`deny`); (2) with `--prune`, leftover allows on
1017
+ non-`group` objects; (3) the group memberships (`group:*#member`) that come in and go out, in the order of a
1018
+ **precedence graph** (computed in the union of both states: between two memberships of a common subject, one that brings
1019
+ a deny on X goes before one that brings an allow on X; one that leaves carrying an allow on X before one that leaves
1020
+ carrying a deny on X; one that leaves before one that comes in on the same nested path when the composed path would
1021
+ carry an allow; one that comes in carrying a deny before one that leaves carrying a deny that protects an allow of the
1022
+ subject; since closure 4 of D2 a membership RENEWAL is in that graph too, as the entry —or, if it shortens, the exit— it
1023
+ becomes at the shorter of its two expiries); (4) new allows and the renewals of allows that extend (one that shortens
1024
+ goes with the deletes of phase 2); (5) with `--prune`, the leftover denies **last** (`removeDeny`, never
1025
+ `unrelate`). Never the source's enumeration order (`database` enumerates by creation: a document shared on Monday, its
1026
+ user put in a denied group on Tuesday). Until closure 2 of sublot D2 every write went before every delete, and a former
1027
+ member composed with a new allow (U ∈ g leaves, `g#member viewer X` arrives: cut after the first write, U was granted X).
1028
+ **What that guarantees, exactly — demonstrated by brute force, not by hand-made tables**: **if the precedence graph has
1029
+ no cycle, no state of a pass cut after any write grants something neither the initial nor the final state granted, and
1030
+ no subject denied in both is granted in any.** The **residue is the cycle** — two memberships changed in the same pass
1031
+ whose order constraints contradict each other; the typical shape: the same subject joins (or leaves) two groups at once,
1032
+ and each brings a deny that cuts an allow the other brings — and there, in general, **no order of the diff is safe**;
1033
+ the pass applies them in a fixed order and reports them (`report.orderResidue`, a warning line starting with
1034
+ `orden SIN garantía`: the final state is right, so it is not drift; if the window matters, apply them in two passes). The
1035
+ demonstration (`tests/relations_order_exhaustive.spec.ts`, oracle = the package's own path cut, both with `--prune` and
1036
+ without): **exhaustive** over 1 user, 2 groups with one nesting level, 2 objects and one relation with deny (10 possible
1037
+ tuples, every (initial, final) pair: 2,097,152 pairs, ~23 s) — 0 pairs grant without a declared residue; 4,176 pairs have
1038
+ a cycle, 816 of them grant in some prefix and 352 of those admit no safe order of the diff at all — plus a seeded sample
1039
+ of 30,000 pairs per mode of the universe with 2 users and direct/group allows and denies (21 tuples) and of one with three
1040
+ groups and two nesting levels (20 tuples): 0 without residue. **Group membership counts as a grant** (closure 5 of D2):
1041
+ the oracle also judges `(user, group:G)` — being a member is itself a grant (`check(member)`, `membersOf`) —, so a pass
1042
+ cut between `+U ∈ h` and `−h ⊂ g` must not make U a member of g, which neither state did; the order gives that pair a
1043
+ precedence (the price, declared: more pairs with a cycle in the two-level universe, 4,188 of 60,000 sampled, all of them
1044
+ reported as order residue). **Validity and the time HORIZON are part of the universe**
1045
+ (closures 3 and 4 of D2): every destination tuple is live (expiring at `now + 1 h`), expired or absent, and the source
1046
+ has it absent or live with the same, a LATER or an EARLIER expiry (8 states per tuple); the plan is derived by the same
1047
+ rules as the pass (`liveInDestination`: expired = absent; `deny-conditional` in the plan; a partition with a
1048
+ `renewal-unsafe` or a `deny-conditional` is refused — zero writes, so its state is the initial one at every instant: the
1049
+ ONLY exclusion), renewals are steps (one write on an `atomicRenewal` destination; two `Write`s with the gap checked as a
1050
+ prefix on `openfga`), and **every prefix and the final state are judged at
1051
+ `now` and at every later expiry** (`+30 min`, `+1 h`), each tuple with ITS expiry (the destination's if the pass did not
1052
+ touch it, the source's if it wrote or renewed it, none for a deny it wrote). Property: at no instant of the horizon does a
1053
+ prefix grant outside initial ∪ source, or to a subject denied in both, without a declared order residue; and the final
1054
+ state never grants what the source denies at that instant — no exception (closure 4 counted a `preexisting` grant as
1055
+ declared; it is gone with the withholding). **Exhaustive** over the reduced
1056
+ universe (8 tuples: 33,554,432 combinations per destination kind, `AUTHZ_ORDER_VIGENCIA_EXHAUSTIVE=1`) and seeded
1057
+ samples of the four universes in the default suite — 0 grant without a declared residue. Without the destination's config (`toConfig`) the order does
1058
+ not assume any allow is cut by a deny (more precedences, more residue, never less guarantee). Surface-guard properties
1059
+ `relations.order.exhaustive` (the brute force over a reduced universe, 131,072 pairs, plus a declared sample of 50,000
1060
+ combinations per mode with validity and horizon, on every mutant of the order),
1061
+ `relations.reconcile.order` and `openfga.purge.order`, the last two judged with the source enumerated in both orders. **The destination driver decides, before the first write, whether it can take each deny**
1062
+ (`capabilities.relationDenies` and its own `denyPhase(type, relation)`: the opt-in of ITS config, and on `openfga` its
1063
+ pinned model, D-31 — never the persisted spec): a source deny that the destination cannot write (`no-capability`,
1064
+ `no-deny-phase`, `phase` — the type is not in `deny: 'write'` there —, `undeclared`, `model`, `unmarked` — a source driver
1065
+ that does not mark `kind`, on either side —, `invalid`) makes the pass refuse with 500 `E_AUTHZ_DENY_MIGRATION_REFUSED` and **zero
1066
+ writes** in every partition — never `skipped`: writing the allows without the deny would grant what the source denies.
1067
+ `--dry-run` does not throw: one line per refusal starting with `deny NO migrable`, and exit ≠ 0. **Each refusal carries
1068
+ the recipe of ITS reason** (`DenyRefusal.recipe`): `phase` — the type in `deny: 'write'` in the destination's config
1069
+ (provision → pin → deploy); `undeclared` — a relation RETIRED with live denies: re-declare the relation temporarily in the
1070
+ config of the process that writes the source (the relation is enough; `removeDeny` needs no opt-in), `removeDeny` those
1071
+ denies, retire it again and repeat (`removeDeny` on an undeclared pair is 422, F-05; executed in the suite); `model` —
1072
+ publish and pin the destination's model; `unmarked` — update the driver that does not mark `kind`; `invalid` — delete
1073
+ the row by hand (the API would reject `removeDeny` too); `no-capability`/`no-deny-phase` — a destination that can write
1074
+ denies. (A partition refused by the plan, above, writes nothing, so its denies do not ask for a phase.) A deny already present in a destination WITHOUT the opt-in is refused too (`phase`) although `database` would
1075
+ honour the row (D8): `denyPhase = null` means «no opt-in» and the pass does not certify what the destination does not
1076
+ declare. **A destination deny with an expiry** (only writable from outside the package, D3) —live with another expiry, or
1077
+ ALREADY EXPIRED— that the source has is rewritten permanent on `database`; on a destination without `atomicRenewal`
1078
+ (`openfga`: it cannot replace the same key in a `Write`, M5, and its `deny` there is 500 `E_AUTHZ_CONFIG` with the recipe
1079
+ `removeDeny` + `deny`, a declared window between the two) **the plan decides it** (closure 4 of D2; same numbers in
1080
+ `--dry-run` and the real pass): the destination residue `deny-conditional` (exit ≠ 0), not rewritten, and **its partition
1081
+ is refused** (closure 5, above) — an expired one denies nothing, and until closure 4 the pass still wrote the allow it cut
1082
+ (the destination granted, and `--dry-run` reported a different count). A driver that declares
1083
+ `atomicRenewal` and still answers that 500 contradicts its capability: the error propagates. The report carries
1084
+ `denies: { written, updated, deleted, unchanged, extra }` (a subset of the totals, printed as its own `denies:` line),
1085
+ `denyRefused` (dry-run) and `partitions` (one row per partition with its status); a pass that blows up halfway throws
1086
+ with `error.report` (`partial: true`: the partitions `done`, the one that `failed` with what it wrote, the `pending`
1087
+ ones) and the command prints it, one `pasada PARCIAL:` line per partition; one that fails before writing anything has
1088
+ `partial: false` and prints `pasada FALLIDA sin escribir nada:`. **The totals `written`/`unchanged`/`extra` include the
1089
+ denies** (2.5.0-alpha.3, a change of meaning): subtract `denies` to count allows only. **Never towards the
726
1090
  ACTIVE relations driver** (2.4.0-alpha.10): once `relations.default ?? config.default` is `--to`, any
727
1091
  other source — with `--from` or picked by elimination — is 500 `E_AUTHZ_STALE_SOURCE_REFUSED` (it
728
1092
  would resurrect shares revoked since the cutover and, with `--prune`, delete the new ones); `--dry-run`
@@ -814,8 +1178,14 @@ driver tolerates old `NULL` rows on read meanwhile.
814
1178
  | `unrelate` | `subject`, `relation`, `object`, `partition`, `actor` | — |
815
1179
  | `purgeObject` | `object`, `partition`, `actor` | no `subject`, no `relation` |
816
1180
  | `purgeSubject` | `subject`, `partition`, `actor` | no `object`, no `relation` |
1181
+ | `deny` (2.5.0) | `subject`, `relation` (the public name, never `denied_<rel>`), `object`, `partition`, `actor` | no `expiresAt` (a deny does not expire) |
1182
+ | `removeDeny` (2.5.0) | `subject`, `relation`, `object`, `partition`, `actor` | no `expiresAt` |
1183
+
1184
+ **`operation` widened in 2.5.0 with `deny` and `removeDeny`** — on the event and on the `RelationRef` that `assertWrite`
1185
+ receives. TypeScript does not warn about a widened union inside an `if`/`else`: **a handler that treats everything that is
1186
+ not `unrelate` as an addition grants too much** (it would index a deny as a share). Switch on the operation.
817
1187
 
818
- A purge notifies **one event per call** — even when the driver sweeps both spellings of the partition uuid underneath, and even when it deletes many tuples — and it **does not count**: the port returns `void` (a count would break the port for third-party drivers, and inside `{ transaction }` it would be a number your rollback contradicts). If you need the "how many", call `enumerateRelations` (or `listSubjects`) before purging — inside your transaction, through your transaction. And a purge that deletes nothing notifies all the same: the auditable fact is *someone asked to delete the keys of X*, whether or not there were any. `scopes.detached` on the roles port emits one `scope_purged` per spelling because there the **manager** canonicalises; the relations manager does not, so the divergence is on purpose and pinned (*"E6 · UN evento por llamada aunque el driver barra DOS ortografías"*). A purge does not go through `assertWrite` — `RelationRef.operation` is `'relate' | 'unrelate'`, the published type of the R-13 hook — and that is pinned too (*"E9 · negativo declarado"*). But F-05 runs first on the purges too (2.4.0-alpha.3, closing the audit's critical finding): `purgeObject` validates the object type and `purgeSubject` the userset of its subject **before** the actor, the scope and the driver, so a purge of a `role_binding` is 422 with zero driver calls and zero events — see [F-05 on the seven operations](#relations-rebac-24) above. `purgeSubject` also validates its subject like `relate` does (a malformed holder or `{}` is 422 `E_AUTHZ_INVALID_IDENTITY` and never reaches the event).
1188
+ A purge notifies **one event per call** — even when the driver sweeps both spellings of the partition uuid underneath, and even when it deletes many tuples — and it **does not count**: the port returns `void` (a count would break the port for third-party drivers, and inside `{ transaction }` it would be a number your rollback contradicts). If you need the "how many", call `enumerateRelations` (or `listSubjects`) before purging — inside your transaction, through your transaction. And a purge that deletes nothing notifies all the same: the auditable fact is *someone asked to delete the keys of X*, whether or not there were any. `scopes.detached` on the roles port emits one `scope_purged` per spelling because there the **manager** canonicalises; the relations manager does not, so the divergence is on purpose and pinned (*"E6 · UN evento por llamada aunque el driver barra DOS ortografías"*). A purge does not go through `assertWrite` — `RelationRef.operation` is `'relate' | 'unrelate' | 'deny' | 'removeDeny'` (the four writes on ONE tuple), the published type of the R-13 hook — and that is pinned too (*"E9 · negativo declarado"*). But F-05 runs first on the purges too (2.4.0-alpha.3, closing the audit's critical finding): `purgeObject` validates the object type and `purgeSubject` the userset of its subject **before** the actor, the scope and the driver, so a purge of a `role_binding` is 422 with zero driver calls and zero events — see [F-05 on the seven operations](#relations-rebac-24) above. `purgeSubject` also validates its subject like `relate` does (a malformed holder or `{}` is 422 `E_AUTHZ_INVALID_IDENTITY` and never reaches the event).
819
1189
 
820
1190
  **The four guarantees, on the four writes** (`relate`, `unrelate`, `purgeObject`, `purgeSubject`):
821
1191
 
@@ -860,14 +1230,17 @@ Every error the package raises carries `status` and `code`. A standard AdonisJS
860
1230
  | `E_AUTHZ_WITHIN_ROOT_FORBIDDEN` | 422 | `requireWithin: 'non-root'` and `within: APP_SCOPE` (2.1) |
861
1231
  | `E_AUTHZ_ACTOR_REQUIRED` | 422 | `requireActor: true` and a write without `actor` (2.1); on the relations port (`relations.requireActor ?? requireActor`) the four writes, `purgeObject`/`purgeSubject` included since 2.4.0-alpha.3 |
862
1232
  | `E_AUTHZ_SCOPE_DEPTH_LIMIT` | 422 | `scopes.attached`/`scopes.moved` with `scopes.maxDepth` declared: the child would end up more than `maxDepth` `parent` hops from `app` (the parent's chain + 1). Refused **before** writing or enqueueing; the gate is opt-in and partial — see [Limits](#limits) (2.4.0-alpha.5). Since 2.4.0-alpha.8 the `message` is written for the tenant that made the request — the depth reached, `scopes.maxDepth`, "nothing written or enqueued" — and names no backend: *why* the ceiling exists (`FACTS_MAX_RESOLVE_DEPTH`, `OPENFGA_RESOLVE_NODE_LIMIT`) travels in `error.details` for the operator's log |
1233
+ | `E_AUTHZ_GROUP_DEPTH_LIMIT` | 422 | Relations (2.5.0-alpha.4, R1): a nesting `relate` (`group#member` into `group`) would leave a chain of nested groups longer than the driver's ceiling, would close a cycle, or walks more than `GROUP_DEPTH_MAX_NODES` groups — or, in `openfga`, exceeds the check's budget of `GROUP_DEPTH_MAX_READS` requests or `GROUP_DEPTH_MAX_TUPLES` tuples read (`details.reason`: `too-deep`/`cycle`/`too-many-groups`; `details.budget`: `nodes`/`reads`/`tuples`). Nothing written, no event. Running out of the check's total time (`GROUP_DEPTH_DEADLINE_MS`) is not this 422 but a transient 503 `E_AUTHZ_BACKEND_UNAVAILABLE`. The `message` is for the tenant and names no backend; `details` (not enumerable) carries the ceiling, why it exists and `OPENFGA_RESOLVE_NODE_LIMIT` — see [Relations](#relations-rebac-24) and [Limits](#limits) |
863
1234
  | `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) |
864
- | `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` |
865
- | `E_AUTHZ_BACKEND_TIMEOUT` | 503 | `timeoutMs` elapsed (subclass of the above) |
1235
+ | `E_AUTHZ_INVALID_PAGE` | 422 | `enumerateRelations`/`enumerateRelationsMany` (relations, 2.5.0-alpha.4 · 7.4) with a page that is not `{ limit?, after? }`: a `limit` that is not an integer ≥ 1, an `after` that is not a non-empty string or — on `database` — not the canonical uuid that driver emits as its cursor. Zero statements; a `limit` above `RELATIONS_ENUMERATE_MAX_LIMIT` is clipped, not this error. `InvalidRelationPageError` |
1236
+ | `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); on `openfga` (since 2.5.0-alpha.4, R3) only the **transient** class: 5xx (501 included), 408, 429, `deadline_exceeded`, a per-check `internal_error` in a `batchCheck` — a 4xx is no longer a 503, see [How OpenFGA failures are classified](#how-openfga-failures-are-classified-250-alpha4); and a nesting `relate` whose depth check ran out of its total time (`GROUP_DEPTH_DEADLINE_MS`: a slow server, not your data — nothing written, no event; 2.5.0-alpha.4, third closure of R1) |
1237
+ | `E_AUTHZ_BACKEND_TIMEOUT` | 503 | `timeoutMs` elapsed (subclass of the above); on `openfga` also the SDK error that carries no response at all (a client timeout and a refused connection — but also a DNS failure, a TLS error or a reset connection — arrive as the same bare `FgaError`, and are not told apart without reading the message: the conservative class, `indeterminate`, 2.5.0-alpha.4), and a **write** answered 504 or `deadline_exceeded` (the deadline of the server or of a gateway in front of it: the write may have landed) |
1238
+ | `E_AUTHZ_RESOLUTION_LIMIT` | 503 | `openfga` (2.5.0-alpha.4, R3): the server answered `authorization_model_resolution_too_complex` — a scope chain or a group nesting deeper than it resolves —, from a single `Check` or as the per-check error of a `batchCheck`. Subclass of `E_AUTHZ_BACKEND_UNAVAILABLE` (a 503, transient for the relay) because at the edge it is probabilistic and a retry may succeed; past the bound it always fails. Never `false`. The bounds are in [Limits](#limits) |
866
1239
  | `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) |
867
1240
  | `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 |
868
1241
  | `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 |
869
- | `E_AUTHZ_WRITE_CONFLICT` | 409 | an `openfga` write kept clashing with another transaction over the same tuples (FGA answers `Aborted`/409, or 400 "cannot write a tuple which already exists"): the driver re-reads and re-applies, and only gives up after three rounds. The backend answered, so this is never a 503 — retry the write. Also (2.4.0-alpha.2, lot L-3) a `database` `grant`/`deny` **inside your transaction** (`{ transaction }`) that hits the UNIQUE because another transaction committed the same fact while yours was open: outside a transaction the driver re-reads and keeps the winner's row; inside yours it cannot (PostgreSQL has already aborted your transaction — every later statement is `25P02` until you roll back — and under REPEATABLE READ the re-read would not see the winner), so it **poisons your transaction**: roll back and retry. Measured on all three engines, see [writing inside your transaction](#writing-inside-your-transaction-240-alpha2). The same for a `database` `relate`/`unrelate`/`purge*` inside your transaction (lot L-4) — on a UNIQUE clash of a tuple (userset, and since lot L-4b holder too: `subject_relation` is `''`, never `NULL`), and on a **deadlock** (two transactions writing two tuples in crossed order: PostgreSQL `40P01`, MySQL `1213`; the engine's victim gets this 409, never a hang nor the raw error) |
870
- | `E_AUTHZ_CONFIG` | 500 | `relayScopeChanges({ only: [] })`, a `ScopeOutbox` that ignores `pending(limit, after, { ids })` and returns an entry that was not asked for, `requeueScopeChanges` with an id that is not parked (or an empty list), or a `scopes.maxDepth` that is not an integer ≥ 1 (2.4.0-alpha.5); contradictory config (`holderTypes` not injective or a holder type not declared in it, `scopes.*` without resolver, `appAccess` without `permission`, `catalog` together with `catalogRevalidate`, an invalid `maxAgeMs`); `bumpAuthzCatalogVersion` called without the writing transaction's client; a `{ transaction }` handed to `sqlScopeOutbox` that is not an open Lucid transaction of the outbox's own connection (another connection, a `QueryClient`, or the whole `db` — the message names both connections), **or one that is already committed or rolled back** (`isCompleted`, 2.4.0-alpha.8 — the same check in the roles and relations drivers; it used to surface as a 503); `sqlScopeOutbox()` enqueueing without `{ transaction }` (`requireTransaction`, 2.4.0-alpha.8; `true` by default since 2.5.0 · B6, `false` is the explicit opt-out); `relayScopeChanges({ deadDetail })` with a value other than `'full'`; `requireTransactionalWrites: true` (root, or `relations.requireTransactionalWrites`) with an active driver that does not declare `transactionalWrites: true` — raised **when the driver is resolved**, so reads fail too and the deployment does not start (2.4); a `relations.assertWrite` that returns a promise — or any value at all: `false`, `true`, a thenable function (it must be synchronous and it does not return verdicts: it throws or it does not; the write is refused before the driver, 2.4.0-alpha.3) |
1242
+ | `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. Since 2.5.0-alpha.4 (R3) also any such race (409, or 400 `write_failed_due_to_invalid_input`) that escapes a write which does not re-read (until then a 503). Also (2.4.0-alpha.2, lot L-3) a `database` `grant`/`deny` **inside your transaction** (`{ transaction }`) that hits the UNIQUE because another transaction committed the same fact while yours was open: outside a transaction the driver re-reads and keeps the winner's row; inside yours it cannot (PostgreSQL has already aborted your transaction — every later statement is `25P02` until you roll back — and under REPEATABLE READ the re-read would not see the winner), so it **poisons your transaction**: roll back and retry. Measured on all three engines, see [writing inside your transaction](#writing-inside-your-transaction-240-alpha2). The same for a `database` `relate`/`unrelate`/`purge*` inside your transaction (lot L-4) — on a UNIQUE clash of a tuple (userset, and since lot L-4b holder too: `subject_relation` is `''`, never `NULL`), and on a **deadlock** (two transactions writing two tuples in crossed order: PostgreSQL `40P01`, MySQL `1213`; the engine's victim gets this 409, never a hang nor the raw error) |
1243
+ | `E_AUTHZ_CONFIG` | 500 | on `openfga` (2.5.0-alpha.4, R3), a failure of the **deployment**: the store or the pinned model does not exist (`authorization_model_not_found`, `latest_authorization_model_not_found`, a 404 `store_id_not_found`), the credentials are refused (401/403), the server rejects a type, relation or context the pinned model does not admit (`validation_error`, also per check in a `batchCheck`), its `--max-tuples-per-write` is below the driver's batches (`exceeded_entity_limit`), or the client refuses a malformed id before calling; and a **4xx the package does not know** (the server's error in `cause`). "Not transient": the same call fails the same way until the deployment is fixed — but the tree relay does **not** consume an attempt on it (a parked `moved` would keep the old parent in the store) and `authz:scopes:relay` prints it on its own line, `fallo de DESPLIEGUE #…`. Also: `relayScopeChanges({ only: [] })`, a `ScopeOutbox` that ignores `pending(limit, after, { ids })` and returns an entry that was not asked for, `requeueScopeChanges` with an id that is not parked (or an empty list), or a `scopes.maxDepth` that is not an integer ≥ 1 (2.4.0-alpha.5); contradictory config (`holderTypes` not injective or a holder type not declared in it, `scopes.*` without resolver, `appAccess` without `permission`, `catalog` together with `catalogRevalidate`, an invalid `maxAgeMs`); `bumpAuthzCatalogVersion` called without the writing transaction's client; a `{ transaction }` handed to `sqlScopeOutbox` that is not an open Lucid transaction of the outbox's own connection (another connection, a `QueryClient`, or the whole `db` — the message names both connections), **or one that is already committed or rolled back** (`isCompleted`, 2.4.0-alpha.8 — the same check in the roles and relations drivers; it used to surface as a 503); `sqlScopeOutbox()` enqueueing without `{ transaction }` (`requireTransaction`, 2.4.0-alpha.8; `true` by default since 2.5.0 · B6, `false` is the explicit opt-out); `relayScopeChanges({ deadDetail })` with a value other than `'full'`; `requireTransactionalWrites: true` (root, or `relations.requireTransactionalWrites`) with an active driver that does not declare `transactionalWrites: true` — raised **when the driver is resolved**, so reads fail too and the deployment does not start (2.4); a `relations.assertWrite` that returns a promise — or any value at all: `false`, `true`, a thenable function (it must be synchronous and it does not return verdicts: it throws or it does not; the write is refused before the driver, 2.4.0-alpha.3); an `openfga` relations purge against a server whose `--max-tuples-per-write` is below `RELATIONS_PURGE_WRITE_MAX` (100) — the server's 400 `exceeded_entity_limit`, marked as a partial write if an earlier `Write` already deleted (2.5.0) |
871
1244
  | `E_AUTHZ_ROLE_IS_NOT_ACCESS` | 500 | `appAccess({ role })` |
872
1245
  | `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) |
873
1246
  | `E_AUTHZ_PURGE_INCOMPLETE` | 500 | `purgeScope` could not prove zero; on the relations port, `purgeObject`/`purgeSubject` of `openfga` could not prove zero after deleting — the error carries the `markPartialWrite` mark and the event was emitted with `indeterminate: true` (2.4.0-alpha.3) |
@@ -875,9 +1248,13 @@ Every error the package raises carries `status` and `code`. A standard AdonisJS
875
1248
  | `E_AUTHZ_STALE_SOURCE_REFUSED` | 500 | `authz:reconcile --to=<config.default>` (or `authz:relations:reconcile --to=<relations.default ?? default>`) with any resolved source other than the destination itself — `--from`, picked by elimination, or `authz_*` (2.4.0-alpha.10): the stale direction — the source stopped seeing the active driver's facts at the cutover, so the pass would resurrect every revocation made since. Nothing was read, frozen or written; for roles the maintenance pass is `--to=<active>` without `--from`, and `--allow-stale-source` is the human decision (`--dry-run` flags `report.staleSource` instead of throwing) |
876
1249
  | `E_AUTHZ_OPERATOR_WINDOW_EXPIRED` | 503 | `freeze()` — and with it `authz:reconcile`, `authz:relations:reconcile`, `withFrozenWrites`, `authz:freeze` — found the OPERATOR window expired (2.4.0-alpha.10): taking a freeze over it would erase the trace that writers resumed mid-cutover. Acknowledge it with `authz:unfreeze`, reopen with `authz:freeze` |
877
1250
  | `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 |
1251
+ | `E_AUTHZ_RENEWAL_UNSAFE` | 409 | `relate` on `openfga` (closure 2 of sublot D2) renewing — another expiry on an existing tuple — a `group:*#member` membership whose group, or a group containing it, is the subject of a live deny of the partition: the renewal is two `Write`s there and between them the subject would be granted. **Always zero writes**; the message carries the honest recipe (closure 5 of D2): there is no window-free way on `openfga` (openfga/openfga#2151); memberships without expiry in groups with a deny, or `unrelate` + `relate` accepting the window. A driver declaring `atomicRenewal: false` must answer it the same way. An already EXPIRED membership is not refused (closure 3 of D2). **Since 2.5.0-alpha.4 (L11) also** `relate` (relations) and `grant` (roles) on `openfga` when the key already exists with a condition the package does not write or cannot read (`foreign`/`unparseable`, see «The pinned model is the one your config generates»): zero writes, the message says "condición que este paquete no escribe"; its recipe is to inspect that tuple and delete it by hand, NOT `unrelate` + `relate` with a window |
1252
+ | `E_AUTHZ_RENEWAL_RACED` | 409 | `RenewalRacedError` (closure 4 of D2; until then the same `E_AUTHZ_RENEWAL_UNSAFE`): an allowed two-`Write` renewal on `openfga` WENT THROUGH, and the re-check after it finds a deny that arrived concurrently and did not reach the subject between the two `Write`s. Marked partial (`indeterminate: true`), `renewalApplied: true`, `carriedDeny`. The final state is right: do NOT apply the `E_AUTHZ_RENEWAL_UNSAFE` recipe (it would reopen the window with the deny inside); review the subject's decisions in the window |
1253
+ | `E_AUTHZ_DENY_MIGRATION_REFUSED` | 500 | `authz:relations:reconcile` / `runRelationsReconcile` (2.5.0-alpha.3): the source carries a live deny the **destination driver** cannot take — no `relationDenies`, no `denyPhase`, the type not in `deny: 'write'` in ITS config, the pair undeclared there, its pinned model without `denied_*`, or a source that does not mark `kind`. Decided in the plan phase: **zero writes** in every partition, never `skipped` (the allows alone would grant what the source denies). `error.refusals` lists them; `--dry-run` flags `report.denyRefused` instead of throwing (`deny NO migrable (<reason>)`, exit ≠ 0) |
878
1254
  | `E_AUTHZ_MASS_PURGE_REFUSED` | 500 | `pruneOrphanRoles({ force: true })` would purge every distinct owner (or more than half the local roles): the signature of a blind `resolveChain`. Nothing was deleted; pass `allowMassPurge: true` (`--allow-mass-purge`) if the prune is real |
1255
+ | `E_AUTHZ_MEMBERS_DENY_LIMIT` | 500 | `membersOf` on `database` over an object with a live deny and more than `RELATIONS_MEMBERS_DENY_CHECK_MAX` (1,000) candidates; never a partial list. 500 and not 422: it is not an invalid question from the caller but a bound of the deployment (precedent `E_AUTHZ_TOO_MANY_LOCAL_ROLES`) (2.5.0) |
879
1256
  | `E_AUTHZ_TOO_MANY_LOCAL_ROLES` | 500 | more local roles than `maxLocalRoles` (10 000) in a `prune-orphans` pass; never a partial list |
880
- | `E_AUTHZ_UNSUPPORTED` | 500 | a primitive needs an optional port method the active driver lacks: `listDenies` (2.1; also behind `defineScopedRole`), `purgeRole` (2.2 — behind `deleteScopedRole`, `authz:catalog:prune-orphans` and, before writing anything, `defineScopedRole`; the `openfga` driver until 2.3); `reconcile` (2.3 — `authz:reconcile --to=<driver>`); `enumerateFacts` (2.3 — being the *source* of `authz:reconcile`; the `database` driver does not implement it: its facts are `authz_*`. Also raised when `--to` is the **active** driver and declares `hierarchyFacts`: its facts are its own, so the pass has to be able to read them instead of rebuilding it from `authz_*`). Also `{ transaction }` on `grant`/`revoke`/`deny`/`removeDeny` or `relate`/`unrelate`/`purge*` when the active driver declares `transactionalWrites: false` (2.4 — the message names the driver and the operation, nothing was called; the way out is the `database` driver or `requireTransactionalWrites`; the `openfga` drivers raise it themselves too, so `manager.driver()` is not a way out — see [the recipe by direction](#openfga-the-recipe-by-direction)), and `{ transaction }` on the delegation API (`defineScopedRole`/`updateScopedRole`/`deleteScopedRole`: the catalog is written through `withAuthzCatalogWrite`, the cross-process serializer, which a caller's transaction would defeat) |
1257
+ | `E_AUTHZ_UNSUPPORTED` | 500 | a primitive needs an optional port method the active driver lacks: `listDenies` (2.1; also behind `defineScopedRole`), `purgeRole` (2.2 — behind `deleteScopedRole`, `authz:catalog:prune-orphans` and, before writing anything, `defineScopedRole`; the `openfga` driver until 2.3); `reconcile` (2.3 — `authz:reconcile --to=<driver>`); `enumerateFacts` (2.3 — being the *source* of `authz:reconcile`; the `database` driver does not implement it: its facts are `authz_*`. Also raised when `--to` is the **active** driver and declares `hierarchyFacts`: its facts are its own, so the pass has to be able to read them instead of rebuilding it from `authz_*`). Also `{ transaction }` on `grant`/`revoke`/`deny`/`removeDeny` or `relate`/`unrelate`/`purge*` when the active driver declares `transactionalWrites: false` (2.4 — the message names the driver and the operation, nothing was called; the way out is the `database` driver or `requireTransactionalWrites`; the `openfga` drivers raise it themselves too, so `manager.driver()` is not a way out — see [the recipe by direction](#openfga-the-recipe-by-direction)), and `{ transaction }` on the delegation API (`defineScopedRole`/`updateScopedRole`/`deleteScopedRole`: the catalog is written through `withAuthzCatalogWrite`, the cross-process serializer, which a caller's transaction would defeat). And (2.5.0-alpha.4 · 7.4) a page argument passed to a relations listing — `listObjects`/`listSubjects`/`listDenies`/`membersOf` take none: anything but `undefined` is refused with zero backend calls, in the manager and in both drivers —, or an `after` handed to `enumerateRelations(Many)` of `openfga` (it never emits a cursor) |
881
1258
  | `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 % |
882
1259
  | `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 |
883
1260
  | `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 |
@@ -932,9 +1309,9 @@ Keep NTP on every process that writes. Second, a `relate` that reads a *ticking*
932
1309
  (one advancing per read) is fine, but it is your test's clock: production leaves `clock` alone and keeps NTP
933
1310
  running.
934
1311
 
935
- `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.
1312
+ `openfga` additionally takes `holderTypes` (required, injective; a holder whose morph name is not in it is 500 `E_AUTHZ_CONFIG`), **`modelId` (required since 2.5.0-alpha.4, in both `openfga` drivers: `openfga:provision` prints it as `OPENFGA_MODEL_ID`; see [the model gate](#the-pinned-model-is-the-one-your-config-generates-250-alpha4))**, 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.
936
1313
 
937
- **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".
1314
+ **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 `Write` carrying the delete of the old parent and the write of the new one — atomic for the writer (the store never holds both parents or none), **not for a concurrent reader**: a check that runs during that `Write` can read the old parent through one branch of the model and the new one through another (the torn-read row of [Limits](#limits)); with local roles in the catalog, the F4 sweep comes before and after it — 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".
938
1315
 
939
1316
  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.
940
1317
 
@@ -958,6 +1335,7 @@ Every driver **declares** this through `driver.capabilities`, and each declared
958
1335
  | `countRoleAssignments` | `true` | **`true`** | how many live facts a role has — what makes `stillGranting` true instead of a guess (2.3; same enumeration `purgeRole` needs) |
959
1336
  | `canonicalScopeReads` | `true` | **`false`** | whether a read canonicalises the caller's scope spelling against your tree before looking for facts (see below) |
960
1337
  | `enumerateFacts` | `false` | **`true`** | the driver can be the **source** of a migration: `enumerateFacts` hands its live facts back page by page, unfiltered and with their expiry (2.3). `database` declares `false` on purpose — its facts *are* `authz_assignments`/`authz_denies`, this package's published schema, so the destination reads them straight from there |
1338
+ | `experimental` (2.5.0-alpha.4; optional, normalised to `false`) | `false` | **`true`** in 2.5.x | the driver is outside the guarantee of the release: it is not built without `experimental: 'openfga@2.5'` and its open limits are in [Limits](#limits); the suite still runs whole against it (no case is skipped) |
961
1339
  | `transactionalWrites` | **`true`** (default; declare `transactionalWrites: false` in the driver options on a pool of 1) | **`false`** | `grant`/`revoke`/`deny`/`removeDeny` with **`{ transaction }`** write the fact **inside the caller's transaction** — *both or neither*, never *"not lost"* (2.4, the `{trx}` panel). `database` does it since 2.4.0-alpha.2 (lot L-3): the write goes through your open Lucid transaction of the driver's connection (`assertCallerTransaction`: another connection, a `QueryClient` or the whole `db` are 500 `E_AUTHZ_CONFIG` before any statement), the authority never does, and the published suite pins **rollback ⇒ zero rows by census** for the four writes — see [writing inside your transaction](#writing-inside-your-transaction-240-alpha2). `openfga` cannot be anything but `false`: a tuple does not enter a SQL transaction and there is no 2PC; what it offers instead is [the recipe by direction](#openfga-the-recipe-by-direction). With `false`, `{ transaction }` is **500 `E_AUTHZ_UNSUPPORTED`** naming driver and operation, with **zero driver calls** (the manager's gate *and* the driver's own) — never ignored, never a warning; `requireTransactionalWrites: true` in the config makes it **500 `E_AUTHZ_CONFIG` when the driver is resolved** (the deployment does not start). Same name and same two gates on `RelationsDriverCapabilities` (`relate`/`unrelate`/`purgeObject`/`purgeSubject`; `relations.requireTransactionalWrites`, inherited from the root) — and since lot L-4 the `database` **relations** driver declares **`true`** too (`new DatabaseRelationsDriver(config, { transactionalWrites: false })` on a pool of 1; see [writing relations inside your transaction](#relations-rebac-24)). The only pair whose **two** faces are mandatory in the suite. Not to be confused with `scopes.*`'s `{ transaction }`, which **enqueues** in the outbox — enqueueing ≠ writing |
962
1340
 
963
1341
  **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.
@@ -1000,7 +1378,7 @@ Two optimisations landed in 2.1, both measured and both **without changing a sin
1000
1378
  - **A shared version in the database.** The migration ships `authz_catalog_version` (one row, `id = 1`). `syncAuthzCatalog` / `node ace authz:catalog:sync` increment it **as the last statement of the sync's transaction** — a sync that does not commit does not bump it. Before serving, each `CatalogCache` compares the version it loaded with that row (one primary-key `SELECT`, with the deadline, classified 503 like any other query; concurrent checks share one read) and reloads when the database is ahead. So a sync run by one worker, one container or a deploy job is seen by **every process on its next question** — no pub/sub, no restart, no TTL (*"el catálogo que decide es el de la base: un sync en otro proceso…"*, a contract case in both drivers, with two managers and two memos over the same database). If the version row cannot be read — the table is missing, **the row is missing or not a number** (a database without the 2.0 migration) — the question is 503 `E_AUTHZ_BACKEND_UNAVAILABLE` saying so: never version `0`, never an answer from a memo that might be stale (*"sin la fila de authz_catalog_version… 503"*).
1001
1379
  - **`catalogRevalidate: 'always'`** (default) checks on every question. **`{ everyMs }`** checks at most once per window: it saves that `SELECT` at the price of a **bounded window in which another process's revocation is not yet seen** (a fail-open window you accept explicitly; a sync in the *same* process is still seen immediately). `{ everyMs: 30_000 }` is a reasonable trade for a read-heavy deployment whose catalog changes at deploy time. The window — like a view's `maxAgeMs` — is measured with a **monotonic clock** (`performance.now()`), so a wall clock stepped backwards by NTP or a snapshot restore neither stretches it nor revives an expired view (*"la ventana de { everyMs } se mide con reloj MONÓTONO"*).
1002
1380
  - **Writing `authz_*` by hand** (a seeder, a data migration, a script) goes through **`withAuthzCatalogWrite(async (trx) => { … })`** — exported: it opens the transaction, runs your write with *that* client and bumps the version **as the last statement, inside**, so either both land or neither. Order matters and is enforced: `bumpAuthzCatalogVersion(trx)` requires the writing transaction's client (500 `E_AUTHZ_CONFIG` without it, or with the global `db`). A bump that commits *before* its write would make every other process reload the **old** rows tagged with the **new** version — and never revalidate again, a permanent fail-open (reproduced with two real processes; closed in 2.1). Until the write commits, the previous catalog stands, pinned as a negative case (*"un cambio en authz_* SIN subir la versión NO se ve"*). `withAuthzCatalogWrite` is the cross-process channel only: this process sees it on its next question under `'always'` and at the end of the window under `{ everyMs }` — call `invalidateAuthzCatalog()` after it if you use `everyMs` and need it at once (what `syncAuthzCatalog` does); `driver.catalog.invalidate()` reaches only that driver's memo (an invalidation that lands while a load is in flight is not lost).
1003
- - Two drivers in one process can share one memo: `new DatabaseAuthorizationDriver({ catalog })` and `new OpenFgaAuthorizationDriver({ catalog })` with the same `new CatalogCache({ revalidate, timeoutMs })` — and without `catalogRevalidate` on the drivers (500 `E_AUTHZ_CONFIG`: the shared memo's `revalidate` is the policy).
1381
+ - Two drivers in one process can share one memo: `new DatabaseAuthorizationDriver({ catalog })` and `new OpenFgaAuthorizationDriver({ experimental: 'openfga@2.5', catalog, … })` with the same `new CatalogCache({ revalidate, timeoutMs })` — and without `catalogRevalidate` on the drivers (500 `E_AUTHZ_CONFIG`: the shared memo's `revalidate` is the policy).
1004
1382
 
1005
1383
  **One `batchCheck` per `authorize` in `openfga`.** The denies of the chain and the roles that grant the permission travel in the same request (the SDK splits at 50 checks and parallelises); the rule is unchanged and evaluated in this order: any per-check `error` ⇒ 503, any deny `allowed` ⇒ `false`, any role `allowed` ⇒ `true`. When no role of the catalog grants the permission anywhere in the chain, the answer is `false` without a request — the denies cannot change it. Each operation takes one snapshot of the catalog, so one version check per question.
1006
1384
 
@@ -1045,6 +1423,7 @@ runAuthorizationDriverContract({
1045
1423
  countRoleAssignments: true, // the port's optional countRoleAssignments (2.3); false ⇒ pruneOrphanRoles reports `undefined`, never `false`
1046
1424
  canonicalScopeReads: true, // reads canonicalise the caller's scope spelling against your tree (2.3); false ⇒ an alias finds no facts (fail-closed)
1047
1425
  enumerateFacts: false, // the port's optional enumerateFacts (2.3): can this driver be the SOURCE of authz:reconcile?
1426
+ experimental: false, // optional (2.5.0-alpha.4): true ⇒ the driver declares itself outside the release's guarantee (the suite still runs whole)
1048
1427
  },
1049
1428
  // The suite builds the scope tree case by case; hand it to your driver.
1050
1429
  makeDriver: (tree) => new MyDriver({ resolveChain: resolveChainFrom(tree) }), // tree.chainOf(scope) = the canonical chain
@@ -1136,6 +1515,24 @@ Seventeen cases per harness (2.4.0-alpha.8): nine core duties — including O-07
1136
1515
 
1137
1516
  ## OpenFGA tooling
1138
1517
 
1518
+ > **Experimental in 2.5.x — not production-ready, outside the semver guarantee, closes in 2.6.** "Outside the semver
1519
+ > guarantee" means exactly this: the experimental parts of `openfga` — the opt-in token and the drivers' behaviour — may
1520
+ > change in 2.6, a minor release, without a major; the subpath, its exports and the rest of the package keep semver. Both `openfga` drivers
1521
+ > (`OpenFgaAuthorizationDriver` and `OpenFgaRelationsDriver`) refuse to be built without the literal option
1522
+ > **`experimental: 'openfga@2.5'`**: absent, `true`, `'acknowledged'` or another version is **500 `E_AUTHZ_CONFIG`** as the
1523
+ > first line of the constructor — nothing else is validated, no client is created, the server is not called. The gate is
1524
+ > in the constructor, not in the config stub, so `manager.driver()` (and `authz:catalog:sync` with it),
1525
+ > `authz:reconcile --to=openfga`, `authz:relations:reconcile` and a direct `new` all go through it; a database-only config
1526
+ > that merely *registers* an `openfga` factory boots and answers (factories are lazy). `openfga:provision` builds no
1527
+ > driver — it creates the store and publishes the model — so it runs without the token; the first driver that serves
1528
+ > from that store does not. Both drivers declare `capabilities.experimental: true`
1529
+ > (`database`: `false`). Why: `openfga` still has open races and unmeasured fixes — each one is a row of [Limits](#limits)
1530
+ > marked *declared limit*, with its figure (or "not measured"), recipe, detector and a characterisation case — and they
1531
+ > close in **2.6**. The token carries the version on purpose (`true` is refused): an opt-in given for 2.5 is not an opt-in
1532
+ > for a later release with other limits, so an upgrade fails loudly instead of slipping in through `^2.5.0`. The subpath
1533
+ > keeps its name (`@jantstack/adonis-authz/openfga`). Use it for CI, rehearsals and development; in production, use
1534
+ > `database`.
1535
+
1139
1536
  ```bash
1140
1537
  node ace openfga:provision # creates a store + writes the model for your holderTypes AND your permissions
1141
1538
  node ace openfga:provision --store-id 01H… # writes a new model version into an existing store
@@ -1144,6 +1541,15 @@ node ace authz:catalog:sync # …and projects the catalog into
1144
1541
 
1145
1542
  **`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.
1146
1543
 
1544
+ **`openfga:provision` re-reads what it published (2.5.0-alpha.4).** Right after `writeAuthorizationModel` it reads
1545
+ the model back by its id and compares it, whole, with the one the config generates (the same comparison the drivers
1546
+ run at start-up). Equal ⇒ it prints `OPENFGA_MODEL_ID=…`. Different, or the read fails ⇒ exit 1 with the stable
1547
+ prefix `provision RELECTURA FALLIDA:` — the model **is already published** in the store, and the line
1548
+ `OPENFGA_MODEL_ID=` is **not** printed: do not pin it (a server that serialises the model differently, e.g. another
1549
+ OpenFGA version — CI runs v1.19.0 — would make every driver answer 500 at its first use). Both clients of the command
1550
+ and `provisionOpenFgaStore` run with `retryParams: { maxRetry: 0 }`: since SDK 0.9.6 a client without them retries
1551
+ 429/5xx three times, and a retried `createStore` can leave an orphan store.
1552
+
1147
1553
  **`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.
1148
1554
 
1149
1555
  ### Migrating and verifying: `authz:reconcile` (2.3)
@@ -1218,6 +1624,14 @@ node ace authz:reconcile --to=openfga --dry-run # after the switch: the M
1218
1624
  node ace authz:unfreeze # close the window
1219
1625
  ```
1220
1626
 
1627
+ **Retiring the old store: deleting it is not enough (measured on OpenFGA v1.19.0, memory engine).** A store removed
1628
+ with `DELETE /stores/{id}` (204) **keeps serving** in that process: `ReadAuthorizationModel` of its pinned model, `Read`,
1629
+ `Check` — and it **accepts `Write`**. The pinned-model gate (L11) sees it alive, so a process still configured with the
1630
+ old `OPENFGA_STORE_ID` keeps deciding and writing against it without any error. After the switch, remove the old store
1631
+ id from **every** process's configuration (and restart them) before you delete it; do not take the `204` (nor
1632
+ `scripts/openfga_prune_stores.mjs`) as proof that nothing can use it any more. With a persistent datastore
1633
+ (PostgreSQL/MySQL) this has not been measured.
1634
+
1221
1635
  **Verify before the switch, never with `--from` after it (2.4.0-alpha.10).** Once `config.default` is `openfga`,
1222
1636
  `--to=openfga --from=database` is the stale direction by definition — even inside the same live operator window,
1223
1637
  where nobody has written since the migration — so its `--dry-run` comes out as drift (exit 1) and the real pass is
@@ -1255,6 +1669,60 @@ Three more properties worth knowing before putting it in front of production tra
1255
1669
  - **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.
1256
1670
  - **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`.
1257
1671
 
1672
+ #### The pinned model is the one your config generates (2.5.0-alpha.4)
1673
+
1674
+ Both `openfga` drivers decide with a model **the server** holds, so the package checks it before trusting it. Once per
1675
+ process (one `ReadAuthorizationModel` of the pinned `modelId`, shared with every `withClock`/`withChainResolver`/
1676
+ `forRequest()` view), each driver compares the model it serves with the one its config generates, **per type**, in
1677
+ **rules, type restrictions (with their condition) and conditions**:
1678
+
1679
+ - **What is compared.** The roles driver: your holder types, `role`, `role_binding`, `scope` and the `conditions`
1680
+ map, modulo the catalog (the permissions are the ones the served model declares: `role#permits_*`). The relations
1681
+ driver: your holder types, `group` and the object types of its config — **per operation**: the types the operation
1682
+ touches, plus `group` (a different `group#member` changes who is a member, so what every type grants) and the
1683
+ `conditions` (they decide whether any tuple is alive), plus your holder types always (the subject of every operation).
1684
+ It is **per type**: a drift in `note` does not block a `check` on `document`. An operation on `group` (a membership)
1685
+ compares **every** type of the config — its renewal reads the `denied_*` of the types with deny —, which is wider than
1686
+ "the operation's types + `group`": a drift in any type blocks the memberships too (fail-closed, on purpose). A
1687
+ relation **more** than the config declares is drift too. A type without `deny` may be served in the deny form only as
1688
+ a **whole type** (the form a config with `deny` generates, D8); a mixed type is drift.
1689
+ - **Different ⇒ 500 `E_AUTHZ_CONFIG`** naming the differences by path (`document#denied_viewer (restricciones de
1690
+ tipo)`, `conditions.inverted (condición de más)`, `scope#can_docs_read (regla)`…) with **zero** calls that evaluate or
1691
+ write. Recipe: republish from the current config (`openfga:provision --store-id`) and pin the new `modelId` on the
1692
+ whole fleet before deploying. The comparison is memoised once it was *made* (with or without differences); a failure
1693
+ to **read** the model, whatever its class, is not memoised — the next operation reads again. Since R3 is not in yet, a
1694
+ `modelId` that does not exist in the store is a 503 for now.
1695
+ - **For the scope relay that 500 is a deployment failure, not the entry's**: `authz:scopes:relay` records it
1696
+ (`last_error`, `transient: true`) **without consuming an attempt and without parking** the `moved`/`detached` — parking
1697
+ them on a half-done deployment kept the old parent (and the old tenant's access) until a manual `requeue`. The next
1698
+ pass after the fleet runs the right model applies them.
1699
+ - **No operation is exempt.** Reads, purges and writes pass the whole gate (the order a purge deletes in was proven on
1700
+ the config's semantics). `enumerateRelations(Many)` and `enumerateFacts` — the *source* of a migration, which has to
1701
+ be able to rescue a drifted store — pass only the `conditions` part.
1702
+ - **Declared limit.** A type of the store that is neither yours nor the package's (another application's type in a
1703
+ shared store) is not compared: no rule the package generates references it (a case proves it structurally).
1704
+
1705
+ **A tuple's expiry comes from one function, and "unreadable" is never "permanent".** `fgaTupleExpiry` (in `src/expiry.ts`)
1706
+ reads `not_expired` only when its context is **exactly** `{ valid_until }` with a timestamp of the grammar the server
1707
+ accepts (fraction of any length truncated to nanoseconds, `.` or `,`, offsets up to `±24:mm`; measured on v1.19.0) and
1708
+ rounds **up** to the next millisecond — the server compares in nanoseconds and the package sends `current_time` in
1709
+ milliseconds, so this is the exact same answer (a `valid_until` of `…30.999999999Z` is alive at `.999`; `new Date()`
1710
+ used to truncate it and call it expired). Anything else is `foreign` (another condition, or a `not_expired` whose tuple
1711
+ freezes its own `current_time`) or `unparseable` (no `valid_until`: the server answers 400 when it evaluates it), and
1712
+ each reader decides, always failing closed:
1713
+
1714
+ | Who reads it | `foreign` / `unparseable` |
1715
+ |---|---|
1716
+ | `relate` (relations) / `grant` (roles) on that key (also when the key appears in a race) | 409 `E_AUTHZ_RENEWAL_UNSAFE`, nothing written, recipe: inspect it and delete it by hand |
1717
+ | the renewal check of a group membership that carries a deny | the deny (or the nesting edge) counts as **alive**: more 409s, never fewer |
1718
+ | the denies that decide `listScopes` (roles) | counted **alive**: fewer scopes listed, never more |
1719
+ | counts: `countRoleAssignments` (roles; `stillGranting` of `authz:catalog:prune-orphans`) | **counted**: "I don't know" never degrades to "does not grant" |
1720
+ | `listSubjects` / `listDenies` (relations), `listSubjects` / `listRoles` / `listDenies` / … (roles) | not listed; warned and counted (`diagnostics.unparseableTuples` / `unparseableBindings`) |
1721
+ | `enumerateRelations` / `enumerateFacts` (source of a migration) | `enumerateRelations`: emitted marked (`RelationTuple.invalidCondition`, with `expiresAt` = the epoch). **Check the mark before the date**: a reader that ignores it reads the tuple **expired**, which fails closed for an allow but **open** for a deny (or a `group` membership carrying one) — no `expiresAt` value is safe for both. `enumerateFacts`: an assignment is skipped with `invalid-condition` (never migrated); a **deny** is emitted marked (`ReconcileFact.invalidCondition`): `--to=database` migrates it as a deny (fails closed) and declares it (`invalid-condition`, drift) |
1722
+ | the source of `authz:relations:reconcile` | not migrated (`skipped.invalid`, drift); a **deny** like that refuses the pass (500 `E_AUTHZ_DENY_MIGRATION_REFUSED`); a **`group` membership** like that in a partition with **any** deny refuses that partition (`partitionsRefused`, `invalid-condition`: zero writes in it — the server may be evaluating it alive and carrying the deny) |
1723
+ | the destination of `authz:relations:reconcile` / `authz:reconcile` | present and untouchable: `extra`/`skipped` with `invalid-condition`, never deleted (not even with `--prune`), its key never rewritten from the source; drift (exit ≠ 0). A source deny whose key it blocks refuses the pass (500 `E_AUTHZ_DENY_MIGRATION_REFUSED`, both commands; `--dry-run` lists it) — except the roles **maintenance pass** (`--to=<active driver>`, source = the store itself): there the deny is reported (drift), neither written nor deleted, and the pass still rebuilds the root marker, the tree and the `roleVisibility` sweep; a `group` membership like that in a partition with any deny refuses the partition (relations) |
1724
+ | purges | deleted like any other tuple |
1725
+
1258
1726
  #### The model ceiling is bytes, not permissions
1259
1727
 
1260
1728
  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).
@@ -1286,20 +1754,67 @@ legal for the `database` driver, which has no such ceiling — the same tree is
1286
1754
  503 in the other, and it is a DoS within reach of whoever can nest sub-scopes. Raise
1287
1755
  `OPENFGA_RESOLVE_NODE_LIMIT` on the server if your tree is deeper.
1288
1756
 
1757
+ ### How OpenFGA failures are classified (2.5.0-alpha.4)
1758
+
1759
+ Until 2.5.0-alpha.4 every failure of the OpenFGA server that was not a timeout came out as 503 "the backend did not answer" — also a 401, a store that does not exist or a relation the pinned model does not declare, which answer the same way on every retry. Since R3 **one function** classifies them (`classifyFgaFailure`, owner's decisions 2026-10-17 (3) and (4)), called from the three places an OpenFGA error can come from in the two drivers (the client wrapper of each driver and the per-check error of a `batchCheck`), and it decides **by the HTTP status and the server's error code — an allowlist — or by the key of a per-check error (`input_error` / `internal_error`), never by the message** (measured: 41 shapes of SDK 0.9.6 against OpenFGA v1.19, pinned as fixtures in the suite):
1760
+
1761
+ | Class | What the server said | You get | The tree relay |
1762
+ |---|---|---|---|
1763
+ | transient | no answer, 5xx (501 included), 408, 429, `deadline_exceeded`, per-check `internal_error` | 503 `E_AUTHZ_BACKEND_UNAVAILABLE` (or `E_AUTHZ_BACKEND_TIMEOUT`, including the bare SDK error a client timeout and a refused connection share — and, **on a write**, a 504 or `deadline_exceeded`: indeterminate, see [Writes](#writes)) | does not consume an attempt |
1764
+ | graph too deep | `authorization_model_resolution_too_complex` (single `Check` or per check) | 503 `E_AUTHZ_RESOLUTION_LIMIT` — at the edge a retry may succeed; past it, never | does not consume an attempt |
1765
+ | deployment or data the pinned model rejects | 401/403/404; 400 `validation_error`, `authorization_model_not_found`, `latest_authorization_model_not_found`, `exceeded_entity_limit` (also per check) | 500 `E_AUTHZ_CONFIG`, "not transient", the server's error in `cause` | does **not** consume an attempt (it is the deployment, not the entry: parking a `moved` would leave the old parent in the store) |
1766
+ | race with another writer | 409; 400 `write_failed_due_to_invalid_input` | handled by the driver (re-read and re-apply); if it escapes, 409 `E_AUTHZ_WRITE_CONFLICT` | does **not** consume an attempt either (owner's decision (3), "race ⇒ as before": until R3 an escaped race was a 503; contention — a concurrent `grant` writing the same `scope#binding` edge the `moved` sweep rewrites — must never park a `moved`); `authz:scopes:relay` prints it as `CARRERA en #…` |
1767
+ | unknown 4xx | any other 4xx (a 422 included), or a 400 code outside the allowlist | 500 `E_AUTHZ_CONFIG` with `cause` | does not consume an attempt |
1768
+
1769
+ Three things the classes cannot see, said out loud. A relation missing from the pinned model, a tuple without its `valid_until` and a request without `current_time` are the same `validation_error`, and a store that does not exist answers the same `authorization_model_not_found` as a model that does not exist when the model id is pinned: each pair lands in the same class, so the message of the `500` names them all. Telling "already exists" from "does not exist" inside a race needs the message; the two functions that read it (`isDuplicateWrite`, `isTreeWriteRace`) are the only exemption, signed by the owner, and they sit on top of the race class. And the SQL twin (`guardSql`: a missing table or column is still a 503) is out of this release. A failure to **read the pinned model** is never memoised, whatever its class: a rotated token recovers on the next call without restarting the process.
1770
+
1771
+ **What "does not consume an attempt" costs you in monitoring.** A deployment failure, an unknown 4xx or a race that never clears is transient **forever**: the entry never parks, so `dead()` stays empty and `deadCount` stays 0 while a `moved` is not applied (the old parent still grants in the store). An alert that watches only `dead()` does not see it. Watch the relay's **exit code** (≠ 0 on every pass with a failure, with a line per branch: `fallo de DESPLIEGUE #…`, `CARRERA en #…`, `límite de RESOLUCIÓN en #…`, `falló #…`) and the **age of the queue** (`authz:scopes:relay --max-age=<ms>` exits ≠ 0 past it; `report.queue.pendingAgeMs`). And an entry whose failure repeats keeps contaminating its scope key on every pass, so a later `detached` of that key waits behind it too (invariant 11 is about parked entries, not failed ones) — fix the cause and the next pass applies both, in order.
1772
+
1289
1773
  ### Limits
1290
1774
 
1291
1775
  Every bound the package enforces or measured, in one place, each with the constant or option that carries it — the suite imports those constants and asserts that **these numbers are the ones in the code** (a constant that changes without this table turns red). What lies past each bound is written next to it, because "past the limit" is never a silent `false` here.
1292
1776
 
1293
1777
  | Limit | Constant / option | Value | Where | Past it |
1294
1778
  |---|---|---|---|---|
1295
- | Scope-chain depth the `facts` model resolves reliably (`parent` hops from a scope to `app`) | `FACTS_MAX_RESOLVE_DEPTH` | 22 | `openfga` only, `database` has no ceiling | `authorize` is 503, never `false`, for the whole subtree below (measured: 23 fails between 4 % and 26 % of the time, 24 always). Raise `OPENFGA_RESOLVE_NODE_LIMIT` on the server if your tree is deeper — and declare `scopes.maxDepth`, below |
1779
+ | Scope-chain depth the `facts` model resolves reliably (`parent` hops from a scope to `app`) | `FACTS_MAX_RESOLVE_DEPTH` | 22 | `openfga` only, `database` has no ceiling | `authorize` is 503 `E_AUTHZ_RESOLUTION_LIMIT` (2.5.0-alpha.4; until then the generic `E_AUTHZ_BACKEND_UNAVAILABLE`), never `false`, for the whole subtree below (measured: 23 fails between 4 % and 26 % of the time, 24 always). Raise `OPENFGA_RESOLVE_NODE_LIMIT` on the server if your tree is deeper — and declare `scopes.maxDepth`, below |
1780
+ | Group nesting depth (`group ⊂ group` hops of the LONGEST path through the edge; relations, 2.5.0-alpha.4 · R1) | `maxGroupDepth` (`RELATIONS_GROUP_DEPTH_MEASURED`) | 22 | `openfga`: calibrated against the server at first use (minimum of a member and a non-member with memberships; 22 on v1.19 with the default `--resolve-node-limit`, measured), on by default, `maxGroupDepth` in `defineRelationsConfig` may only lower it (1..97, or `false` to switch it off); `database`: none unless `database.enforceGroupDepth: true` | 422 `E_AUTHZ_GROUP_DEPTH_LIMIT` with zero writes on the edge that exceeds or closes a cycle (renewals included); 503 `E_AUTHZ_RESOLUTION_LIMIT` on reads of a deeper chain; `authz:relations:reconcile` refuses the partition (`too-deep`). Declared gaps: concurrent writes of two halves, and no scan of an `openfga`-only store (2.6) — see [Relations](#relations-rebac-24) |
1781
+ | Groups one nesting check walks (upwards from the parent plus downwards from the child) | `GROUP_DEPTH_MAX_NODES` | 10,000 | both relations drivers, on a nesting `relate` | 422 `E_AUTHZ_GROUP_DEPTH_LIMIT` (`details.reason: 'too-many-groups'`), never "passes"; the walk also stops as soon as its BFS levels exceed the ceiling. Cost in `openfga`: one `Read` per group walked (downwards, it reads the group's holders too), bounded by the three rows below |
1782
+ | `Read` requests one nesting check may send (`openfga`) | `GROUP_DEPTH_MAX_READS` | 2,000 | `openfga`, on a nesting `relate` | 422 `E_AUTHZ_GROUP_DEPTH_LIMIT` (`too-many-groups`, `details.budget: 'reads'`), nothing written |
1783
+ | Tuples one nesting check may read (`openfga`; holders included: the server cannot filter the downward `Read` by user type) | `GROUP_DEPTH_MAX_TUPLES` | 50,000 | `openfga`, on a nesting `relate` | 422 (`details.budget: 'tuples'`), nothing written |
1784
+ | Total time of one nesting check (`openfga`; checked before each request, a request in flight is bounded by `timeoutMs`) | `GROUP_DEPTH_DEADLINE_MS` | 10,000 ms | `openfga`, on a nesting `relate` | 503 `E_AUTHZ_BACKEND_UNAVAILABLE`, transient — the server is slow, not your data: retry —, nothing written and no `onRelationWrite` event (not the timeout subclass: the write did not happen). Until the third closure of R1 it was a 422 |
1785
+ | Direct holders of ONE group that is nested, renewed or sits below a nesting edge (`openfga`) | `GROUP_DEPTH_MAX_TUPLES` | 50,000 | `openfga` only; `database` reads the nesting rows alone (`subject_relation = 'member'`) and has no such bound | the downward `Read` of the check brings the group's holders too (the server cannot filter it by user type), so **a group with more than 50,000 direct holders cannot be nested into another group nor have that edge renewed, and neither can any group that contains it, directly or transitively** in `openfga`: always 422 (`details.budget: 'tuples'`), deterministic. Within a partition, whoever can add holders to a group can block every nesting that walks through it this way. An edge migrated by `authz:relations:reconcile` passes (the plan's mark skips the gate) but cannot be renewed afterwards. Keep large populations in leaf groups that are never nested, or stay on `database` for them |
1786
+ | Wait after a failed calibration of the nesting ceiling (`openfga`) | `GROUP_DEPTH_CALIBRATION_BACKOFF_MS` | 2,000 ms | `openfga`, per process, monotonic clock | nesting writes inside the wait get a new error of the calibration's kind (its `cause` is the calibration's error; a timeout becomes a non-timeout 503, no `indeterminate` event) without probing again; the first one after it calibrates again |
1787
+ | Minimum interval between two re-calibrations triggered by a read's 503 `E_AUTHZ_RESOLUTION_LIMIT` (`openfga`) | `GROUP_DEPTH_RECALIBRATION_MIN_INTERVAL_MS` | 60,000 ms | `openfga`, per process, monotonic clock | a 503 inside the interval does not forget the ceiling again; lowering the server's limit is seen without a restart, raising it needs one. Declared: any read's 503 `E_AUTHZ_RESOLUTION_LIMIT` triggers it — any tenant's, and not only a group chain's — and the new ceiling is whatever the probes measure then: a spurious `resolution_too_complex` under load would lower it for the whole process (fail-closed: nesting writes get the 500 «config > calibrated» or tighter 422s) until the next re-calibration (≥ 60 s later) or a restart |
1296
1788
  | Depth gate on writes (opt-in, 2.4.0-alpha.5) | `scopes.maxDepth` | none by default; set it to 22 for `facts` | the manager, `scopes.attached`/`scopes.moved`, every driver | 422 `E_AUTHZ_SCOPE_DEPTH_LIMIT` **before** writing or enqueueing; see below |
1297
1789
  | Authorization-model size | `FACTS_MODEL_MAX_BYTES` | 262,144 | `openfga`, `syncAuthzCatalog` | 500 `E_AUTHZ_MODEL_TOO_LARGE` before writing, warning past 80 % (`FACTS_MODEL_WARN_RATIO`); how many permissions those bytes are is [not a property of the model](#the-model-ceiling-is-bytes-not-permissions) |
1298
1790
  | Checks per `batchCheck` | `FGA_MAX_BATCH_CHECK` | 50 | `openfga`, `authorizeMany` | the driver splits into batches of 50 (the SDK's cap), nothing to do |
1299
1791
  | `ListObjects` effective cut of the server (relations `listObjects`) | `listObjectsDeadlineMs` (`DEFAULT_LIST_OBJECTS_DEADLINE_MS`) | 3,000 ms | the `openfga` relations driver (2.5.0 · B4) | a call that takes ≥ `LIST_OBJECTS_DEADLINE_RATIO` (0.9) × this value returns `truncated: true` — the server cuts at its deadline with a 200 and no signal; set it to the **lowest** of your server's `--listObjects-deadline` (when not 0), `--request-timeout` (3 s by default) and, over HTTP, `--http-upstream-timeout` (5 s by default) — declare that lowest one: with `--listObjects-deadline=0` the request timeout still cuts with a mute 200 (measured on OpenFGA v1.19.0), there is no "no deadline", `Infinity` is 500, both at 0 is not supported (the gateway's cut is not measured for `ListObjects`), and the driver's `timeoutMs` (5 s by default) cannot be lower than `listObjectsDeadlineMs` (500 at construction: the signal has to fire before the client cuts; `Number.MAX_SAFE_INTEGER` is caught by that). **The price of the conservative signal**: with the default, every `ListObjects` that takes 2,700 ms or more comes back `truncated` even when complete; a consumer that treats `truncated` as a 503 pays that p99 as user-facing 503s — raise the server's deadline (and this value) or handle `truncated` as a partial list, not as an outage |
1300
- | Objects per `checkMany` | `RELATIONS_CHECK_MANY_MAX` | 50 | the `RelationsManager` and both relations drivers (2.5.0) | 422 `E_AUTHZ_CHECK_MANY_BATCH` with zero backend calls — split the list yourself; `[]` is 422 too. One batch is one `batchCheck` in `openfga` and one query in `database` |
1792
+ | Objects per `checkMany` / `decideMany` | `RELATIONS_CHECK_MANY_MAX` | 50 | the `RelationsManager` and both relations drivers (2.5.0) | 422 `E_AUTHZ_CHECK_MANY_BATCH` with zero backend calls — split the list yourself; `[]` is 422 too. One batch is one `batchCheck` in `openfga` and one query in `database` |
1793
+ | `membersOf` candidates judged one by one when the object has a deny (`database`) | `RELATIONS_MEMBERS_DENY_CHECK_MAX` | 1,000 | the `database` relations driver (2.5.0) | 500 `E_AUTHZ_MEMBERS_DENY_LIMIT` before the first `check`, never a partial list; the whole loop also runs under one total deadline (`timeoutMs`), 503 `E_AUTHZ_BACKEND_TIMEOUT` past it. Without a deny row on the object there is no extra query and no bound |
1794
+ | Tuples a relations purge deletes in one `Write` (atomic for the writer; see the torn-read row) | `RELATIONS_PURGE_WRITE_MAX` | 100 | the `openfga` relations driver (2.5.0) | the server's `--max-tuples-per-write` default (101 is a 400, measured on v1.19.0). Up to it a purge is one `Write`; past it, allows → the subject's group memberships in their own `Write` → denies. With more than 100 memberships a cut purge can grant until repeated (declared row, [Relations](#relations-rebac-24), «Deny on relations»). **Requires `--max-tuples-per-write` ≥ 100 on the server**: below it, the `Write` that passes the server's cap — not necessarily the first — is 500 `E_AUTHZ_CONFIG` naming the requirement (never a 503 that looks transient: repeated, the purge would fail the same way forever), marked as a partial write (`indeterminate: true`) if an earlier `Write` already deleted |
1795
+ | **Torn read**: a single `authorize`/`check` that runs while a write of the `openfga` drivers is in flight can read part of it — one relation of the model before the write, another after — and answer `true` where neither the state before nor the state after grants, past a deny (2.5.0-alpha.4, declared; invariant 2's declared exception) | — (no constant: a property of OpenFGA, which evaluates the branches of a `Check` as separate reads; it happens under `HIGHER_CONSISTENCY` too) | measured on OpenFGA v1.19 + postgres: **1.2–7.1 %** of the checks concurrent with a role-tree change (`scopes.moved`/`attached`/`detached` with roles; up to 7.1 % on a descendant of the moved scope), 1.4–1.8 % with a purge or a membership swap in one `Write`, 0.02 % across two consecutive `Write`s of the relations reconcile, ~10⁻⁵ for "two writes, any two (tree or facts), spanned by one check" on the scope they touch; the two contiguous `Write`s of F4 (the pre-sweep, then the tree edge) measured with the published model (c2r) and the real driver: **~1.8·10⁻³ on a DESCENDANT of the moved scope when it LEAVES the owner** (180 in 100,000; 49 in 60,000 in a second run, all of them leaving — the deny is evaluated one hop later there) and **~10⁻⁵ on the moved scope itself** (1 in 100,000) — OpenFGA v1.19 + postgres, `HIGHER_CONSISTENCY`, 1 writer + 7 readers, F4 audit 2026-10-19 (the 1 in 50,000 published before was a different model on the test harness) | `openfga` only (roles and relations); `database` has none (one SQL statement per question). **It is caused by whoever writes — in a role-tree change, whoever moves scopes** | one answer of one check, **never persisted**: the next check sees a real state. What the package guarantees is about **prefixes** — a write CUT halfway never leaves a state that grants what neither end granted (the order properties of the surface guard: purges, reconcile, `moved` since F4) —, not about readers; "atomic" in this README means atomic for the writer. A PEP that caches a `true` makes it persistent: do not cache `authorize`. Removing it needs a model or a mechanism change (planned for evaluation in 2.6) |
1796
+ | **Races between writers of the `openfga` role driver (local roles)** — declared limit `openfga.races.settle-window` (experimental `openfga`, closes in 2.6) | — | persistent fail-open: 0 in ~38,800 interleavings of the in-memory double since the settle; against a real server not measured (M1); transient window: from a writer's `Write` to its own settle's correction | `openfga`, `hierarchy: 'facts'`, local roles only; `database` has none (it decides on every question) | the five interleavings found by the F4 audit (`updateScopedRole`/`projectCatalogRole` ∥ `scopes.moved`; a `grant` that resolved the OLD chain; a `grant` during the relay lag of a unit ENTERING the owner; two INLINE `moved` of a parent and its child) no longer leave a binding edge outside the owner in the double: every writer of a local-role `scope#binding` re-reads with `HIGHER_CONSISTENCY` and corrects to a fixed point (the settle, 2.5.0-alpha.4). What remains declared is the window before that correction — a check in it can grant outside the owner, across tenants and past a deny of the owner — and the closing measurement against a real server. **Recipe**: serialise catalog writes with scope moves, keep the outbox, and run `authz:reconcile --to=openfga` after reorganising the tree. **Detector**: a non-zero exit of `authz:reconcile --to=openfga --dry-run` on a cron (`drift.roleVisibility`). **Closes in 2.6**: M1 (zero persistent fail-opens against a real server, with a positive control that turns red) is the exit criterion of experimental. Case: «límite declarado openfga.races.settle-window: se voltea en 2.6». |
1797
+ | **Cost of a `moved`/`attached` of an existing scope with local roles (`openfga`)** — declared limit `openfga.roles.moved-cost` (experimental `openfga`, closes in 2.6) | — | 2 `Read`s per local role of the catalog per `moved`/`attached` (14 → 214 `Read`s with 100 local roles without a single binding, in the double; 2,008 with 1,000 in the F4 audit) | `openfga`, `hierarchy: 'facts'`; `database` has none | a tenant that defines many local roles slows the relay — one global queue — and widens the lag for everyone; zero `Read`s without local roles. **Recipe**: keep the number of local roles bounded and reorganise trees off-peak. **Detector**: the relay's lag: `authz:scopes:relay` reports `remaining: true` pass after pass. **Closes in 2.6**: read the bindings per scope of the moved subtree (`scope:X#binding`) instead of per local role of the catalog. Case: «límite declarado openfga.roles.moved-cost: se voltea en 2.6». |
1798
+ | **Two concurrent `scopes.moved` of the SAME scope applied inline (`acceptScopeDriftRisk: true`, no outbox)** — declared limit `openfga.tree.inline-same-node` (experimental `openfga`, closes in 2.6) | — | deterministic in the double (one preemption between the two movers); frequency against a real server not measured | `openfga`, `hierarchy: 'facts'` without `scopes.outbox`; `database` has none | the store keeps the parent of the writer that landed last, your tree the one that committed last: the store's tree diverges from yours and persists — a GLOBAL role skips the deny of the old tenant and an organisation role of the old tenant is inherited inside the new one. **Recipe**: use `scopes.outbox` (the relay applies your fresh parent), or lock the scope's row (`SELECT … FOR UPDATE`) before `scopes.moved`. **Detector**: a non-zero exit of `authz:reconcile --to=openfga --dry-run` (its tree phase counts the divergent edge). **Closes in 2.6**: the `drift.treeMismatch` report and the `FOR UPDATE` recipe measured on PostgreSQL and MySQL. Case: «límite declarado openfga.tree.inline-same-node: se voltea en 2.6». |
1799
+ | **The store's tree diverging from yours WITH the outbox (two movers of the same scope, commit order ≠ relay order)** — declared limit `openfga.tree.outbox` (experimental `openfga`, closes in 2.6) | — | closed in code since 2.5.0-alpha.4 and in the in-memory double — the relay applies the consumer's FRESH parent; the closing measurement (S1/S5/R at 0 disagreements on PostgreSQL and MySQL, the `relay.trust-enqueued-parent` mutant at 1000/1000) not measured; before the fix, 1000/1000 forced and 8.5–11.5 % with natural concurrency | `openfga`, `hierarchy: 'facts'` with `scopes.outbox`; SQLite does not suffer it (measured); `database` has none | if the fix does not hold under real concurrency, the same persistent divergence as the inline row, with every entry `applied`. **Recipe**: lock the scope's row (`SELECT … FOR UPDATE`) before `scopes.moved`, and give the relay a resolver without a TTL cache. **Detector**: a non-zero exit of `authz:reconcile --to=openfga --dry-run` on a cron. **Closes in 2.6**: S1/S5/R measured on PostgreSQL and MySQL, plus a property "everything through the outbox" (C1-6). Case: «límite declarado openfga.tree.outbox: se voltea en 2.6». |
1800
+ | **A cached resolver in the relay and two moves of the same scope within its TTL** — declared limit `openfga.relay.stale-resolver` (experimental `openfga`, closes in 2.6) | — | deterministic with one stale cache entry; frequency not measured | `openfga` with `scopes.outbox` (the fresh-parent relay is `hierarchyFacts` only); `database` has none | the relay applies the cached parent to both entries — the second as `consumer-parent`, "backed" by the first — and the store keeps the old parent while your tree has the new one, with nothing left in the queue to repair it. **Recipe**: give the relay's `scopes.resolveChain` no cache, or a TTL shorter than the time between two moves of one scope. **Detector**: `authz:scopes:relay` exits 1 with its `consumer-parent` line (`authz:scopes:requeue --apply` exits 0 with its own line). **Closes in 2.6**: the relay stops accepting a parent backed only by an entry of the same pass when the resolver may be cached. Case: «límite declarado openfga.relay.stale-resolver: se voltea en 2.6». |
1801
+ | **A scope restored with the same uuid after a `detached`, relayed by a resolver that answers `null`** — declared limit `openfga.relay.restored-without-edge` (experimental `openfga`, closes in 2.6) | — | deterministic; needs a blind resolver (filtered by tenant) and the reuse of a detached uuid; frequency not measured | `openfga` with `scopes.outbox`; `database` has none | the `attached` of the restored scope is applied as `superseded` with no edge: the scope exists in your tree and has no parent in the store, so it does not reach the root there (`rooted`) and denies everything on it, direct grants included (fail-closed) — while `database` grants. **Recipe**: do not reuse a detached scope's uuid, or notify `scopes.attached` again once the relay's resolver sees it. **Detector**: the `superseded` line of `authz:scopes:relay` (exit 0), and `authz:reconcile --to=openfga --dry-run` (the missing edge). **Closes in 2.6**: a `superseded` backed only by an EARLIER `detached` of the same pass is refused instead. Case: «límite declarado openfga.relay.restored-without-edge: se voltea en 2.6». |
1802
+ | **An orphan `assignee` after a `purgeRole` that ran out of rounds, resurrected by reusing the role's uuid** — declared limit `openfga.roles.purge-orphan-reuse` (experimental `openfga`, closes in 2.6) | — | needs a failed purge (500 `E_AUTHZ_PURGE_INCOMPLETE`) with a concurrent writer, a fixed `CatalogRoleSpec.uuid` and a re-sync; frequency not measured | `openfga` only; `database` purges in one transaction | the retry of the purge does not see the concurrent writer's `assignee` (it enumerates by the `role_binding#role` anchor, already gone); if the role is declared again with the same uuid, that holder is a member again without any grant, and the first `grant` of the role on that scope makes it grant. **Recipe**: before declaring a role again with the same uuid, run `authz:reconcile --to=openfga --dry-run` and, if it reports surplus facts, `--prune`. **Detector**: `authz:reconcile --to=openfga --dry-run` counts it as `extra-fact` and exits non-zero. **Closes in 2.6**: membership requires the `role_binding#role` anchor, and a `grant` that creates a binding's structure sweeps earlier `assignee`s. Case: «límite declarado openfga.roles.purge-orphan-reuse: se voltea en 2.6». |
1803
+ | **A `scope#binding` edge without `role_binding#role` after a failed purge racing a move (ORPHAN-edge)** — declared limit `openfga.roles.orphan-edge` (experimental `openfga`, closes in 2.6) | — | 2 in 34,420 fault∥race interleavings of the double; it grants nothing | `openfga` only; `database` purges in one transaction | garbage that fails closed: an edge of a purged role that the purge's retry does not see. **Recipe**: `authz:reconcile --to=openfga --prune`. **Detector**: `authz:reconcile --to=openfga --dry-run` counts it as `extra-fact` and exits non-zero. **Closes in 2.6**: the purge enumerates the role's edges by `scope#binding` too, not only by the `role_binding#role` anchor. Case: «límite declarado openfga.roles.orphan-edge: se voltea en 2.6». |
1804
+ | **Two concurrent projections of the same role (`updateScopedRole`/`projectCatalogRole`): a removed permission's `permits_*` survives** — declared limit `openfga.catalog.permits-twin` (experimental `openfga`, closes in 2.6) | — | deterministic in the double (one preemption before the projection's read of the store); frequency against a real server not measured | `openfga`, `hierarchy: 'facts'`; `database` has none (the catalog is its own table) | the projection that read the OLD catalog writes after the one that read the new one: the role keeps granting a permission the catalog no longer gives it, persistently — within the tenant that owns the role on this path (`updateScopedRole` is local roles only); the sync's projection is the same race on any role, global ones included, and is its own row (`openfga.catalog.permits-sync`). **Recipe**: serialise catalog writes of one role, and run `authz:reconcile --to=openfga` after a burst of catalog changes. **Detector**: a non-zero exit of `authz:reconcile --to=openfga --dry-run` (its catalog phase counts the surplus `permits_*`). **Closes in 2.6**: after its `Write` the projection re-reads the catalog version fresh and re-projects to a fixed point. Case: «límite declarado openfga.catalog.permits-twin: se voltea en 2.6». |
1805
+ | **The projection of `authz:catalog:sync` racing another catalog write: a removed permission's `permits_*` comes back** — declared limit `openfga.catalog.permits-sync` (experimental `openfga`, closes in 2.6) | — | deterministic in the double (one preemption before the sync's projection reads the store); frequency not measured — the window grows with the catalog (one paged `Read` of `role:` per holder type after the commit) | `openfga`, `hierarchy: 'facts'`; `database` has none (the catalog is its own table) | the sync takes its snapshot inside its transaction and projects after the commit without re-reading the version; if another write removes a permission in between (an `updateScopedRole`, or a second sync with another spec — a pod of the old release during a rolling deploy), the stale projection writes the `permits_*` back: the role keeps granting it, persistently, and with a GLOBAL role in every tenant where it is assigned; a real pass of `authz:reconcile --to=openfga` (the recipe itself) is the same race — it takes its catalog snapshot before it dumps the store, the freeze does not stop a sync, and a sync that removes a permission in between is reverted by the pass and reported as a write (it looks like a repair). **Recipe**: do not run two `authz:catalog:sync` of different specs at the same time (one sync job per deploy), avoid catalog changes during a sync, and run `authz:reconcile --to=openfga` after a deploy has finished — never a real pass while a sync can run; check with `--dry-run` afterwards. **Detector**: a non-zero exit of `authz:reconcile --to=openfga --dry-run` (its catalog phase counts the surplus `permits_*`). **Closes in 2.6**: the sync's projection re-reads the catalog version after its `Write` and re-projects to a fixed point, like the twin's. Case: «límite declarado openfga.catalog.permits-sync: se voltea en 2.6». |
1806
+ | **`openfga:provision` publishes a model that narrows the reach of a live deny (a relation withdrawn from an `includes`)** — declared limit `openfga.provision.deny-reach` (experimental `openfga`, closes in 2.6) | — | deterministic (model and interpreter, no server): with `editor: { includes: ['owner'] }` and a live `denied_editor` on a direct owner, the owner is denied; narrowed to `includes: []`, `openfga:provision` publishes without refusing and the same tuples grant `owner`; how often a config narrows under live denies is not measured | `openfga` (`openfga:provision`); `database` refuses the same change at `saveRelationsConfig` since sublot (8) (500 `E_AUTHZ_DENY_REACH_REFUSED`, forced way out by name: `allowDenyReachShrink: ['<type>#<relation>']`) | the provision guard only refuses a model that DROPS a `denied_*` relation (`--allow-drop-deny`); narrowing an `includes` keeps every `denied_*` but shrinks what the deny cuts (the up-closure of D5), so a deny that blocked a holder stops blocking them, persistently, once the new model is pinned. **Recipe**: before narrowing or withdrawing a relation of a type with live denies, list them (`authz:relations:reconcile --dry-run` over the partitions, or `listDenies`) and re-issue each deny on the relation that still covers the access you mean to cut. **Detector**: `authz:relations:reconcile --dry-run` (sublot (8)), which **requires a persisted baseline, saved on every provision** (`saveRelationsConfig` with the config you provision; without it the narrowing is not seen — the pass only says «alcance de denies: SIN línea base y N deny(s) vivos sin juzgar», exit 0): it compares the persisted config with the one the process serves and lists, per partition and in both stores, every live deny whose reach shrank («deny con alcance ENCOGIDO») or that was orphaned («deny HUÉRFANO»), and a baseline narrower than the served config («línea base RANCIA»), with its recipe, exit ≠ 0 — run it BEFORE saving the narrowed config: once saved, the baseline is the narrowed one; the `database` guard of `saveRelationsConfig` reads `authz_relations`, never the store. **Closes in 2.6**: the provision guard with the property «for every live deny, the new reach ⊇ the old reach» and an explicit forced way out (decision (8)). Case: «límite declarado openfga.provision.deny-reach: se voltea en 2.6». |
1807
+ | **A relations config that shrinks the reach of a live deny** — the `database` half of decision (8) (2.5.0-alpha.4, sublot (8)) | `allowDenyReachShrink` (option of `saveRelationsConfig`: the forced way out, by name) and `establishBaseline` (the adoption) | refused: for every live deny, new reach ⊇ old reach | `database`: `saveRelationsConfig`, inside its catalog transaction, over every `denied_*` row of `authz_relations`; detectors: `checkRelationsConfigDenyReach` (one driver) and `authz:relations:reconcile --dry-run` (two drivers) | 500 `E_AUTHZ_DENY_REACH_REFUSED` before anything is written (neither the row nor the catalog version), with one finding per type and relation and its recipe: «deny con alcance ENCOGIDO» — an `includes` narrowed, directly or transitively (the reach of a deny on `X` is `down*(X)`, what it cuts by the path cut of D5: with `editor: { includes: ['owner'] }` a deny on `editor` cuts a direct `owner`; narrowed to `includes: []` the same rows grant `owner`) —, «deny HUÉRFANO» — the relation or the type withdrawn (nobody reads the deny, no `removeDeny` reaches it, and it comes back if the relation is declared again), judged also when the baseline already lacked it — or «deny SIN línea base» — no usable baseline (no row, or a row whose `objectTypes[].relations[].{name, includes}` cannot be typed: it is read all-or-nothing, never filtered) while denies live, so whether the new config shrinks them cannot be judged. Widening is never refused, and the first row is created freely while no deny lives. **Two named ways out, apart**: `{ establishBaseline: true }` lifts only «deny SIN línea base» and only when there is no usable baseline (inert otherwise; orphans are still judged) — adopt once, with the spec the fleet serves today, in a release that does not change `relations.config`; `{ allowDenyReachShrink: ['note#editor'] }` exempts only the findings it names (an unnamed one is still refused; any other form, `true` included, is 500 `E_AUTHZ_CONFIG`; **a name that exempts no `shrunk`/orphan finding of that very save is also 500 `E_AUTHZ_CONFIG`**, nothing written, naming the surplus key — the list switches itself off, so a list left in the pipeline cannot silently exempt a later shrink of that relation: remove it after the forced save). **Persisting the config is a mandatory release step if you use deny**: save (the guard) → `checkRelationsConfigDenyReach(served)` (CI, and a cron; never a hard failure in `ready`) → serve. **What it does not cover**: the `database` driver evaluates with the config of the process that SERVES, not the persisted one, so a deploy that serves a narrowed config without saving it is not refused — `checkRelationsConfigDenyReach` catches it (and `authz:relations:reconcile --dry-run`, which needs two relations drivers: it cannot run in a `database`-only deploy); a stale baseline, the deploy window and re-declaring after forcing are declared below. Save with the same `connection` as the `database` relations driver (the guard reads `authz_relations` through the connection it is given). Withdrawing a type's `deny` opt-in does not change the reach in `database` (its reads honour the row) and is not judged here (in `openfga` the provision guard and `--allow-drop-deny` do). A withdrawn TYPE is not enumerated by `--dry-run`, so its denies are not seen there; the guard and `checkRelationsConfigDenyReach` read the whole table. |
1808
+ | **A stale baseline hides a narrowing (S-B)** — declared yellow of `database` (sublot (8), closes in 2.6) | — | deterministic: a row saved by release N-1 with `editor: { includes: [] }`, release N serving `includes: ['owner']` without saving it and a deny on `editor` written under it, release N+1 narrowing back and saving: saved, and the owner grants again | `database`: `saveRelationsConfig` (the guard compares with the row, not with what the fleet served) | when the persisted row is narrower than what the fleet served when the deny was written, a narrowing back to the row looks like no change and is saved: the deny stops cutting, persistently. **Recipe**: persisting the config is a mandatory release step if you use deny — save the config every release serves, before it serves. **Detector**: `checkRelationsConfigDenyReach(served)` run with the wider served config reports «línea base RANCIA» (`ok: false`) — in CI after the save and before serving, and in a cron; `authz:relations:reconcile --dry-run` prints the same line (exit 1) when there are two relations drivers. **In the order of this case, the CI step does not see it**: release N's CI runs before the deny exists, and N+1's runs after the narrowing is saved, when the row and the served config already agree; **what sees it is a cron that runs while release N serves, after the deny is written** — so the cron must run more often than a release lives. **Closes in 2.6**: checking at deny write (W) or recording the reach in the deny. Case: «límite declarado database (8) · S-B línea base RANCIA: la guarda no la ve y la delata checkRelationsConfigDenyReach; se voltea en 2.6». |
1809
+ | **The rolling-deploy window (S-C)** — declared yellow of `database` (sublot (8), closes in 2.6) | — | deterministic: the narrowed config saved with no live deny, then an old pod (wider config) writes a deny on `editor` over a direct owner: once the narrowed config serves, the owner grants | `database`: relation writes do not take the catalog lock | the window runs from the save of the narrowed config to the end of the deploy (the last old pod): a deny an old pod writes in it under the wider config cuts less once the narrowed config serves, and `shrunk` is not detected afterwards (the row and the served config agree). **Recipe**: do not write denies on the relations you are narrowing while old pods remain; after the deploy, re-issue the denies of those relations on the relation that still covers the access (`listDenies` enumerates them). **Detector**: during the window, `checkRelationsConfigDenyReach` run with an old pod's config reports «línea base RANCIA»; none after it. **Closes in 2.6**: checking at deny write (W) or recording the reach in the deny. Case: «límite declarado database (8) · S-C ventana del despliegue: un deny que escribe un pod viejo tras el guardado no se ve como encogido; se voltea en 2.6». |
1810
+ | **Forcing a withdrawal, then declaring it again, resurrects grants (S-E)** — declared yellow of `database` (sublot (8), closes in 2.6) | — | deterministic: a deny on `editor` with `editor ⊇ owner` over a direct owner; withdrawing `owner` forced with `allowDenyReachShrink: ['note#editor']`, then declaring `owner` again without `editor` including it: saved, and the owner grants | `database`: `saveRelationsConfig` | forcing a shrink moves the baseline: against the new row, declaring the withdrawn relation again without its old `includes` shrinks nothing, so the grants the deny used to cut grant again. **Recipe**: before forcing, remove or re-issue the denies the finding names (`removeDeny`, or `deny` on a relation that stays); never declare again a relation withdrawn by force without restoring the `includes` that put it under the deny. **Detector**: none automatic (the row agrees with what is served) — review every forced save; keeping the forcing list in the re-declaring save is 500 `E_AUTHZ_CONFIG` (the name exempts nothing there), but a save without it goes through. **Closes in 2.6** only by recording the reach in the deny: checking at deny write (W) does NOT close it — the deny is written while the row and the served config agree, and neither the forced save nor the re-declaration writes a deny. Case: «límite declarado database (8) · S-E forzar la retirada y re-declarar resucita los grants que el deny cortaba; se voltea en 2.6». |
1811
+ | `openfga:provision --store-id` sweep for live denies of a dropped relation | — (no cap: streamed page by page) | — | `openfga:provision --store-id` (2.5.0-alpha.3), only when some model of the store declared a `denied_*` the new one does not | it reads the WHOLE store, page by page, keeping only a count and three examples (memory does not grow with the store, time does: O(store) per run); a server that does not advance (a repeated continuation token) or a page without `tuples` is exit 1 (`provision NEGADO:`) without writing the model — "zero live denies" is never assumed from a sweep that did not finish. Until closure 2 of sublot D2 the sweep stopped at 1,000,000 tuples, and since the store's model history keeps a retired opt-in forever, a bigger store could never be provisioned again |
1812
+ | Models of the store `openfga:provision --store-id` reads to judge the store's state (every published model is one) | `PROVISION_MODEL_HISTORY_MAX` | 10,000 | `openfga:provision --store-id` (2.5.0-alpha.3) | exit 1 (`provision NEGADO:`) without writing the model — never judged on a truncated history |
1813
+ | Relation name in a type with `deny` | `DENIABLE_RELATION_NAME_MAX` | 43 | `defineRelationsConfig` and the `openfga` model generator (2.5.0) | 422 `E_AUTHZ_RELATION_CONFIG` at definition: `denied_<rel>` has to fit FGA's 50. A type without `deny` keeps 50 |
1301
1814
  | `Read` page size (enumerations, `enumerateFacts`, `enumerateRelations`) | — | 100 | `openfga` | the server's hard cap: `pageSize: 1000` is a 400; the driver pages until the continuation token is empty — a page with zero tuples **and** a token is not the end; a token that repeats, more than 10,000 pages, or a response without `tuples` is 503 `E_AUTHZ_BACKEND_UNAVAILABLE`, never a shorter enumeration |
1302
1815
  | `enumerateRelations(partition)` / `enumerateRelationsMany(partitions)` cost | — | O(tuples in the store) **per sweep** — one sweep per `enumerateRelations` call, **one sweep for K partitions** with `enumerateRelationsMany` / `runRelationsReconcile(…, { partitions })` (2.4.0-alpha.6) | `openfga`, the *source* of `authz:relations:reconcile` | there is no prefix `Read` in OpenFGA (a prefix object answers 200 with **zero** tuples, measured: never "optimise" it that way — the suite spies the filter of every `read`, it is always `{}`); measured 15,000 tuples ⇒ 151 pages ≈ 0.5 s per sweep whatever the partition size; 3 partitions in a 5,000-tuple store: **150 reads / 502–558 ms** as three sweeps → **50 reads / 170–209 ms** as one ([the table](#the-source-sweep-measured)). Run it from a job or a cron, **never from a tenant request** |
1816
+ | Relation listings — `listObjects`, `listSubjects`, `listDenies`, `membersOf` — do not page (2.5.0-alpha.4, «before the RC» lot · 7.4) | — | no `page` parameter, no `cursor` in the answer | the `RelationsManager`, both relations drivers and the `/testing` double | the **whole** set, or `truncated: true` where the backend cuts (`listObjects` on `openfga`); never a cursor. A page argument that reaches them anyway (plain JS, `as any`) — anything but `undefined`, `null` and `{}` included — is 500 `E_AUTHZ_UNSUPPORTED` naming the operation, with zero backend calls. Paginated listings come in 2.6 as separate `*Page` methods behind their own capabilities |
1817
+ | Page size of `enumerateRelations(Many)` (`database`) | `RELATIONS_ENUMERATE_DEFAULT_LIMIT` / `RELATIONS_ENUMERATE_MAX_LIMIT` | 100 by default / 1,000 at most | the `database` relations driver; the `RelationsManager` and `openfga` validate the shape too (2.5.0-alpha.4 · 7.4) | a `limit` above 1,000 is **clipped** (the page carries a cursor: nothing is lost); a `limit` that is not an integer ≥ 1 (`NaN`, `0`, negative, decimal, a string) is 422 `E_AUTHZ_INVALID_PAGE` with zero statements (until 7.4 `NaN` produced a query without `LIMIT`); an `after` that is not the canonical uuid `database` emits is 422 `E_AUTHZ_INVALID_PAGE` (until 7.4: a 503 on PostgreSQL, a silent end on MySQL/SQLite). `openfga` (and the double) ignore a valid `limit` — one page, O(store), no cursor — and answer an `after` with 500 `E_AUTHZ_UNSUPPORTED`: they never emitted one. Declared: a cursor is not bound to its partition (a cursor of A reused on B skips rows) — 2.6 |
1303
1818
  | Hops the `only` probe walks back to inherit a block's origin | `RELAY_ORIGIN_MAX_HOPS` | 100 | the manager, `relayScopeChanges({ only })` | the origin is taken as a real block and the entry stays `deferred` (the purge waits for a full pass); one `earlierPending` query per hop |
1304
1819
  | Interval between `acquire()` retries under `waitForLeaseMs` | `RELAY_LEASE_POLL_MS` | 25 ms | the manager, `relayScopeChanges({ waitForLeaseMs })` and `requeueScopeChanges({ apply, waitForLeaseMs })` | the budget is spent in polls of this size; past it the pass reports `busy` (default `waitForLeaseMs` is 0: one attempt) |
1305
1820
  | Scopes a `pending()` pass reads per round trip | `DEFAULT_RELAY_BATCH` | 100 | `authz:scopes:relay` (`--batch-size`) | the pass keeps paging |
@@ -1312,6 +1827,14 @@ Every bound the package enforces or measured, in one place, each with the consta
1312
1827
  | Local roles a `prune-orphans` pass may read | `maxLocalRoles` | 10,000 | `pruneOrphanRoles` | 500 `E_AUTHZ_TOO_MANY_LOCAL_ROLES`, never a partial list |
1313
1828
  | Ancestors `hierarchicalScopeResolver` walks | `maxDepth` (resolver option) | 64 in the README example | your `resolveChain` | 500 `E_AUTHZ_SCOPE_TOO_DEEP`, never a truncated chain |
1314
1829
 
1830
+ **The rows marked *declared limit* are the open limits of the experimental `openfga` driver (2.5.x).** Each one has an
1831
+ id, its figure — or "not measured" —, a recipe, a detector, the version that closes it (2.6) and a characterisation case
1832
+ in the suite titled «límite declarado <id>: se voltea en 2.6» that reproduces today's behaviour without a server: if
1833
+ someone fixes one without rewriting its row, the case turns red. The versioned list is
1834
+ `tests/fixtures/openfga_declared_limits.json`, and a case keeps the list, the cases and these rows equal. The torn read
1835
+ above is declared too (both `openfga` drivers) and is not repeated in that list. The detector most rows share is the
1836
+ maintenance pass on a cron: alert on a **non-zero exit** of `authz:reconcile --to=openfga --dry-run`.
1837
+
1315
1838
  **`scopes.maxDepth` — the depth gate (2.4.0-alpha.5).** Declare it in `config.scopes` and `scopes.attached`/`scopes.moved` refuse, with **422 `E_AUTHZ_SCOPE_DEPTH_LIMIT`**, any child that would end up more than `maxDepth` `parent` hops from `app` — the parent's chain plus one — **before writing or enqueueing** (zero driver calls, zero outbox rows) and at zero extra I/O: the parent's chain is already resolved for the cycle check. Without the option there is no gate: a tree of any depth stays legal, and the `database` driver pays nothing. It lives in the config and not in `capabilities()` because a capability is a boolean by doctrine (`capabilities()` normalises with `=== true`); and it is the same knob `authz:reconcile` uses to report chains above the ceiling as drift (`drift.tooDeep`, exit ≠ 0 — a scope legal in `database` and a 503 in `openfga` belongs in CI, not in the first tenant that reaches it; without the option the verifier uses `FACTS_MAX_RESOLVE_DEPTH`). Set it to `22` for the `facts` model: then every question stays within the measured ceiling whichever ancestor carries the binding. **How to size it: `maxDepth` counts `parent` hops from the child to `app`, so a tree of N levels below `app` needs `scopes.maxDepth = N`** — not N + 1: the parent's chain includes `app`, but a child at exactly `maxDepth` hops *enters* (`chain.length > maxDepth` is the refusal). Pinned by an executable case (`scope_depth.spec.ts`): a tree of exactly three levels (organization → unit → sub-unit), `maxDepth: 2` refuses the third level with 422, `maxDepth: 3` lets it in. The 422's `message` is for the tenant (depth, `scopes.maxDepth`, nothing written or enqueued); the reason the ceiling exists goes to `error.details` (2.4.0-alpha.8). **The gate is **partial**, and sold as such**: it looks at the *destination* of the child, not at the height of the subtree a `moved` drags along — a subtree of height *h* moved under a parent at depth *d* leaves its leaves at *d + h + 1*, and the gate does not see them; measuring that would need `descendantsOf` on the write path, which is forbidden. The reconcile report is what catches those afterwards. Why a gate at all: past the ceiling the model answers 503 for the whole subtree (a DoS within reach of whoever can nest sub-scopes), and the cost of each relayed `attached` grows with the depth of its neighbour (measured ×5.8 from depth 1 to 34) on the queue every tenant shares.
1316
1839
 
1317
1840
  ### Operational notes for the SQL engines
@@ -1800,13 +2323,205 @@ DDL of the published migration (`stubs/migration.stub`) and copy the rows. The d
1800
2323
  row per tuple: if the two rows raced with different expiries, the survivor is the first written. The `openfga`
1801
2324
  driver needs nothing — a tuple is unique by construction.
1802
2325
 
2326
+ ### Upgrading to 2.5.0-alpha.3: rolling out the deny on relations, migrating it, and why there is no rollback below 2.5
2327
+
2328
+ Nothing changes until a type opts in: with no `deny` in `defineRelationsConfig` the fused model is byte for byte the
2329
+ one of 2.4.0-alpha.10 and there is no DDL (a deny is a row of `authz_relations` with `relation = 'denied_<rel>'`).
2330
+
2331
+ **Turning the deny on is two deployments, in this order: provision → pin `modelId` → deploy.** A pod still pinned to a
2332
+ model without `denied_*` grants over a denied object (S11, declared row), and a pod running < 2.5.0-alpha.3 on
2333
+ `database` never looks at the `denied_*` rows — so no deny may be written until the whole fleet serves the new model with
2334
+ the new code:
2335
+
2336
+ 1. Put the type in **`deny: 'read'`** and publish the model from that config: `node ace openfga:provision
2337
+ --store-id=$OPENFGA_STORE_ID` (it prints a new `OPENFGA_MODEL_ID`).
2338
+ 2. Pin **every** pod to that `modelId` and deploy the fleet on ≥ 2.5.0-alpha.3 with `deny: 'read'`: reads honour
2339
+ denies, `deny()` is still 422 `E_AUTHZ_RELATION_NOT_DENYABLE`. A pod with the new config and the old pin is 500 on
2340
+ first use (D-31), never a grant.
2341
+ 3. Verify, then a **second** deployment moves the type to **`deny: 'write'`** (same model: the two phases publish the
2342
+ same rules). Only now are denies written.
2343
+
2344
+ The job `cutover-runbook` runs exactly this (steps 20 and 21, against pg and sqlite-file with a real OpenFGA): phase 1 ⇒
2345
+ `deny` 422 on both drivers; phase 2 ⇒ the deny lands, `authz:relations:reconcile` carries it to the store (`denies:
2346
+ escritos 1`), `check` is false on both drivers; and the S11 table — a pod that was not redeployed, pinned to the old
2347
+ model, grants; the same pod with the new `modelId` does not (the server subtracts); a pod with the new config and the
2348
+ old pin is 500.
2349
+
2350
+ **`openfga:provision --store-id` refuses to mutilate the model.** Publishing from a process whose config lacks the opt-in
2351
+ (a refactor, another branch of the config, an old binary) would drop `denied_*` and switch every deny of that type off,
2352
+ silently. Before writing, the command judges the **state of the store**, not just its last model: it reads every model the
2353
+ store has (`PROVISION_MODEL_HISTORY_MAX`) and, if any of them declared a `<type>#denied_<rel>` of a relation type that the
2354
+ new one does not, it sweeps the store (all of it, streamed page by page with no tuple cap — closure 2 of sublot D2; it
2355
+ costs O(store) per run, and a store with a retired opt-in pays it on every `provision --store-id`) — a live deny of those
2356
+ relations is exit 1
2357
+ (`provision NEGADO: …`) and nothing is written, also when a model without `denied_*` was published in between (a rollback
2358
+ rehearsal with an older binary). Dropping one the **latest** model declares also needs `--allow-drop-deny` (the way to
2359
+ retire an opt-in for real, said out loud); the flag **never switches off a live deny**. Right before
2360
+ `writeAuthorizationModel` the sweep is repeated (`provision NEGADO: el RECUENTO …` if a deny arrived meanwhile: the fleet
2361
+ is still writing denies). **The recount narrows the window, it does not close it**: a deny that lands on a page the
2362
+ recount already read, or between the recount and `writeAuthorizationModel`, is not seen — **step 0 (the whole fleet on
2363
+ `deny: 'read'` before counting) is what closes it**. A sweep that fails halfway is exit 1 too. (`database` honours `denied_*` rows always, also
2364
+ without the opt-in: retiring it there does not switch them off either — remove them with `removeDeny`.)
2365
+
2366
+ **Rolling back below 2.5 with live denies is not supported (D12 · S17, declared row).** A process < 2.5.0-alpha.3 ignores
2367
+ every deny: on `database` its `check` never reads `denied_*`; its `defineRelationsConfig` drops the `deny` key in
2368
+ silence; its `openfga:provision` republishes a model without `but not`; its `authz:relations:reconcile` counts the denies
2369
+ as undeclared relations and migrates the allows. There is no command that makes that safe. **Before any rollback, stop
2370
+ the denies, count them and take them to zero — in this order:**
2371
+
2372
+ 0. **Move the whole fleet to `deny: 'read'`** for every type with the opt-in, and deploy it, **before** counting, sweeping
2373
+ or publishing anything: every `deny()` is then 422 `E_AUTHZ_RELATION_NOT_DENYABLE` on every pod and reads still honour
2374
+ the denies. Skipping this leaves a window — counting, publishing, redeploying — in which a tenant's `deny()` lands and
2375
+ is then switched off in silence, for good.
2376
+ 1. Count the live denies (below).
2377
+ 2. Retire them: `removeDeny` (or the purges) on the active driver and `authz:relations:reconcile … --prune` on the mirror,
2378
+ until the count is 0.
2379
+ 3. Publish the model without the opt-in: `node ace openfga:provision --store-id=… --allow-drop-deny` (it sweeps, and
2380
+ re-counts right before writing the model — the recount narrows the window, it does not close it: step 0 does).
2381
+ 4. **Count again: it must still be 0.** If it is not, a pod was still in `'write'`: do not pin the new model, go back to 0.
2382
+ 5. Pin, redeploy, roll back.
2383
+
2384
+ The count, on `database` (the three engines; executed by the suite):
2385
+
2386
+ ```sql
2387
+ -- D12 · live relation denies (rollback below 2.5 requires 0)
2388
+ SELECT count(*) AS n FROM authz_relations WHERE substr(relation, 1, 7) = 'denied_';
2389
+ ```
2390
+
2391
+ On `openfga`, `node ace authz:relations:reconcile --to=<other> --from=openfga --dry-run` per partition prints a
2392
+ `denies:` line (`escritos + iguales` are the store's live denies). Retire them with `removeDeny` (or the purges) on the
2393
+ active driver and `authz:relations:reconcile … --prune` on the mirror until the count is 0. Step 21 of the runbook executes
2394
+ this procedure with its EFFECT: with the denies alive both `provision` attempts exit 1 without touching the store; an
2395
+ operator who skips step 0 retires the denies to zero and a pod still in `'write'` writes one more — the count is 1 again
2396
+ and `provision --allow-drop-deny` exits 1 instead of publishing; with the fleet in `'read'` the concurrent `deny` is 422 on
2397
+ both drivers, the count reaches 0 and stays 0, and only then the model is published.
2398
+
2399
+ **A pass of 2.5 never classifies a deny as `undeclared`**: it migrates it as a deny or refuses the whole pass (above).
2400
+ Never run `authz:relations:reconcile` from a process < 2.5.0-alpha.3 once there are denies in a store: it counts them as
2401
+ undeclared, and its `--prune` hands them to `unrelate`.
2402
+
2403
+ ### Upgrading to 2.5.0-alpha.4: pin `OPENFGA_MODEL_ID` everywhere (`openfga` only)
2404
+
2405
+ **Breaking for `openfga`**: both drivers refuse to construct without `modelId` (500 `E_AUTHZ_CONFIG` with the recipe),
2406
+ with or without deny types — until alpha.3 it was required only with a deny type (and by `decideMany`). Unpinned, the
2407
+ driver would check one model and the server would evaluate each request with the *latest* one of the store.
2408
+
2409
+ 1. `node ace openfga:provision --store-id=$OPENFGA_STORE_ID` from the config you are deploying: it publishes, **re-reads
2410
+ and compares** the model, and only then prints `OPENFGA_MODEL_ID=…` (no such line ⇒ do not pin, see the error).
2411
+ 2. Put that id in the `.env` of **every** process (web, workers, the `ace` commands) and deploy. The published
2412
+ `config/authorization.ts` stub already passes `env.get('OPENFGA_MODEL_ID')` to both drivers and now throws a plain
2413
+ error at resolution if it is missing.
2414
+ 3. A pod whose config and pinned model differ answers 500 `E_AUTHZ_CONFIG` on first use naming the differences
2415
+ ([the model gate](#the-pinned-model-is-the-one-your-config-generates-250-alpha4)) — fix the pair, never the gate.
2416
+
2417
+ `database` is not affected.
2418
+
2419
+ ### Upgrading to 2.5.0-alpha.4: `openfga` is experimental — opt in with `experimental: 'openfga@2.5'` (`openfga` only)
2420
+
2421
+ **Breaking for every `openfga` user, by design.** Both `OpenFgaAuthorizationDriver` and `OpenFgaRelationsDriver` now
2422
+ throw **500 `E_AUTHZ_CONFIG`** at construction unless their options carry the literal `experimental: 'openfga@2.5'` —
2423
+ in the factory of `config/authorization.ts`, in the relations factory, and in any `new` of your own (scripts, tests,
2424
+ CI). Nothing else is validated before it and no client is created. `true`, `'acknowledged'` or another version are
2425
+ refused on purpose: the token says you read the [declared limits](#limits) of **this** release.
2426
+
2427
+ ```ts
2428
+ openfga: async () => {
2429
+ const { OpenFgaAuthorizationDriver } = await import('@jantstack/adonis-authz/openfga')
2430
+ return new OpenFgaAuthorizationDriver({ experimental: 'openfga@2.5', apiUrl, storeId, modelId, resolveChain, holderTypes, outbox })
2431
+ },
2432
+ ```
2433
+
2434
+ - **Not production-ready in 2.5.x.** The stable driver of 2.5.0 is `database`. `openfga` keeps its suite (the same
2435
+ judge runs whole against it), its runbook and its tooling, and its open limits are rows of [Limits](#limits) marked
2436
+ *declared limit*, each with figure, recipe, detector and a characterisation case. They close in **2.6**.
2437
+ - **The published config stub does not opt in for you.** Its two `openfga` factories carry the line commented out
2438
+ (`// experimental: 'openfga@2.5',`), so an app scaffolded with `AUTHZ_DRIVER=openfga` fails closed at the first use
2439
+ until someone writes it knowing what it accepts. With `AUTHZ_DRIVER=database` the factory is registered and never
2440
+ built: nothing changes.
2441
+ - **Reading it at runtime**: `(await authorization.capabilities()).experimental` and
2442
+ `relations.capabilities().experimental` are `true` on `openfga`, `false` on `database` (a third-party driver without
2443
+ the key reads `false`).
2444
+ - **Migrating off `openfga` needs the token too.** `authz:reconcile --to=database` reads the store through the `openfga`
2445
+ factory (it is the SOURCE), and building that driver is what the gate guards: keep `experimental: 'openfga@2.5'` in
2446
+ that factory until the migration is done, then remove the factory. Serving from `database` never builds it.
2447
+ - The subpath keeps its name (`@jantstack/adonis-authz/openfga`).
2448
+
2449
+ ### Upgrading to 2.5.0-alpha.4: Node 22, the SDK range, no source maps, and relation listings without `page`
2450
+
2451
+ Four changes of the «before the RC» lot (7.1–7.4), all in `package.json`, the build or the type of the relations port:
2452
+
2453
+ 1. **Node ≥ 22** (`engines.node`). It is what the suite and the first consumer run; on Node 20 npm warns (or refuses,
2454
+ with `engine-strict`).
2455
+ 2. **`@openfga/sdk` is a declared optional peer: `>=0.9.6 <0.10.0`.** Until now only `peerDependenciesMeta` named it,
2456
+ so any version was "fine". 0.9.5 fixes `authorizationModelId` in `batchCheck` and 0.9.6 is the version the suite runs;
2457
+ a 0.10 will be a decision, not an accident. A `database`-only install still needs nothing (the peer is optional) —
2458
+ but if `@openfga/sdk` reaches your tree through ANOTHER package, npm validates the range anyway and a 0.10 fails the
2459
+ install with `ERESOLVE`.
2460
+ 3. **No source maps in the tarball** (`sourceMap`/`declarationMap` off): smaller package, stack traces point at
2461
+ `build/*.js`. Nothing to do on your side.
2462
+ 4. **🔴 BREAKING (type of the relations port) — `listObjects`, `listSubjects`, `listDenies` and `membersOf` take no
2463
+ `page`, and their answers have no `cursor`.** The parameter never did anything (designed in phase 4, never
2464
+ implemented), so a list that came back looked like a page and was the whole set; keeping it would have turned the
2465
+ real pagination of 2.6 into a silent change of meaning ("today everything, tomorrow 10 and a cursor nobody reads").
2466
+ - TypeScript: a fifth argument (fourth in `listSubjects`/`listDenies`/`membersOf`) no longer compiles, and
2467
+ `RelationObjectsPage.cursor` / `RelationSubjectsPage.cursor` are gone. Remove the argument; the answer is the whole
2468
+ set (or `truncated: true` where the backend cuts, `listObjects` on `openfga`).
2469
+ - At runtime (plain JS, `as any`): anything but `undefined` — `null` and `{}` included — is 500
2470
+ `E_AUTHZ_UNSUPPORTED` naming the operation, with zero backend calls, in the manager **and** in both drivers
2471
+ (`manager.driver()` is not a way around it). `undefined` passed explicitly is fine.
2472
+ - A **third-party driver** that still declares `page?` keeps compiling (an extra optional parameter is assignable);
2473
+ the published contract suite now pins at runtime that, without a page, the four listings return the whole set and
2474
+ no cursor (N = 150, above every internal page size).
2475
+ - **`enumerateRelations(Many)` keep their page**, now validated: a `limit` that is not an integer ≥ 1 is 422
2476
+ `E_AUTHZ_INVALID_PAGE`, above 1,000 it is clipped (with a cursor, nothing lost); on `database` an `after` that is not
2477
+ the canonical uuid it emitted is 422; on `openfga` any `after` is 500 (it never emits a cursor). The reconcile only
2478
+ ever passes the cursor it received, so it is unaffected.
2479
+ - Paginated listings arrive in 2.6 as **separate** methods (`listSubjectsPage`, …) behind their own capabilities —
2480
+ adding them will not break anything.
2481
+
2482
+ ### Upgrading to 2.5.0-alpha.4: the release steps if you use the deny (`database`)
2483
+
2484
+ The deny-reach guard of [the persisted config](#relations-rebac-24) adds steps to every release. They are spread over
2485
+ that section and the [Limits](#limits) rows; this is the same thing in one place. If you do not use `deny`, nothing
2486
+ here is required.
2487
+
2488
+ ```ts
2489
+ import { saveRelationsConfig, checkRelationsConfigDenyReach } from '@jantstack/adonis-authz'
2490
+
2491
+ // `spec` is the same object your config/authorization.ts passes to defineRelationsConfig.
2492
+ // Run this in the release job (an init container, a migrate step), BEFORE the new version serves traffic,
2493
+ // with the same `connection` as the `database` relations driver (if it has its own).
2494
+
2495
+ // 1 · Save the config this release serves. This IS the guard: if it shrinks the reach of a live deny it throws
2496
+ // 500 E_AUTHZ_DENY_REACH_REFUSED and writes nothing — the release stops here.
2497
+ const served = await saveRelationsConfig(spec)
2498
+
2499
+ // 2 · Check. Read-only; fail the job when `ok` is false (one line per finding in `lines`).
2500
+ const check = await checkRelationsConfigDenyReach(served)
2501
+ if (!check.ok) throw new Error(check.lines.join('\n'))
2502
+
2503
+ // 3 · Serve.
2504
+ ```
2505
+
2506
+ - **Once, the first save with live denies and no saved config yet:** `saveRelationsConfig(spec, { establishBaseline:
2507
+ true })`, with the spec the fleet serves **today**, in a release that does **not** change `relations.config`. Remove
2508
+ the option afterwards — with a baseline it does nothing. If you already save the config before your first deny, you
2509
+ never need it.
2510
+ - **Only when you really mean to narrow:** `saveRelationsConfig(spec, { allowDenyReachShrink: ['note#editor'] })`,
2511
+ naming exactly what may shrink. A name that exempts nothing in that save is 500 `E_AUTHZ_CONFIG`, so the list cannot
2512
+ stay in the pipeline by accident: remove it after that release.
2513
+ - **Also run step 2 on a schedule** (a health job, a cron) while a release serves: it is what sees a saved config that
2514
+ fell behind the one being served. Never as a hard failure in `ready`.
2515
+ - On a `database`-only deploy, `authz:relations:reconcile --dry-run` cannot run (it needs two relations drivers):
2516
+ step 2 is your detector.
2517
+
1803
2518
  ## Compatibility
1804
2519
 
1805
2520
  | | |
1806
2521
  |---|---|
1807
- | Node | ≥ 20.6 |
2522
+ | Node | ≥ 22 (`engines.node`, since 2.5.0-alpha.4); CI is set up to run the suite on 22 and 24 (job `compat`). Note that AdonisJS 7 and Lucid 22 themselves declare Node ≥ 24 |
1808
2523
  | AdonisJS | ^7 (peer) · Lucid ^22 (peer) |
1809
- | OpenFGA SDK | ^0.9 (optional peer, only for that driver); server verified against `v1.19.0` |
2524
+ | OpenFGA SDK | `>=0.9.6 <0.10.0` (optional peer, only for that driver; 0.9.5 fixes `authorizationModelId` in `batchCheck`, 0.9.6 is what the suite runs); server verified against `v1.19.0` |
1810
2525
  | Databases | The full contract suite (`database` driver, and `openfga` with the catalog in SQL) runs on every engine in CI: **SQLite** (in memory, and as a file with a pool of 2–5), **PostgreSQL 18** and **MySQL 8.4**. See [Operational notes for the SQL engines](#operational-notes-for-the-sql-engines) for the three schema decisions those runs forced. |
1811
2526
  | Module format | ESM only |
1812
2527