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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/README.md +496 -29
  2. package/build/commands/authz_catalog_diff.d.ts.map +1 -1
  3. package/build/commands/authz_catalog_diff.js +0 -1
  4. package/build/commands/authz_catalog_diff.js.map +1 -1
  5. package/build/commands/authz_catalog_prune_orphans.d.ts.map +1 -1
  6. package/build/commands/authz_catalog_prune_orphans.js +0 -1
  7. package/build/commands/authz_catalog_prune_orphans.js.map +1 -1
  8. package/build/commands/authz_freeze.d.ts.map +1 -1
  9. package/build/commands/authz_freeze.js +1 -2
  10. package/build/commands/authz_freeze.js.map +1 -1
  11. package/build/commands/authz_reconcile.d.ts +15 -6
  12. package/build/commands/authz_reconcile.d.ts.map +1 -1
  13. package/build/commands/authz_reconcile.js +143 -20
  14. package/build/commands/authz_reconcile.js.map +1 -1
  15. package/build/commands/authz_relations_reconcile.d.ts +72 -1
  16. package/build/commands/authz_relations_reconcile.d.ts.map +1 -1
  17. package/build/commands/authz_relations_reconcile.js +234 -21
  18. package/build/commands/authz_relations_reconcile.js.map +1 -1
  19. package/build/commands/authz_scopes_relay.d.ts +50 -0
  20. package/build/commands/authz_scopes_relay.d.ts.map +1 -1
  21. package/build/commands/authz_scopes_relay.js +213 -10
  22. package/build/commands/authz_scopes_relay.js.map +1 -1
  23. package/build/commands/authz_scopes_requeue.d.ts +80 -0
  24. package/build/commands/authz_scopes_requeue.d.ts.map +1 -0
  25. package/build/commands/authz_scopes_requeue.js +230 -0
  26. package/build/commands/authz_scopes_requeue.js.map +1 -0
  27. package/build/commands/authz_unfreeze.d.ts +12 -4
  28. package/build/commands/authz_unfreeze.d.ts.map +1 -1
  29. package/build/commands/authz_unfreeze.js +74 -9
  30. package/build/commands/authz_unfreeze.js.map +1 -1
  31. package/build/commands/main.d.ts +16 -13
  32. package/build/commands/main.d.ts.map +1 -1
  33. package/build/commands/main.js +59 -11
  34. package/build/commands/main.js.map +1 -1
  35. package/build/commands/openfga_provision.d.ts +49 -3
  36. package/build/commands/openfga_provision.d.ts.map +1 -1
  37. package/build/commands/openfga_provision.js +72 -7
  38. package/build/commands/openfga_provision.js.map +1 -1
  39. package/build/index.d.ts +15 -5
  40. package/build/index.d.ts.map +1 -1
  41. package/build/index.js +16 -3
  42. package/build/index.js.map +1 -1
  43. package/build/providers/authz_provider.d.ts +7 -2
  44. package/build/providers/authz_provider.d.ts.map +1 -1
  45. package/build/providers/authz_provider.js +20 -3
  46. package/build/providers/authz_provider.js.map +1 -1
  47. package/build/src/define_config.d.ts +106 -1
  48. package/build/src/define_config.d.ts.map +1 -1
  49. package/build/src/define_config.js +11 -0
  50. package/build/src/define_config.js.map +1 -1
  51. package/build/src/drivers/database_driver.d.ts +53 -14
  52. package/build/src/drivers/database_driver.d.ts.map +1 -1
  53. package/build/src/drivers/database_driver.js +104 -32
  54. package/build/src/drivers/database_driver.js.map +1 -1
  55. package/build/src/drivers/database_relations_driver.d.ts +74 -9
  56. package/build/src/drivers/database_relations_driver.d.ts.map +1 -1
  57. package/build/src/drivers/database_relations_driver.js +391 -93
  58. package/build/src/drivers/database_relations_driver.js.map +1 -1
  59. package/build/src/drivers/openfga_driver.d.ts +71 -10
  60. package/build/src/drivers/openfga_driver.d.ts.map +1 -1
  61. package/build/src/drivers/openfga_driver.js +195 -16
  62. package/build/src/drivers/openfga_driver.js.map +1 -1
  63. package/build/src/drivers/openfga_facts.d.ts +15 -0
  64. package/build/src/drivers/openfga_facts.d.ts.map +1 -1
  65. package/build/src/drivers/openfga_facts.js +25 -2
  66. package/build/src/drivers/openfga_facts.js.map +1 -1
  67. package/build/src/drivers/openfga_relations_driver.d.ts +72 -6
  68. package/build/src/drivers/openfga_relations_driver.d.ts.map +1 -1
  69. package/build/src/drivers/openfga_relations_driver.js +308 -50
  70. package/build/src/drivers/openfga_relations_driver.js.map +1 -1
  71. package/build/src/errors.d.ts +175 -4
  72. package/build/src/errors.d.ts.map +1 -1
  73. package/build/src/errors.js +219 -3
  74. package/build/src/errors.js.map +1 -1
  75. package/build/src/freeze.d.ts +100 -20
  76. package/build/src/freeze.d.ts.map +1 -1
  77. package/build/src/freeze.js +159 -25
  78. package/build/src/freeze.js.map +1 -1
  79. package/build/src/manager.d.ts +239 -7
  80. package/build/src/manager.d.ts.map +1 -1
  81. package/build/src/manager.js +1192 -148
  82. package/build/src/manager.js.map +1 -1
  83. package/build/src/models/authz_assignment.d.ts +2 -2
  84. package/build/src/models/authz_assignment.d.ts.map +1 -1
  85. package/build/src/models/authz_deny.d.ts +2 -2
  86. package/build/src/models/authz_deny.d.ts.map +1 -1
  87. package/build/src/models/authz_permission.d.ts +2 -2
  88. package/build/src/models/authz_permission.d.ts.map +1 -1
  89. package/build/src/models/authz_role.d.ts +2 -2
  90. package/build/src/models/authz_role.d.ts.map +1 -1
  91. package/build/src/models/authz_role_permission.d.ts +2 -2
  92. package/build/src/models/authz_role_permission.d.ts.map +1 -1
  93. package/build/src/relation_partition_trigger.d.ts.map +1 -1
  94. package/build/src/relation_partition_trigger.js +11 -3
  95. package/build/src/relation_partition_trigger.js.map +1 -1
  96. package/build/src/relations/define_relations_config.d.ts +51 -0
  97. package/build/src/relations/define_relations_config.d.ts.map +1 -1
  98. package/build/src/relations/define_relations_config.js +68 -1
  99. package/build/src/relations/define_relations_config.js.map +1 -1
  100. package/build/src/relations/manager.d.ts +106 -6
  101. package/build/src/relations/manager.d.ts.map +1 -1
  102. package/build/src/relations/manager.js +368 -30
  103. package/build/src/relations/manager.js.map +1 -1
  104. package/build/src/relations/reconcile.d.ts +81 -6
  105. package/build/src/relations/reconcile.d.ts.map +1 -1
  106. package/build/src/relations/reconcile.js +233 -77
  107. package/build/src/relations/reconcile.js.map +1 -1
  108. package/build/src/scope_outbox.d.ts +54 -0
  109. package/build/src/scope_outbox.d.ts.map +1 -1
  110. package/build/src/scope_outbox.js +274 -33
  111. package/build/src/scope_outbox.js.map +1 -1
  112. package/build/src/shared/backend_guard.d.ts +28 -0
  113. package/build/src/shared/backend_guard.d.ts.map +1 -1
  114. package/build/src/shared/backend_guard.js +52 -0
  115. package/build/src/shared/backend_guard.js.map +1 -1
  116. package/build/src/shared/sql_expiry.d.ts +2 -0
  117. package/build/src/shared/sql_expiry.d.ts.map +1 -1
  118. package/build/src/shared/sql_expiry.js +4 -0
  119. package/build/src/shared/sql_expiry.js.map +1 -1
  120. package/build/src/shared/transaction_guard.d.ts +53 -0
  121. package/build/src/shared/transaction_guard.d.ts.map +1 -0
  122. package/build/src/shared/transaction_guard.js +74 -0
  123. package/build/src/shared/transaction_guard.js.map +1 -0
  124. package/build/src/sql_descendants.d.ts +8 -0
  125. package/build/src/sql_descendants.d.ts.map +1 -1
  126. package/build/src/sql_descendants.js.map +1 -1
  127. package/build/src/testing/contract.d.ts +55 -4
  128. package/build/src/testing/contract.d.ts.map +1 -1
  129. package/build/src/testing/contract.js +244 -9
  130. package/build/src/testing/contract.js.map +1 -1
  131. package/build/src/testing/main.d.ts +6 -4
  132. package/build/src/testing/main.d.ts.map +1 -1
  133. package/build/src/testing/main.js +3 -2
  134. package/build/src/testing/main.js.map +1 -1
  135. package/build/src/testing/migration_contract.d.ts +9 -0
  136. package/build/src/testing/migration_contract.d.ts.map +1 -1
  137. package/build/src/testing/migration_contract.js +5 -2
  138. package/build/src/testing/migration_contract.js.map +1 -1
  139. package/build/src/testing/relations_contract.d.ts +23 -1
  140. package/build/src/testing/relations_contract.d.ts.map +1 -1
  141. package/build/src/testing/relations_contract.js +603 -19
  142. package/build/src/testing/relations_contract.js.map +1 -1
  143. package/build/src/testing/relations_reconcile_contract.d.ts.map +1 -1
  144. package/build/src/testing/relations_reconcile_contract.js +49 -1
  145. package/build/src/testing/relations_reconcile_contract.js.map +1 -1
  146. package/build/src/testing/scope_outbox_contract.d.ts +98 -0
  147. package/build/src/testing/scope_outbox_contract.d.ts.map +1 -0
  148. package/build/src/testing/scope_outbox_contract.js +677 -0
  149. package/build/src/testing/scope_outbox_contract.js.map +1 -0
  150. package/build/src/traits/has_uuid.d.ts +2 -2
  151. package/build/src/traits/has_uuid.d.ts.map +1 -1
  152. package/build/src/types.d.ts +708 -38
  153. package/build/src/types.d.ts.map +1 -1
  154. package/build/src/types.js +55 -0
  155. package/build/src/types.js.map +1 -1
  156. package/build/stubs/config/authorization.stub +45 -1
  157. package/build/stubs/migration.stub +20 -10
  158. package/package.json +1 -1
package/README.md CHANGED
@@ -31,7 +31,11 @@ node ace authz:catalog:sync
31
31
 
32
32
  `configure` registers the provider, the commands and the `appAccess` middleware, defines the env variables, and **publishes into your project** what belongs to you: the migration for the `authz_*` tables and two config files (`config/authorization.ts` for drivers and the scope tree, `config/app_acl.ts` for your role catalog). The suite compiles both published configs against the package on every run, so they cannot drift from it.
33
33
 
34
- For the OpenFGA driver, also install its SDK (optional peer): `npm i @openfga/sdk`. Everything that touches the SDK lives behind the **`@jantstack/adonis-authz/openfga` subpath** (`OpenFgaAuthorizationDriver`, `provisionOpenFgaStore`, `importAuthzFactsToOpenFga`, `openFgaAuthorizationModel`, `assertHolderTypes`); the main entry never imports it, so a database-only install boots without the SDK (the suite loads `index.ts` in a child process with `@openfga/sdk` blocked to prove it: *"index.ts (la entrada principal) carga con el SDK bloqueado"*). The published config imports the driver from that subpath *inside* the factory.
34
+ > **Upgrading to 2.4.0-alpha.7 on MySQL from 2.4.0-alpha.4 or earlier — stop the relay, do not roll.** alpha.5 and alpha.6 took both the old (64-char-truncated) and the new (stable, hashed) `GET_LOCK` names of the outbox lease so a mixed rolling deploy kept a single writer; alpha.7 takes only the new one (`SCOPE_OUTBOX_TAKES_LEGACY_LOCK = false`). A leftover alpha.4 relay holding the old name is **not seen** by alpha.7 — pinned by a case, on purpose — so upgrade with a full stop of every relay (cron and inline) and a restart. From alpha.5 or later, or on PostgreSQL/SQLite, nothing to do.
35
+
36
+ > **Upgrading to 2.4.0-alpha.4 — how the commands are registered.** `@jantstack/adonis-authz/commands` is now a real ace loader (it exports `getMetaData()`/`getCommand()`, built with `ListLoader` over the nine command classes, next to the named class exports). Until alpha.4 it only re-exported the classes, so the line `configure` writes — `commands: [() => import('@jantstack/adonis-authz/commands')]` — made **the whole `node ace` crash** at boot (`TypeError: loaderInstance.getMetaData is not a function`). If you registered the commands by wrapping the barrel in a `ListLoader(Object.values(m))` (the only way it worked before alpha.4), remove the wrapper and register the module directly: `() => import('@jantstack/adonis-authz/commands')`, which is what `node ace configure` writes. The wrapper now fails with `TypeError: command.serialize is not a function`, because the module also exports the loader functions: `Object.values(m)` includes them and `ListLoader.getMetaData()` calls `serialize()` on each value. The module has no `default` export on purpose — ace calls `getMetaData()` on the module namespace and never unwraps `default` (same shape as `@adonisjs/core/commands`).
37
+
38
+ For the OpenFGA driver, also install its SDK (optional peer): `npm i @openfga/sdk`. Everything that touches the SDK lives behind the **`@jantstack/adonis-authz/openfga` subpath** (`OpenFgaAuthorizationDriver`, `OpenFgaRelationsDriver`, `provisionOpenFgaStore`, `openFgaFactsModel`, `assertHolderTypes`); the main entry never imports it, so a database-only install boots without the SDK (the suite loads `index.ts` in a child process with `@openfga/sdk` blocked to prove it: *"index.ts (la entrada principal) carga con el SDK bloqueado"*). The published config imports the driver from that subpath *inside* the factory.
35
39
 
36
40
  ## Semantics (what every driver guarantees)
37
41
 
@@ -95,6 +99,8 @@ Your resolver's *answer* is validated too: an element that is not a well-formed
95
99
 
96
100
  `ScopeType` is an open `string`, so define your own union for type safety. The engine never queries your tables.
97
101
 
102
+ **How deep can the tree go?** In `database`, as deep as your resolver walks. With the `openfga` driver the `facts` model resolves a chain reliably up to **22 `parent` hops** (`FACTS_MAX_RESOLVE_DEPTH`) and answers 503 — never `false` — past it, so design the tree with that number in front of you and, if you want the engine to refuse the 23rd level before it is written, declare `scopes.maxDepth` (422 `E_AUTHZ_SCOPE_DEPTH_LIMIT`, partial: it does not see the height of a moved subtree). Every bound the package enforces is in one table: [Limits](#limits).
103
+
98
104
  #### The tree outbox, and the relay lag you are accepting
99
105
 
100
106
  Those three notifications write to the backend **inside your transaction, and they do not roll back with it**. If a later statement of that transaction fails — a constraint, a validation, a pool timeout; no crash needed — your database keeps the old tree and the backend keeps the new one. With the `openfga` driver the backend *is* the PDP, so what is left is a **persistent escalation your own database cannot show you**: every holder with a role in the new parent authorises over a scope that, in SQL, still belongs to the old tenant. This is not misuse; correct use leaks. The suite demonstrates it against a real server — the rollback happens and the escalation stays.
@@ -122,13 +128,42 @@ await db.transaction(async (trx) => {
122
128
  ```bash
123
129
  node ace authz:scopes:relay # drain the queue and apply the edges
124
130
  node ace authz:scopes:relay --dry-run # list what is still unpropagated
131
+ node ace authz:scopes:relay --only=12,13 # apply only these entries (the receipt of scopes.*), 2.4.0-alpha.5
132
+ node ace authz:scopes:requeue --id=12 # return a parked entry to the queue (refuses to revert later applied changes), 2.4.0-alpha.5
133
+ node ace authz:scopes:relay --max-age=300000 # exit ≠ 0 if the oldest pending entry is older than 5 min (the age is always printed), 2.4.0-alpha.8
125
134
  ```
126
135
 
127
- The package **does not impose a table**: the contract is the `ScopeOutbox` port (`enqueue`, `pending`, `markApplied`, `markFailed`, plus two optional ones: `dead` for parked entries and `acquire` for the single-writer lease). `sqlScopeOutbox` is the published implementation over Lucid and `stubs/scopes_outbox_migration.stub` is its migration — **copy it into your migrations yourself**; `node ace configure` does not publish it, because the outbox is opt-in. The only thing an implementation must do is write `enqueue` inside the transaction it is handed.
136
+ The package **does not impose a table**: the contract is the `ScopeOutbox` port (`enqueue`, `pending`, `markApplied`, `markFailed`, plus five optional ones: `dead` for parked entries, `acquire` for the single-writer lease and, since 2.4.0-alpha.5, `earlierPending` — the dependency probe of `only` —, `requeue` and `laterApplied` — the pair behind `authz:scopes:requeue`; in the same release `enqueue` may return `{ id }`, `pending` takes an optional `{ ids }` filter and `markFailed` an optional `{ transient }`, all three covariant: an implementation written against alpha.4 still compiles and works). Since 2.4.0-alpha.5 the port has a published judge, [`runScopeOutboxContract`](#the-outbox-contract-runscopeoutboxcontract-240-alpha5). `sqlScopeOutbox` is the published implementation over Lucid and `stubs/scopes_outbox_migration.stub` is its migration — **copy it into your migrations yourself**; `node ace configure` does not publish it, because the outbox is opt-in. Two things an implementation must do: write `enqueue` inside the transaction it is handed, and **`pending()` must return the whole unapplied queue, oldest first** — it must not hide an entry the relay did not filter through `{ ids }`, by tenant, by scope or by anything else: the order guarantee of the relay (a `moved` before the `attached` that depends on it) rests on the manager seeing the whole queue, and no guard of the manager can tell when a port hides rows on its own (that is exactly how the cross-tenant escalation measured in the audit is reproduced with a "filtering" outbox). It is a duty of the port and the contract judges it (O-08).
137
+
138
+ **Two rules about that transaction (2.4.0-alpha.2, lot L-1).** First, **`{ transaction }` carries the write, never the authority**: the freeze barrier that precedes every write (row `id = 2` of `authz_catalog_version`, see [the freeze](#migrating-and-verifying-authzreconcile-23)) is read through the engine's own connection, **never through your transaction** — a transaction opened *before* a freeze does not see it in its snapshot (InnoDB's REPEATABLE READ, PostgreSQL under that level, SQLite in WAL), and a barrier decided by the caller's snapshot is not a barrier (the suite reproduces the bypass on all three engines and pins the 503). The price is declared: **`{ transaction }` requires a connection pool of at least 2**. With a pool of 1 (SQLite `:memory:`) the barrier cannot get a connection while you hold the only one, and the notification is **503 `E_AUTHZ_BACKEND_TIMEOUT` at `freezeTimeoutMs`** (config, default 5000 ms) — fail-closed, never a hang, never a bypass. Second, `sqlScopeOutbox` **checks whose transaction it is**: it must be an *open* Lucid transaction (`isTransaction === true`) of the **outbox's own connection** (`connection`, or the primary one). A transaction of another connection, a `QueryClient` (`db.connection()`) or the whole `db` service are **500 `E_AUTHZ_CONFIG`** naming both connections, before any statement — until L-1 a transaction of another connection silently enqueued in *its* database (where no relay reads and `dead()` never shows it), and `db` passed the duck-check and wrote outside any transaction, in silence. The same check (`assertCallerTransaction`) is the rule for every `{ transaction }` of the package. The whole story — what `{ transaction }` means on each call, what the engines do, the `resolveChain` limit and what `openfga` offers instead — is in [Writing inside your transaction](#writing-inside-your-transaction-240-alpha2).
128
139
 
129
140
  The relay is resumable and never silent: the report says *which* changes were applied, not a count. A change that cannot be applied **defers what depends on it and lets the rest through**: the failure poisons the scopes that change names, every later change naming one of them is reported as `deferred` and not attempted (transitively), and everything else is applied. That is what keeps the tree order — `attached(P, org)` before `attached(C, P)`, a `moved` before the `detached` of the same node — without letting one tenant freeze the queue for all of them. Until 2.0 the pass stopped at the first failure, and a single change that can never be applied (its parent scope was deleted before the pass) then blocked every later change of every tenant, indefinitely. `sqlScopeOutbox` also **parks** an entry after `maxAttempts` failures (default 5): it stops being retried, it is reported in `dead` on every pass, and the command exits non-zero while any exists — a parked entry is a permanent divergence of the backend's tree, not a resolved incident. The command exits non-zero on any failure too, so a supervisor notices. Applying a queued `detached` runs `purgeScope` and only then removes the edge, and it emits the `scope_purged` audit event at that point, carrying the actor that ordered it.
130
141
 
131
- **The relay is a single writer.** `pending()` reserves nothing, so two passes at once (a Kubernetes `CronJob` with the default `concurrencyPolicy: Allow`, two replicas, a pass that lasts longer than its interval) work on the same batch: the straggler re-applies an old `attached` after the other applied the new `moved`, and the store is left with the **old parent and a single edge, so nothing denounces it** — the old tenant keeps access to a subtree that is no longer theirs (measured). `sqlScopeOutbox.acquire()` takes a lease for the pass — a server-side lock on PostgreSQL and MySQL, a process-wide one on SQLite — and a second simultaneous pass does nothing and says so (`busy`). If you implement the port yourself and skip `acquire`, run the relay one pass at a time.
142
+ **"Was mine applied?" — the receipt and `only` (2.4.0-alpha.5).** `scopes.attached/moved/detached` return a receipt, **`{ queued: [id] }`** — the outbox ids of what they just enqueued (plural on purpose: a `detached` whose row is already gone enqueues one entry per spelling of the uuid). Empty means there is nothing to relay: without an outbox the change was applied inline; with a third-party `ScopeOutbox` whose `enqueue` still returns `void` there is no id, and then there is no `only` either. Hand the receipt to the relay on the interactive path, right after your commit: **`await authorization.relayScopeChanges({ only: queued })`**. That is the fix for the cost a consumer measured: with 500 entries of another tenant ahead in the queue, the inline pass cost **4,507 ms and 2,004 OpenFGA calls** to land one change; with `only` it costs **19 ms and 4 calls**, because the filter lives in the port (`pending(limit, after, { ids })` — the primary key) and the pass never reads the other 500 rows (the suite spies on `pending` to pin that; a port implementation that ignores the filter is 500 `E_AUTHZ_CONFIG` before anything is applied, never a silent drain of the backlog inside your request). **The order by scope key is not relaxed — deferred, never out of order.** Before applying each requested entry the relay probes the *whole* queue (`earlierPending`: one existence query against the `(applied_at, id)` index, 0.69 ms measured; a port without it is paged through `pending()`): if an earlier unapplied entry names one of the same scopes and was not requested, yours is reported `deferred` with `depends-on:<id>` and not applied. Why, measured: with `#1 moved(unit → orgB)` pending and `only: [#2 attached(child, unit)]`, a filter without the probe wrote `child → unit` while the store still said `unit → orgA`, and the editor of the *old* organization authorised over the child (`authorize(A@orgA, docs:read, child) = true`, report all green). One level is enough — the last link of any chain of pending dependencies shares a scope with yours by definition of the key contamination — so the probe costs one query, not the queue. The report is **per entry**: `entries` names every entry the pass looked at, `applied` / `deferred` (reason `depends-on:<id>` or `parked:<id>`) / `failed` / `dead`, with or without `only`; and `missing` names the ids of `only` that are not in the queue at all — neither pending nor parked: already applied by an earlier pass, or unknown; the relay cannot tell — never an empty report in green. **`notEvaluated` names the ids of `only` that are still pending and the pass never looked at** because it reached `limit` first (2.4.0-alpha.10): until then `{ only: [a, b], limit: 1 }` put the pending `b` in `missing` — "already applied, or unknown" — and the applying pass exited 0. Telling them apart costs one read of the queue filtered by those ids, only when some requested id was not seen. `authz:scopes:relay --only … --limit …` names each of them ("is PENDING and this pass did NOT evaluate it", saying whether it was `--dry-run` or the applying pass and which `--limit`) and exits ≠ 0, in both. `only: []` is 500 `E_AUTHZ_CONFIG`: an empty `filter` on your side, or a receipt from an outbox without ids, cannot end up draining the whole queue under the lease of an HTTP request. **`remaining` and `dead` are always global**, with or without `only`: a filtered report that kept quiet about a queue that does not converge would be a green report over a broken tree. Three things this is **not**. `only` takes the ids from the receipt your own process just got, **never from the request body** — an endpoint that accepts `only` from a client is policy in an API that has no policy (the relay skips `requireActor`/`requireWithin` on purpose). **There is no filter by tenant or organization**: a `detached` whose row is already gone belongs to no organization (excluding it always is a permanent fail-open — its facts are never purged; including it always is "anyone relays anyone's `detached`"), and a `moved` would be attributed to its *destination*, which is the bias that keeps accesses. And `only` does not pull its dependencies along — that is transitive and ends up being the whole queue; if what you want is "the queue landed up to here", run the relay without `only`. **Do not return the relay report over HTTP**: it enumerates the tree activity of every tenant (ids, scope uuids, actors, the backend's error text); read it, log it, act on `entries`, and hand the client your own answer. Finally, **the inline pass returns a report, it does not throw**: a `deferred` or a `failures` does not abort your request unless you look at it — if `entries` says your change is `deferred`, it has not landed, and that is the fail-closed window described below.
143
+
144
+ **A 503 does not consume an attempt (2.4.0-alpha.5).** `maxAttempts` exists to converge on the *irreparable* — the parent of an enqueued `attached` was deleted and will not come back — and until alpha.4 a backend outage spent the same budget: measured, with the store down, **five requests of another tenant** running the inline relay parked an entry that was not theirs (one attempt per request: the entry at the head of the queue pays for everyone's pass), and from then on its dependants were applied without it. Now the manager tells the two apart by the nature of the error: an `AuthorizationBackendError` — the store did not answer, the deadline expired, any 503 — is reported in `failures` with `transient: true` and passed to `markFailed(id, error, { transient: true })`, and `sqlScopeOutbox` writes `last_error` **without** counting the attempt; a 422, a 500, `E_AUTHZ_SCOPE_TREE_DRIFT`, a missing parent or a bare error from a third-party driver count as before. A port implementation that ignores the third parameter counts everything — exactly the alpha.4 behaviour, degrading towards safe. For the inline recipe, with the numbers: an outage of the store lasting `N` requests leaves `N−1` entries deferred behind the failing head on request `N`, and none of them parked.
145
+
146
+ **A parked entry blocks, by scope key, what grants or re-parents (`attached`, `moved`) and lets through what retires (`detached`) (2.4.0-alpha.5).** Until alpha.4 a parked entry left `pending()` and stopped contaminating, so what depended on it was applied *without* it — for an `attached` behind a parked `moved` that is the store keeping the **old parent, permanently**, the persistent escalation this outbox exists to prevent (measured; and live in a consumer running the inline relay, with no change of theirs involved). Now the keys of `dead()` seed the blocked set at the start of every pass: an `attached` or `moved` naming one of those scopes is reported `deferred` with `parked:<id>`, on every pass, until someone looks at the parked entry (the command names it; the way out is `authz:reconcile --to=<driver>`, which recomputes with today's tree, or a new notification from your side). A `detached` of that key is applied anyway — a purge never waits (invariant 11: a half-applied tree may leave denies *in excess*, never facts alive on a node nobody purges) — **and that holds transitively** (closing lot of alpha.5, auditor 🟠 1): a `detached` behind an `attached` that is itself deferred *because of* a parked entry is applied too, because the manager tracks the *origin* of every key block; only a block born from a real failure of the pass (an entry that threw) still holds a purge back, as it did since 2.0, so the order between a failed `attached` and the purge of that same node is kept. The parked keys are read **in full on every pass, whatever `--limit` or `batchSize`** (🟠 2: a census bounded by `limit` let the 101st parked entry go unnoticed with `--limit 50` or the inline `{ only, limit: 1 }`, and the `attached` landed under the old parent); more than `PARKED_CENSUS_MAX` (50,000) parked entries is a 500 before anything is applied, never a blind plug. The price is declared: one irreparable parked entry freezes the *grants and moves* of its scope key until a human acts — bounded to the key, not the queue, and loud (`deferred` + `dead` + exit code ≠ 0).
147
+
148
+ **When the parked set grows.** Nothing in the package shrinks it for you, on purpose: `authz:reconcile` never touches the outbox (the counter does not go down), `authz:scopes:requeue` has no `--all` (re-queueing an irreparable entry parks it again in one pass, and re-queueing a stale one is the reversal the probe refuses), and past `PARKED_CENSUS_MAX` (50,000) every pass is a 500 before applying anything. The remedy is an operator's SQL on **your** table: list `authz_scope_outbox` rows with `attempts >= maxAttempts` and `applied_at IS NULL`, read `last_error`, and either `authz:scopes:requeue --id` the ones whose cause is gone (a parent that exists again) or **delete** the irreparable ones (a parent that will never exist) — then `authz:reconcile --to=<driver>` rebuilds the tree from today's rows. Deleting a parked entry is safe for the tree only because the reconcile recomputes it; do not delete and skip the reconcile.
149
+
150
+ **The root `app` is not a scope key, and a parked entry only plugs the key of its own child (2.4.0-alpha.8).** Two corrections to the key rule above, both measured in a consumer. *First*, until alpha.7 the keys of an `attached`/`moved` were `[child, parent]` and the parent of every tenant registration is `app`, so every tenant registration shared a key with every other one: one parked `attached(orgA, app)` left every later registration `deferred · parked:` on every pass (five passes with `applied = []`), two concurrent registrations relayed inline left the second one `deferred · depends-on:` **with nothing parked at all** (the root was a global serialiser of sign-ups, and under (c2r) a fail-closed one: the new organisation granted nothing until the cron ran), and `authz:scopes:requeue` refused the parked one with a false conflict as soon as another tenant had registered. The root **never moves, is never purged and is never a child** (`scopes.attached(APP_SCOPE, …)`, `scopes.moved(APP_SCOPE, …)` and `scopes.detached(APP_SCOPE)` are 422), so two changes whose *only* shared scope is `app` commute and there is no order to keep: the manager now leaves the root out of the keys — decided on the reference (`type === 'app'` and a null uuid), never on the string `'app'`, and in the three places that build the list (the pass, the `only` probe, the requeue probe), also for blocks born from a real failure. The child's key still orders everything that matters: a `detached(orgA)` that failed still holds back an `attached(orgA, app)` with the same uuid, and a failed `moved(unit, app)` still holds back a later `moved(unit, orgB)`. The port is untouched: `sqlScopeOutbox.earlierPending`/`laterApplied` still know how to match the root as a parent; the manager just never asks. *Second*, the parked census now seeds only the key of the parked entry's **child**: a parked entry says the position of *its child* in the store is wrong, nothing about its new parent, so a `moved(otherUnit → orgA)` behind a parked `moved(unit → orgA)` is applied (until alpha.7 it was `deferred · parked:` for no reason and the store kept the old parent of everything under `orgA` with no expiry), while `attached(child, unit)` and `moved(unit → orgC)` — which name the parked child — stay `deferred · parked:<id>` and a `detached(unit)` still passes. When comparing two changes the manager still uses both keys; only the seeding of the census changed. Both are a change of observable behaviour: an `attached`/`moved` that used to come out `deferred` under those conditions is now applied, which is the bug.
151
+
152
+ **Under `only` the purge exemption holds too (2.4.0-alpha.6).** With `relayScopeChanges({ only })` the probe that finds an earlier unapplied entry sharing a scope key with yours now **inherits the origin of the block** of that entry, the same way the full pass tracks it: if the earlier entry is — directly or through its own dependencies — waiting only on a **parked** one (it would be `parked:<id>` or `depends-on:` of one in a full pass), a requested `detached` is **applied and purges**, exactly as in the full pass (invariant 11: a purge never waits for a parked entry). If the earlier entry is an `attached`/`moved` that is genuinely pending (it would be applied by the next full pass) or is held by a **real failure** of the pass, the `detached` stays `deferred` (`depends-on:<id>`), because applying a purge ahead of a pending `moved` of the same scope leaves drift. Until alpha.6 the probe marked every such deferral as a real block, so a deployment that only ever relayed inline never purged behind a parked entry (audit 🟡 of alpha.5, measured). The probe walks back one `earlierPending` query per hop over entries it does not apply, at most `RELAY_ORIGIN_MAX_HOPS` (100) hops; past that it assumes a real block and defers — towards safe, never applying without knowing. Entries you did not request are not touched: they keep their attempts and their place.
153
+
154
+ **Requeueing a parked entry: `node ace authz:scopes:requeue --id=<id>` (2.4.0-alpha.5).** A parked entry is never retried on its own. When you have fixed the cause — the missing parent is back, the store was reconciled — `authorization.requeueScopeChanges([id])` (platform API, like the relay; the command is its ace face) returns it to `pending()` with its old id, and **`attempts` is set to `maxAttempts − 1`**, neither `0` nor kept (owner's decision): if the cause is still broken the entry is parked again after **one** pass — and it contaminates its scope key only during that pass, so an automated requeue of something irreparable is not a permanent deferral tap for that tenant's new changes —, and if you fixed it, it lands on the first pass. No new column. It is all or nothing: an id that does not exist, is still pending, or was already applied is a 500 naming it and **no row is touched** (an applied entry never comes back: requeueing an applied `detached` would purge again). **The anti-reversion probe lives in the manager and has no exception.** Before touching the queue it asks the port (`laterApplied`) whether an entry *applied after* the parked one shares a scope key with it; if one does, requeueing would re-apply the old change on top of the new — measured: a parked `moved(unit → orgB)` requeued after `moved(unit → orgC)` was applied leaves the store with the **old** parent and a single edge, so nothing denounces it while the old tenant regains access — and the call is **500 `E_AUTHZ_REQUEUE_REFUSED`** naming the later entry and the key, with the right remedy (`authz:reconcile --to=<driver>`, which recomputes with today's tree). **`--force` exists only in the ace command**: it prints the diff — the parked entry and every applied entry it would revert — and then requeues anyway; `requeueScopeChanges(ids, { force: true })` is what the command calls and nothing reachable over HTTP should. The probe is the manager's, not the table's, so a third-party `ScopeOutbox` gets it for free by implementing the two optional methods (`requeue` and `laterApplied`); without them the command says so (500 `E_AUTHZ_UNSUPPORTED`). Two limits of the probe, written: the diff lists at most 100 later entries per parked one and says when it is cut (`truncated`, "… y más"); and **`laterApplied` only sees the outbox** — `authz:reconcile` does not write to it, so a requeue *after* a reconcile can revert what the reconcile just certified and the probe has nothing to compare against: **do not requeue after reconciling**; if you already did, the remedy is to reconcile again.
155
+
156
+ **Closing the incident in one call: `requeueScopeChanges(ids, { apply: true })` / `node ace authz:scopes:requeue --id=<id> --apply` (2.4.0-alpha.8).** Measured in a consumer (panel relay 2, tester P14): after `requeue` the entry comes back with its *original* id — smaller than everything enqueued since — so the tenant's next inline `relayScopeChanges({ only })` found it *in front* and came out `deferred · depends-on:<id>`; the incident needed a full pass behind the command. With `apply: true` the entry is requeued **and relayed in the same call**, and the relay's report travels in `report.relay`. Four rules, each pinned by a case: **one lease** for the whole call, taken *first* — if another pass holds it the result is `relay.busy === true` with **zero rows requeued** and the command exits ≠ 0 (requeueing and then saying "busy" would leave the entry pending with `attempts = maxAttempts − 1`, to be parked again by the next pass and poison its key once more; `waitForLeaseMs` composes with it); it **passes the freeze barrier** before touching anything (with `apply` the command writes the tree; without `apply`, `requeue` still does not, it only touches the queue); it **does not compose with `force`** (500 `E_AUTHZ_CONFIG` — with both, reverting the tree over what was applied later would be a single call); and the internal `only` is **exactly the requeued ids**: what depended on the parked entry is *not* dragged along (it stays `deferred`/pending, the report says so — `remaining`, and the command prints it), because `only` is *your receipt* and nothing else. The anti-reversion probe still runs before anything is requeued (a refusal is 500 with zero driver calls, and the lease is released). If the cause is still broken, the same call parks the entry again (`attempts` goes from `maxAttempts − 1` to `maxAttempts`, `relay.failed` says why); a 503 from the store leaves it pending with `attempts = maxAttempts − 1`, as always. `--apply` is explicit, never the default: without it the command behaves exactly as before. Platform API, like the relay: never reachable over HTTP.
157
+
158
+ **The relay is a single writer.** `pending()` reserves nothing, so two passes at once (a Kubernetes `CronJob` with the default `concurrencyPolicy: Allow`, two replicas, a pass that lasts longer than its interval) work on the same batch: the straggler re-applies an old `attached` after the other applied the new `moved`, and the store is left with the **old parent and a single edge, so nothing denounces it** — the old tenant keeps access to a subtree that is no longer theirs (measured). `sqlScopeOutbox.acquire()` takes a lease for the pass — a server-side lock on PostgreSQL and MySQL, a process-wide one on SQLite — and a second simultaneous pass does nothing and says so (`busy`). Since 2.4.0-alpha.5 that is **observed between real processes** (`tests/scope_outbox_multiprocess.spec.ts`: a second process relays the same queue while the first holds the lease ⇒ `busy`, zero writes, on PostgreSQL and MySQL), and the SQLite limitation is pinned the same way — the second process *does* apply over the first: **SQLite is not the engine for two replicas of the relay**. If you implement the port yourself and skip `acquire`, run the relay one pass at a time.
159
+
160
+ **Waiting for the lease instead of giving up: `relayScopeChanges({ waitForLeaseMs })` / `--wait-lease-ms` (2.4.0-alpha.8).** By default (`waitForLeaseMs: 0`) a pass that finds the lease taken reports `busy` at once — one `acquire()` attempt, the behaviour above, unchanged. With a positive value the pass retries `acquire()` every `RELAY_LEASE_POLL_MS` (25 ms; measured 0.29 ms per busy poll in PostgreSQL, and a failed poll holds no connection between attempts) until the lease is free or the budget is spent, and then reports `busy` — bounded, never a hang. It is opt-in and it **needs a pool of at least 2** connections, like `{ transaction }`: with a pool of 1 the waiting pass may be holding the very connection the pass that has the lease needs. Do not size it to a full backlog: a pass over 2,000 entries with real backend writes takes tens of seconds; the option exists so that an operator's `authz:scopes:requeue --apply --wait-lease-ms` does not lose systematically against the cron, not to queue relays behind each other.
161
+
162
+ **How old is the queue? `report.queue` and `--max-age` (2.4.0-alpha.8).** Measured in a consumer (panel relay 2, auditor B2): with the store down, ten relay passes in a row reported `attempts = 0` on every entry (a 503 does not consume an attempt, since alpha.5), `dead = 0` and **no notion of time anywhere** — the cron was red because of `failures`, but nobody could tell since when, and nothing distinguished a queue that has been stuck for an hour from one that was enqueued a second ago. Every relay report now carries **`queue: { oldestPendingAt, pendingAgeMs }`**: the `createdAt` of the **oldest pending entry of the whole queue** — the `pending(1)` the pass already ran at the end for `remaining`, so it costs no extra query — and its age, `max(0, now − createdAt)`, measured with the manager's injectable clock (`config.clock`; no `Date.now()` in the package sources, the suite greps for it). It is **always global, also under `only`**, like `remaining` and `dead`: it names nobody, it only says "there is backlog". Both fields are `null` when nothing is *pending* (an empty queue, or only parked entries — those have their own alarm, `deadCount`, below) and when the port does not provide `createdAt`: a third-party `ScopeOutbox` whose entries carry no `createdAt` reads as `null`, **never as `0` or "now"** (the same rule as `stillGranting`: "I do not know" does not degrade to a number). `sqlScopeOutbox` returns it from the `created_at` column it already had (`createdAt?: Date` is an optional field of `PendingScopeTreeChange`; the outbox contract has a `createdAt` capability pair). Read the number for what it is: **the age is the clock of whoever enqueued** — another replica with a skewed clock stamps its own `created_at`, so the age is clamped at zero and never negative, and it is not a decision the package takes (audit stamps are not decisions). The command prints it on every pass; **`--max-age=<ms>`** makes the pass exit ≠ 0 when the age is above the threshold — **only with a threshold**: without it the age is reported and never fails the run, because a cron that goes red for ever over a queue that simply has backlog is a cron nobody reads. Parking by age (`parkAfterMs`) is *not* offered, on purpose — see the changelog: it reintroduces, with another clock, the mass parking alpha.5 removed, and it was measured to make every pass a 500 once the census fills.
163
+
164
+ **Enqueueing outside your transaction: `sqlScopeOutbox({ requireTransaction: true })` (2.4.0-alpha.8).** The mitigation above is *entirely* the `{ transaction: trx }` you hand to `scopes.attached/moved/detached`. Without it the call still enqueues — the row commits on its own, outside your unit of work — and if your transaction rolls back afterwards **the change stays in the queue and the relay applies it to the store**: your database says one parent, the PDP another, and nothing on your side can show it (the S5 leak, on the *correct-use* path; measured by the tester of panel relay 2, P13). With `requireTransaction: true` that call is **500 `E_AUTHZ_CONFIG` before the `INSERT`**, naming exactly that leak, through the manager and through the outbox alone; with an open transaction of the outbox's connection it enqueues as always. The default is **`false` in 2.4** — the published outbox contract (`runScopeOutboxContract`) enqueues without a transaction in its `ctx()`, and a `true` default would turn every third-party outbox red — and **the default becomes `true` in 2.5** (owner's decision), together with the contract passing the transaction in `ctx()` (a prerequisite of that release; without it the default cannot change). Turn it on today: it is the letter of the mitigation, and the only thing you lose is the ability to enqueue by mistake. A transaction that is **already committed or rolled back** is a different 500, below.
165
+
166
+ **What the report says about the parked set — `deadCount`, `deadDetail`, and the `origin` of a deferral (2.4.0-alpha.8).** Three small corrections to the report, each measured in a consumer. **`deadCount`** is always present and always global: `dead[]` is capped at `batchSize` (100), so `dead.length` *lies* once more than that is parked; the alarm "the queue does not converge" now rests on the count, which comes from the census the pass already reads for the key plug (no extra query). **Under `only`, `dead[]` carries `id`, `change` and `attempts` and — by default — no `error`**: the inline recipe runs inside a tenant's request, and the error text is what the backend said about *another* tenant's tree; pass `deadDetail: 'full'` to get it back (without `only` nothing changes, and the entries you *asked for* keep their `reason` in `entries`, they are yours). Read this as the safe-fields rule for a report you might be tempted to surface: `deadCount` and the ids say "there is a parked backlog" and nothing else; `change` still names scopes of other tenants (the actor never travels in `dead[]`), so the rule above stands — do not return the report over HTTP. And every `deferred` entry now carries **`origin`**: `'parked'` (held, directly or transitively, by a parked entry — the only origin that lets a `detached` through, invariant 11), `'failed'` (held by a real failure of this pass), or `'pending'` (under `only`: held by an earlier entry that is genuinely pending and was not requested — it will land on the next full pass). Until alpha.8 `depends-on:<id>` did not distinguish the last two and the origin lived only in the Spanish prose of `deferred[].error`; it is derived from the block itself, never parsed back from the text of `reason`.
132
167
 
133
168
  **What the outbox does not fix, in plain words.** Between your commit and the relay pass there is a lag of **seconds during which FGA decides with the old tree**. What that costs you depends on the change, and the two directions have **opposite signs** (2.0, the `rooted` relation of the `facts` model — see [What a scope that is not attached grants](#what-a-scope-that-is-not-attached-grants)):
134
169
 
@@ -150,6 +185,72 @@ Two consequences you have to plan for:
150
185
  - **Notify `attached` for every node.** A consumer that materialises paths and only notifies some of its nodes used to get *more* than it asked for; now it gets less. Diagnose it with `authz:reconcile --dry-run`, which lists the scopes that are not reachable from `app`.
151
186
  - **Publishing the model is not enough: the store needs its root marker.** The reachability of the root is anchored by one tuple per holder type (`scope:app#rooted@<holder>:*`) — **zero per scope**, so the outbox and the relay carry nothing new. `syncAuthzCatalog` writes it (idempotently, and that is also how a holder type added to your config gets one), and `authz:reconcile` reports it as drift if it is missing. **Without it the whole store denies** — fail-closed and loud on the first question, but total; it is the same class of accident as "the model was never published".
152
187
 
188
+ ### Writing inside your transaction (2.4.0-alpha.2)
189
+
190
+ `{ transaction: trx }` — an open Lucid transaction of yours — means **three different things** depending on the call, and the difference is the whole story of this section:
191
+
192
+ | Call | What `{ transaction }` does | `database` | `openfga` |
193
+ |---|---|---|---|
194
+ | `grant` / `revoke` / `deny` / `removeDeny` | **writes the fact inside your transaction** — commit and the fact is there with your rows, roll back and neither is | yes (`transactionalWrites: true`, lot L-3) | **500 `E_AUTHZ_UNSUPPORTED`**, nothing called (lot L-5) — see [the recipe by direction](#openfga-the-recipe-by-direction) |
195
+ | `relate` / `unrelate` / `purgeObject` / `purgeSubject` | **writes the tuple inside your transaction** | yes (lot L-4) | the same 500 |
196
+ | `scopes.attached` / `moved` / `detached` | **enqueues** the tree change in the outbox, inside your transaction (`scopes.outbox`, see [the tree outbox](#the-tree-outbox-and-the-relay-lag-you-are-accepting)). Enqueueing ≠ writing: this never passes the capability gate | both | both |
197
+ | `defineScopedRole` / `updateScopedRole` / `deleteScopedRole` | **refused**, 500 `E_AUTHZ_UNSUPPORTED`: the catalog is written through `withAuthzCatalogWrite`, the cross-process serializer (invariant 14), which your transaction would defeat | — | — |
198
+
199
+ The capability behind the first two rows is **`transactionalWrites`** (same name on both ports), and it means **exactly** *both or neither with your transaction* — never *"not lost"*; there is no intermediate value. `database` declares `true`; `openfga` declares `false` and cannot declare anything else: a tuple lives in another service and there is no two-phase commit between it and your database. `requireTransactionalWrites: true` in the config (root, or `relations.requireTransactionalWrites`) turns a wrong deployment into one that **does not start** — 500 `E_AUTHZ_CONFIG` when the driver is resolved, reads included — instead of a 500 on a rarely travelled route. It is the first capability of this package that is **not portable between drivers**: the same call is 200 on `database` and 500 on `openfga`, and the suite pins that asymmetry on purpose (each driver declares what it can do; you read the capability and adjust).
200
+
201
+ #### The rule: the write goes through your transaction, the authority never does
202
+
203
+ **A transaction that is already committed or rolled back is refused before any statement (2.4.0-alpha.8).** Lucid keeps `isTransaction === true` on a finished transaction, so until alpha.7 `assertCallerTransaction` let it through and the statement blew up inside knex as **503 `E_AUTHZ_BACKEND_UNAVAILABLE`** ("the backend did not answer") — which is false: it is the caller's wiring (a `trx` reused after `await trx.commit()`). It is now the fourth check of the shared guard (`isCompleted === true` ⇒ **500 `E_AUTHZ_CONFIG`** naming the operation, "confirmada o revertida", zero statements), in the three places that take `{ transaction }`: `sqlScopeOutbox.enqueue`, the `database` roles driver and the `database` relations driver — pinned in the four engines.
204
+
205
+ **Through your transaction: the write, and only the write** — the `INSERT`/`UPDATE`/`DELETE`, plus the "does it already exist?" read that belongs to it (a `grant` and its re-grant, a `relate` and its renewal, inside the same transaction see each other). **Through the engine's own connection, always: the authority** — the freeze barrier (row `id = 2` of `authz_catalog_version`), the catalog memo, `resolveChain`, F-05 and `assertWrite`. They are decided **before the first statement on your transaction** (the suite spies on the transaction and counts zero), and a transaction opened *before* a freeze does not get to decide it is not frozen (its snapshot would say so under InnoDB's REPEATABLE READ, PostgreSQL at that level and SQLite in WAL — the suite reproduces the bypass on the three engines and pins the 503). Until 2.4.0-alpha.2 the barrier *was* read through the caller's transaction "so a pool of 1 would not deadlock"; that traded authority for availability and it is gone.
206
+
207
+ Hence the deployment rule, said out loud: **`{ transaction }` requires a pool of at least 2.** With a pool of 1 (SQLite `:memory:`, the suite's default mode) the authority cannot get a connection while you hold the only one, and the write is **503 `E_AUTHZ_BACKEND_TIMEOUT` at `freezeTimeoutMs`** (config, default 5000 ms — the barrier's own total deadline, pool wait included; knex's own `acquireConnectionTimeout` would have made it 60 s) with **zero statements on your transaction**: fail-closed, never a hang, never a bypass. A pool-of-1 deployment declares it — `new DatabaseAuthorizationDriver({ transactionalWrites: false })`, `new DatabaseRelationsDriver(config, { transactionalWrites: false })` — and gets gate 1's immediate 500 instead; the driver declares what its deployment can do, the package does not guess the pool. This also applies to `scopes.*` with `{ transaction }` (the outbox): a single-connection deployment that used it has to raise the pool (breaking of deployment, lot L-1). While the engine is **frozen**, `{ transaction }` is the retryable 503 `E_AUTHZ_FROZEN` before the first statement on your transaction.
208
+
209
+ The transaction must be **yours and the driver's**: an open Lucid transaction (`isTransaction === true`) of the driver's connection (the primary one for facts; `connection` or the primary one for relations and the outbox). Another connection, a `QueryClient` (`db.connection()`) or the whole `db` service are 500 `E_AUTHZ_CONFIG` naming both connections, before any statement (`assertCallerTransaction`, exported: the same check for every `{ transaction }` of the package — until lot L-1 a foreign transaction silently enqueued in *its* database and `db` passed the duck-check and wrote outside any transaction). "Both or neither" is only true on the same connection.
210
+
211
+ What the published suite pins, **by census and not by the answer**: after a rollback `authz_assignments`/`authz_denies` hold zero rows for that holder and `authz_relations` zero new tuples in that partition, for the four writes of each port, on SQLite (file), PostgreSQL and MySQL; `purgeObject`/`purgeSubject` delete and revert *together*; while the transaction is open the authority (reading through the engine's connection) does not see the write; on commit census and answer change together (`runAuthorizationDriverContract` / `runRelationsDriverContract`, the `true` face of the pair). `onWrite`/`onRelationWrite` fire when the driver returns, with **`transactional: true`** on the event: at that instant the row exists only inside your transaction and it is a fact if and only if you commit — the package never sees that; a sink that wants the last word hangs itself on `trx.after('commit')`.
212
+
213
+ #### What the engines do when the write lives as long as your transaction (measured)
214
+
215
+ Outside a transaction the driver absorbs a clash: it re-reads and keeps the winner's row (2.5-B · K4). Inside yours it must not — PostgreSQL has already aborted your transaction, and under REPEATABLE READ the re-read would not see the winner (a mutant that re-reads fails differently on each engine) — so **a clash poisons your transaction**: the driver never issues a second statement on a transaction that may be aborted, the choice between roll back and retry is yours, and `onWrite` publishes nothing for the loser (a clash is not a deadline). Measured on the three engines, facts and tuples alike:
216
+
217
+ | Situation | PostgreSQL | MySQL | SQLite (file, WAL) |
218
+ |---|---|---|---|
219
+ | **Two open transactions write the same fact/tuple** (unique index) | the second `INSERT` waits until the first ends; on its commit, `23505` ⇒ **409 `E_AUTHZ_WRITE_CONFLICT`** ("roll back and retry"); your transaction is **aborted** (`25P02` on every later statement until you roll back) | waits (shared lock on the duplicate); `1062` ⇒ **409 `E_AUTHZ_WRITE_CONFLICT`**; your transaction **lives on**, only the statement was undone | does not wait: a reader cannot become the writer while another holds the reserved lock ⇒ `SQLITE_BUSY` at once, *before* the first commits ⇒ 503 `E_AUTHZ_BACKEND_UNAVAILABLE`; alive |
220
+ | **The driver's `timeoutMs` elapses while your statement waits** | knex cancels the query (`pg_cancel_backend`) ⇒ 503 `E_AUTHZ_BACKEND_TIMEOUT`; **aborted** | knex kills it (`KILL QUERY`) ⇒ 503 `E_AUTHZ_BACKEND_TIMEOUT`; lives on | the synchronous driver cannot elapse a deadline: `SQLITE_BUSY` above |
221
+ | **Two transactions write two tuples in crossed order** (A→B / B→A) | detected after `deadlock_timeout` (1 s by default); the victim gets **409 `E_AUTHZ_WRITE_CONFLICT`** (`40P01`) and stays **aborted** until its rollback; the winner waits for that rollback | InnoDB detects it at once; the victim gets **409** (`1213`) and InnoDB **rolls back its whole transaction** — its earlier rows are gone too; the winner may resolve first | cannot deadlock: the second writer is `SQLITE_BUSY` (503) at its first write |
222
+ | **A cross-partition userset row your own code inserts on that transaction** (the partition trigger fires inside it, lot L-4) | refused by the trigger; your transaction is **aborted** and the next `relate` of the package on it is a classified 503 (`25P02`), never the raw error | refused; lives on | refused; lives on |
223
+ | **A transaction that read before another writer committed** (a freeze put and lifted after your first `SELECT`, say) | — | — | cannot write afterwards: `SQLITE_BUSY` snapshot ⇒ 503, the engine's rule |
224
+
225
+ Two more things that follow from "as long as your transaction": for the 503 of an elapsed deadline `onWrite` publishes **`indeterminate: true` *and* `transactional: true`** — your rollback does determine the outcome, but the rollback is yours and the package never sees it (invariant 13 stands); and **renewing a relation expiry (delete+insert, R-15) rolls back to the previous expiry** — the census after the rollback holds the old row with its old `expires_at` and its old uuid.
226
+
227
+ #### The decision is not in your transaction: `resolveChain`
228
+
229
+ The *row* is inside your transaction; the *decision* that authorises writing it is not. `resolveChain` is your function and it reads your tree through your `db` — the engine's connection, not your transaction — so **the tree is judged against the state before your transaction** (`within` too): creating a scope and granting on it inside the same transaction is 422 `E_AUTHZ_UNKNOWN_SCOPE`, fail-closed, and that is the correct answer, pinned. It is the one limit of this port, and it is not fixed by handing the driver your transaction (that would put the authority back inside the caller's snapshot). Two recipes:
230
+
231
+ - **To grant on a scope you create in the same transaction**, use a `ScopeChainResolver` that closes over your transaction — `scopes.resolveChain` is a plain callback of yours and nothing stops it from reading your tree through `trx` (no case of this package exercises that form: it is your code). You are then telling the authority to trust your uncommitted tree — your call, said out loud. Otherwise commit the scope first and grant after.
232
+ - **If you move the tree *and* write facts in the same transaction, notify `authorization.scopes.*` first**, then write the facts. The notification validates the move against the tree as it stands (anti-cycles, existence), and with the outbox it **enqueues** in your transaction, so the tree change and your rows commit or vanish together; the facts you write after it are still judged against the pre-transaction tree, which is the fail-closed side.
233
+
234
+ #### `openfga`: the recipe by direction
235
+
236
+ `openfga` cannot put a tuple in your transaction and says so — 500 `E_AUTHZ_UNSUPPORTED` on every write that carries `{ transaction }`, through the manager **and through the driver itself** (`manager.driver()` re-validates: the message names the driver and the operation, nothing was called; the way out is the `database` driver or `requireTransactionalWrites`, and the driver's own letter adds that entering through `manager.driver()` is not a way out — for the **tree** the way out that exists is `scopes.outbox`; **for facts and relations there is no outbox**, on purpose: an outbox of facts was measured as fail-open during its lag and discarded by the panel, the numbers are in the changelog). What you *can* do with `openfga` is order your writes by **direction**, so that whichever of the two fails, the failure lands on the closed side:
237
+
238
+ | Direction | With `openfga` | If your transaction rolls back |
239
+ |---|---|---|
240
+ | **Permissive** — `grant`, `relate`, `removeDeny` | write **after the commit** (`trx.after('commit', …)`, or after `db.transaction()` returns) | nothing was granted ⇒ **fail-closed** |
241
+ | **Restrictive** — `revoke`, `deny`, `unrelate` | write **before, or inside, in line** (no `{ transaction }`) | something was revoked that your SQL still grants ⇒ **fail-closed**, and repairable (grant again) |
242
+
243
+ **This is not "both or neither"**: it is a write *order* that makes the failure fall on the closed side, and its limits are measured against a real server:
244
+
245
+ - **Its limit: it does not compose.** A `grant` and a `revoke` of the *same* fact in the same transaction are applied in inverted order — the revoke in line, on a fact that does not exist yet (a safe no-op), the grant after the commit — and **end up granting**. If your unit of work both grants and revokes the same fact, the recipe is not for it.
246
+ - **The package does not automate it.** `grant` with `{ transaction }` on `openfga` is the 500 above: the driver does not hook `trx.after('commit')` to write later, does not write in line "pretending", and nothing lands when you commit (pinned: zero hooks registered on the transaction, zero client calls, `onWrite` empty). The order is yours to write.
247
+ - **Lucid swallows what an `after('commit')` handler throws** (`TransactionClient.commit()` runs the hooks and catches). A `grant` that fails there — 503, 422 — fails in silence: still fail-closed, but unreported. Await the write after `db.transaction()` returns, or catch inside the handler and report it yourself.
248
+ - **"Inside" needs a pool of at least 2 even with `openfga`.** The write goes to the store, but the freeze barrier before it is read through the engine's SQL connection: a `revoke` in line *inside* your open transaction on a pool of 1 is the 503 of the barrier at `freezeTimeoutMs`, not a revoke. On a pool of 1 the restrictive write goes **before** you open the transaction.
249
+
250
+ #### Third-party drivers, and what the suite can and cannot prove
251
+
252
+ "Both or neither" is **a guarantee of this package's `database` driver**, measured on its own schema on three engines. For a driver of yours declaring `transactionalWrites: true`, the runner requires the census (the `transactions` hook: how to open a transaction of *your* connection and how to count *your* rows) and judges rollback ⇒ zero, commit ⇒ applied, the uncommitted write invisible to the authority and three foreign transactions ⇒ 500 with zero statements — but it **cannot prove that a third-party driver's write really lives and dies with the transaction it is handed**: the census it counts is the one your hook reports, and a driver that opens its own transaction underneath passes the answer checks. Two more honest boundaries: the `true` face only runs where a second connection exists (`sqlite-file`, PostgreSQL, MySQL — the engine jobs of CI are not optional for this capability), so a driver judged only on `:memory:` never sees those cases; and the 409/503 classification of the table above is the `database` driver's — a third-party driver that returns the raw engine error is not caught by the suite.
253
+
153
254
  ### Identity is validated, once and everywhere
154
255
 
155
256
  `SubjectRef.type`/`uuid`, `ScopeRef.type`/`uuid`, role and permission slugs and `expiresAt` are checked by the manager on every call and again by each driver (the contract suite and third-party drivers bypass the manager). Lowercase letters, digits, `.`, `_`, `-` — **types and uuids alike**: types since 2.0 (a `*_ci` MySQL collation would merge `Users` and `users` into one row while FGA keeps them apart), uuids since 2.1 (the tree of a consumer merges `BBBB…` with `bbbb…` on PostgreSQL's `uuid` type and on MySQL's default collation, and the alias evaded a deny — *"la identidad es una cadena validada por la gramática … un uuid con MAYÚSCULAS … es 422"*; lower-case your ids at your edge: a UUID is the same id in any case); permissions may carry one `:` (`resource:action`); slugs are lowercase and at most **42** characters; `parent`, `binding`, `ancestor`, `rooted`, `role`, `assignee`, `denied` and the prefixes `can_`, `denied_`, `permits_` are reserved; `{ type: 'app', uuid: X }` and the root sentinel uuid outside `app` are rejected — even when your tree knows that sentinel (*"uuid centinela en un scope que el árbol SÍ conoce ⇒ 422"*); `grant`, `revoke` and `listSubjects` take a slug, and a `{ slug, scopeType }` object there is 422 (*"un RoleQuery objeto donde el contrato pide un slug ⇒ 422"*); `expiresAt` is `undefined`, `null` or a valid `Date` (*"expiresAt que no es Date válida, null ni omitido ⇒ 422"*). Violations are **422** (`E_AUTHZ_INVALID_IDENTITY`, `E_AUTHZ_INVALID_SLUG`) before any catalog, tree or backend call — zero queries, spied (*"identidad inválida ⇒ 422"*, *"slug mal formado o reservado ⇒ 422"*, *"una identidad inválida se rechaza con 0 llamadas al backend"*). `assertIdentity`, `assertValidSlug` and `assertExpiresAt` are exported so you can validate at your own edge with the same rule.
@@ -176,6 +277,8 @@ A seeder or an onboarding that calls `grant` "to make sure they have the role" n
176
277
 
177
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.
178
279
 
280
+ **Inside your transaction (2.4.0-alpha.2, lot L-3) the event also carries `transactional: true`**, and a deadline that elapses there is *still* `indeterminate: true`. The `{trx}` panel's auditor objected that inside a transaction "the rollback determines it" — it does, but the rollback is *yours* and the package never sees it: when the 503 reaches you the statement may or may not have landed inside your transaction (SQLite cannot cancel; MySQL kills the statement and your transaction lives on; PostgreSQL leaves your transaction aborted), and whether you commit is yours. `transactional: true` is on **every** event of a write inscribed in your transaction (`granted`/`extended`/`revoked`/`denied`/`deny_removed`): at that instant the row exists only inside your transaction and is a fact if and only if you commit. A sink that wants the last word hooks `trx.after('commit', …)`. Enqueueing through `scopes.*` is not writing and does not carry it.
281
+
179
282
  `grant` always returns a `GrantOutcome`; a third-party driver that still returns `void` is normalized to `{ existed: false, expiresAt: options?.expiresAt ?? null }` (*"un driver de terceros cuyo grant no devuelve GrantOutcome sigue notificando granted"*). `revoke`/`removeDeny` require the role/permission to exist in the catalog for that level (422, like `grant`/`deny`); the safe no-op is for a *missing assignment* of a valid role (*"revoke/removeDeny con rol o permiso fuera del catálogo ⇒ 422"*, *"revoke/removeDeny inexistentes son no-ops seguros"*).
180
283
 
181
284
  ## Queries
@@ -214,7 +317,7 @@ await authorization.isWithin(unit, currentOrg) // the same question on its own
214
317
 
215
318
  **Actor.** Every write (`grant`, `revoke`, `deny`, `removeDeny`, `scopes.*`) takes `{ actor }` — a `SubjectRef`, validated like any identity — which `onWrite` receives as `event.actor`. `requireActor: true` ⇒ a write without it is 422 `E_AUTHZ_ACTOR_REQUIRED` before the driver and before the hook. No `AsyncLocalStorage`: the actor is an explicit argument. For these six writes the engine never evaluates it (who may grant what is your policy) — **but the delegation API of 2.2 does**: there the `actor` is the whole policy, so it must come from the session and never from the request body. See [Scoped roles](#scoped-roles-22).
216
319
 
217
- **`manager.driver()` is the documented way out of all of that.** It returns the active driver as-is: writes through it skip `actor`/`requireActor`, `within`/`requireWithin` **and `onWrite`**; reads skip the per-request memo. It exists for platform code (seeders, commands, writing at the root under `'non-root'`) and for tests — a tenant call-site should never call it, and a code review can grep for it. Nothing else is offered through it on purpose.
320
+ **`manager.driver()` is the documented way out of all of that.** It returns the active driver as-is: writes through it skip `actor`/`requireActor`, `within`/`requireWithin` **and `onWrite`**; reads skip the per-request memo. It exists for platform code (seeders, commands, writing at the root under `'non-root'`) and for tests — a tenant call-site should never call it, and a code review can grep for it. Nothing else is offered through it on purpose. **Grep for `relationsDriverFactory` and `relationsDriverKeys` too** (2.4.0-alpha.10): `manager.relationsDriverFactory(key)` returns the factory registered under `relations.drivers[key]` and `manager.relationsDriverKeys()` lists the keys — platform API next to `driver()`, used by `runRelationsReconcile` to resolve source and destination from the manager's own config. With the factory, any code holding the manager (a `forRequest()` view included) can build a relations driver that is **not** the active one and write to it with no `assertWrite`, no `requireActor`, no freeze and no `onRelationWrite`. It adds no real capability — `app.config` already hands out the factories — but it is one more thing to look for in a review. And `runRelationsReconcile(authorization, …)` takes the manager **structurally** (`Pick<AuthorizationManager, 'withFrozenWrites' | 'relationsDriverKey' | 'relationsDriverFactory'>`): an object that fakes `relationsDriverKey` or `relationsDriverFactory` decides which driver is active and which factories are compared, so pass it the service manager, never a hand-built or wrapped one.
218
321
 
219
322
  **Decisions in bulk.** `authorizeMany(subject, permission, scopes)` → `boolean[]` by position, identical to N `authorize` (duplicates, unknown scopes, denies); empty ⇒ `[]` without touching anything; a position that cannot be answered rejects the whole call. `openfga` answers with one `batchCheck` for all chains (a repeated scope shares one slot); `database` composes N `authorize` over a memoised view (one tree call per distinct scope). A third-party driver's `authorizeMany` is validated: a result that is not a `boolean[]` with exactly one position per scope is 500 `E_AUTHZ_INTERNAL` naming the driver (*"authorizeMany valida la respuesta de un driver de terceros"*).
220
323
 
@@ -255,6 +358,7 @@ scopes: {
255
358
  descendantsOf: sqlDescendantsOf({ table: 'org_nodes', uuidColumn: 'uuid', parentColumn: 'parent_uuid', typeColumn: 'kind' }),
256
359
  maxScopes: 1000, // answer bound of authorizedScopes
257
360
  maxDescendants: 10000, // maxNodes handed to descendantsOf
361
+ maxDepth: 22, // optional (2.4.0-alpha.5): refuse attached/moved past this many parent hops from app — see Limits
258
362
  }
259
363
  ```
260
364
 
@@ -427,9 +531,32 @@ const relations = defineRelationsConfig({
427
531
 
428
532
  await rel.relate(user, 'viewer', { type: 'document', id }, tenant) // share with a user
429
533
  await rel.relate({ object: team, relation: 'member' }, 'editor', doc, tenant) // share with a TEAM (userset)
534
+ await rel.relate(guest, 'viewer', doc, tenant, { expiresAt: new Date(Date.now() + 7 * 86_400_000) }) // time-boxed share
430
535
  await rel.check(user, 'viewer', doc, tenant) // one Check; editor⊆viewer resolves server-side
431
536
  ```
432
537
 
538
+ **Relation expiry (2.4.0-alpha.2, R-15).** A relation tuple may carry an `expiresAt`, with the
539
+ **same three states as `grant`** (invariant 10): omitted preserves a live expiry (an expired one
540
+ revives without expiry — it is a new share), `null` removes it, a `Date` sets it. Expiry is
541
+ **strict** — a share that expires *now* no longer grants — and is honoured by `check`,
542
+ `listObjects`, `listSubjects` and `membersOf` in both drivers, including a membership that expires
543
+ (`relate(u, 'member', group, tenant, { expiresAt })` stops granting through the userset at that
544
+ instant). Enforced in SQL by `database` (`expires_at > now`, the same `DATETIME(3)` column and codec
545
+ as assignments) and by the `not_expired` condition on every relation subject in the fused model for
546
+ `openfga` (`current_time` travels in every `Check`/`ListObjects`). The `RelationsManager` takes the
547
+ same `clock` as the roles manager (the provider passes `config.clock`), and both drivers implement
548
+ `withClock`. In `database`, **renewing an expiry is delete+insert, never an `UPDATE`** — the table
549
+ stays insert/delete-only, and the case that observes it is in the suite (the row changes its uuid).
550
+ A bad `expiresAt` (not a valid `Date`/`null`/omitted) is 422 `E_AUTHZ_INVALID_IDENTITY` before the
551
+ driver. `enumerateRelations` does **not** filter expired tuples: they reach `authz:relations:reconcile`
552
+ with their `expiresAt` and are counted in `skipped.expired`, never silently dropped. And a *live*
553
+ tuple whose object type or relation the **destination** does not declare is **not written** and is
554
+ counted in `skipped.undeclared` (L-0): with the destination config at hand the pass discards it
555
+ before the driver (so `--dry-run` and the real pass report the same numbers, and the type also shows
556
+ in `modelDrift`); without it, the destination driver's own F-05 422 funnels it and it is counted the
557
+ same. Unlike `expired`, that one is a live relation that did not arrive, so the command exits ≠ 0:
558
+ declare the type in the destination, republish its model, and run the pass again.
559
+
433
560
  `group` is a **built-in** object type (the userset carrier: `group#member`, nesting allowed), so
434
561
  teams work without declaring anything. Every operation takes a **`partition: ScopeRef`** — the
435
562
  tenant — and it is **mandatory**: a relation in tenant A never resolves in tenant B (`APP_SCOPE` is
@@ -439,9 +566,14 @@ in the model.
439
566
  **The model is shared with the catalog, and so is its byte budget.** In the `openfga` driver,
440
567
  relations fuse into the same `facts` model and the same store, so a single `Check` still answers
441
568
  each question. The price is one budget: the 262,144-byte model holds **both** your permissions and
442
- your object types. Measured: with realistic permission names the ceiling is **~450 permissions**,
443
- and each object type costs about **0.46 of a permission** (`group` ≈ 0.1). So a catalog near the
444
- ceiling has room for ~20 object types, not unlimited; a small catalog has room to spare. The gate
569
+ your object types. Measured (3 holder types, realistic permission names): the ceiling is **~450–470
570
+ permissions**, and since relation expiry (2.4.0-alpha.2) a three-relation object type costs about
571
+ **1.0 of a permission** (≈ 579 B vs ≈ 557 B; before the `not_expired` condition on relation subjects
572
+ it was ≈ 0.5) and `group` ≈ 0.34 (191 B; was 86 B). The condition adds `(holders + 1) × (type name +
573
+ "not_expired")` bytes per declared relation — ≈ 103 B per relation with three holders. So a catalog
574
+ of 447 realistic permissions has room for **24** three-relation object types (was 52), and one object
575
+ type costs the permission ceiling a single permission (472 → 471); a small catalog has room to
576
+ spare. The gate
445
577
  watches the **fused** model — `defineRelationsConfig` that would push it over is 500
446
578
  `E_AUTHZ_MODEL_TOO_LARGE` before anything is published (80 % warns), the same protection
447
579
  `syncAuthzCatalog` already gives the permissions. A consumer that needs *many* object types **and**
@@ -457,8 +589,43 @@ declared (422 `E_AUTHZ_RELATION_TYPE_UNKNOWN` / `E_AUTHZ_RELATION_UNKNOWN`) **be
457
589
  driver** — so the id of a `role_binding` is never composed by the relations driver, the collision
458
590
  does not exist rather than being watched. This is F-05, and it is a **chokepoint**: every write path
459
591
  funnels through it, and the published contract plants the exploit so a third-party relations driver
460
- that does not enforce it **does not pass**. Because it lives in the manager, calling
461
- `manager.driver()` (the platform escape hatch) skips it — as with every other barrier.
592
+ that does not enforce it **does not pass**. **It lives in the manager AND in both drivers** (L-0,
593
+ 2.4.0-alpha.2): `relate`/`unrelate` of `database` and `openfga` apply the very same function
594
+ (`assertRelationDeclared` — same class, same `code`, same message) **before touching the backend**
595
+ (zero `Write`, zero `INSERT`; the suite spies on the client and on the connection). Until L-0 the
596
+ port's docblock claimed the drivers re-validated and it was false in both: `manager.driver()` (the
597
+ platform escape hatch) and `authz:relations:reconcile` — which writes through the destination
598
+ *driver* — went straight past F-05, and in the shared store `driver.relate(evil, 'assignee',
599
+ { type: 'role_binding', id: <roleUuid> }, S)` turned `roles.authorize(evil)` from `false` to `true`
600
+ (measured against a real server; the bridge spec keeps that case, and removing the driver guard puts
601
+ it back in red). `manager.driver()` still skips the manager's *other* barriers (`actor`,
602
+ `assertWrite`, `onRelationWrite`), as documented — F-05 is no longer one of them.
603
+
604
+ **And since `2.4.0-alpha.3` F-05 covers **all eight operations of the port**, not only the two writes that
605
+ compose a tuple.** The alpha.3 audit found the chokepoint one call-site short (pre-existing in alpha.2, in
606
+ code alpha.3 had just rewritten): `purgeObject`/`purgeSubject`/`check`/`listObjects`/`listSubjects`/`membersOf`
607
+ never called `assertRelationDeclared`, so in the shared store `purgeObject({ type: 'role_binding', id: R }, S)`
608
+ composed — byte for byte — the id of a real binding and **deleted the real binding** with proof of zero
609
+ (`authorize` went from `true` to `false`, zero tuples left, and a clean `purgeObject` audit event), and
610
+ `listSubjects('assignee', role_binding)` enumerated the holders assigned to a role in a scope through the
611
+ relations port, with no catalog, no `within`, no `requireActor` and no role visibility. Now the same
612
+ check runs first on the eight, in the manager **and** in both drivers (`manager.driver()` is not an
613
+ exit, same pattern as L-0 and L-5) — and it is **two functions, not an optional**: `assertRelationDeclared`
614
+ is **strict** (object type *and* relation, and the relation is mandatory) for `relate`/`unrelate`/`check`/
615
+ `listObjects`/`listSubjects`/`membersOf` and for the userset of any subject, while `assertObjectTypeDeclared`
616
+ (type only) serves `purgeObject`, which names no relation (`purgeSubject` validates the userset of its
617
+ subject). The first closing lot had made `relation` optional on one shared function, and the auditor's second
618
+ pass showed what that opened: `relate(u, undefined, doc, p)` — `request.input('relation')` missing — went past
619
+ F-05 (it wrote on the doubles, came back as a 503 from the OpenFGA server, and was saved only by the
620
+ `database` driver's own grammar), and `listSubjects(undefined, doc, p)` returned the **union** of every
621
+ relation's holders. So a missing `relation` (`undefined`) is 422 `E_AUTHZ_RELATION_UNKNOWN` on the six that
622
+ name one, on both drivers, with zero calls — 422 `E_AUTHZ_RELATION_TYPE_UNKNOWN` / `E_AUTHZ_RELATION_UNKNOWN`,
623
+ **zero client calls, zero events**, everywhere. The exploit is pinned against the shared store on the real
624
+ server (*"por el MANAGER: purgeObject(role_binding:R) ⇒ 422 … authorize(U, read, S) SIGUE true"*, *"por el
625
+ DRIVER en directo"*, *"cierre-2 · relate/unrelate/check/listSubjects/listObjects con relation: undefined"*),
626
+ and the published runner plants it on the six for any third-party driver (*"F-05 ·
627
+ purgeObject/purgeSubject(userset)/check/listObjects/listSubjects/membersOf"*). `membersOf` runs F-05 **before**
628
+ its capability check, so a `role_binding` there is 422 even where `membersOf` itself would be 500.
462
629
 
463
630
  **`membersOf` is `database`-only.** `membersOf(group, 'member', partition)` returns the **transitive**
464
631
  membership (through nested groups). Only the `database` driver has it (a recursive CTE); in `openfga`
@@ -479,15 +646,117 @@ contention that will not yield is 409 `E_AUTHZ_WRITE_CONFLICT`, never a half mod
479
646
  analog of `authz:reconcile`, idempotent, bidirectional and never silent (it reports written / deleted
480
647
  / unchanged / extra). `--to`/`--from` are keys of `relations.drivers` in `config/authorization.ts`;
481
648
  `--dry-run` is the read-only verifier and also flags **model drift** (an object type in the source the
482
- destination does not declare); `--prune` deletes what the source no longer backs. It migrates **facts
649
+ destination does not declare); `--prune` deletes what the source no longer backs. **Never towards the
650
+ ACTIVE relations driver** (2.4.0-alpha.10): once `relations.default ?? config.default` is `--to`, any
651
+ other source — with `--from` or picked by elimination — is 500 `E_AUTHZ_STALE_SOURCE_REFUSED` (it
652
+ would resurrect shares revoked since the cutover and, with `--prune`, delete the new ones); `--dry-run`
653
+ flags it, and `--allow-stale-source` is the human decision. It migrates **facts
483
654
  only** — there is no tree or catalog in `relations/` — and works **per partition** (`--partition-type`
484
- /`--partition-uuid`; default `app`).
655
+ /`--partition-uuid`; default `app`). **From code, the one public way is `runRelationsReconcile`** —
656
+ `import { runRelationsReconcile } from '@jantstack/adonis-authz/commands/authz_relations_reconcile'` and
657
+ `runRelationsReconcile(authorization, { toKey, fromKey, partitions: [...] })` migrates several partitions in one
658
+ pass (see the sweep below), with the same guard and the same durable window as the command. Source and
659
+ destination are **keys of `relations.drivers`, resolved from the manager's own config**, and the guard compares
660
+ the *resolved* driver, not the label: two keys that point at the same factory are the same driver (an alias of the
661
+ active one is refused too; source = destination is 500 `E_AUTHZ_CONFIG`), and the active driver is always the
662
+ manager's — there is no `activeKey` to contradict it. **Declared limit: identity is the factory reference, not the
663
+ backend.** Two *different* factories that build the same store — `openfga: () => new OpenFgaRelationsDriver(…)` and an
664
+ alias `fga: () => new OpenFgaRelationsDriver(…)` with the same `OPENFGA_STORE_ID`, which is how an alias is naturally
665
+ written in a config — are **not** detected: `--to=fga --from=database` passes the guard and resurrects what was revoked.
666
+ Nothing is built before the guard, so there is no backend to compare. Register an alias with the *same* reference
667
+ (`const fga = () => …` and `{ openfga: fga, alias: fga }`) or do not register it; a backend identity declared by the
668
+ driver is on the 2.5 list. Until the closing lot of 2.4.0-alpha.10 the primitive
669
+ `reconcileRelations` was exported from the package root and this paragraph recommended it for K partitions: it
670
+ never saw the active driver nor took the window, so a loop written after the cutover resurrected revoked shares
671
+ with the fleet still writing. It is no longer public. **The guard reads the config of the process that runs it**:
672
+ `relations.default ?? config.default` *there* — a job or pod started with another environment (say, without
673
+ `AUTHZ_DRIVER`) believes another driver is active and the guard is off. Run it with the fleet's config; a durable
674
+ seal of the active driver is on the 2.5 list. **The pass that writes runs under the durable freeze** (2.4.0-alpha.2,
675
+ lot L-1), exactly like `authz:reconcile`: every write of the package — roles *and* relations — answers a
676
+ retryable 503 `E_AUTHZ_FROZEN` fleet-wide while it runs; a live **operator** window (`authz:freeze`) is its
677
+ own context (it runs inside, does not renew or lift it); a live freeze of another pass is 423; and the
678
+ report carries `frozen: { durable, lapsed, leaseMs, fence }` — **`lapsed: true` means the lease was lost
679
+ mid-pass and the pass is not certified** (exit ≠ 0). `--dry-run` does not freeze: the verifier is
680
+ read-only and is meant for a cron.
681
+
682
+ #### The source sweep, measured
683
+
684
+ In `openfga`, `enumerateRelations(partition)` — the *source* of the migration — is a `Read({})` of the
685
+ **whole store**, paginated by 100 (the server's cap) and filtered in memory by the whole partition key.
686
+ There is no cheaper `Read`: by type without `user` is a 400, and by **prefix** of the object id
687
+ (`document:unit|<uuid>|`) answers **200 with zero tuples** — silently, which in the source of a migration
688
+ means "this partition is empty" and in a purge "proved zero" without deleting anything. Never optimise it
689
+ that way; the suite spies the filter of every `read` and it is always `{}`. What *is* cheaper is doing the
690
+ sweep **once for K partitions**: `enumerateRelationsMany(partitions)` (optional port method, implemented by
691
+ `openfga`; `RelationsManager` exposes it and falls back to K calls for a driver without it) and
692
+ `runRelationsReconcile(authorization, { toKey, fromKey, partitions })`, which enumerates source and destination once, decides the K diffs
693
+ first (the mass-delete guard R-17 is evaluated for every partition **before** writing anything) and then
694
+ applies them; the report is the sum. Same output per partition, byte for byte, as K separate calls; `app`
695
+ does not pull `appliance|…`, `org|U` does not pull `organization|U`, and each spelling of a uuid is its own
696
+ partition. Measured against OpenFGA 1.19.0 (2.4.0-alpha.6):
697
+
698
+ | Store | Partitions asked | `enumerateRelations` × K (before) | `enumerateRelationsMany` (after) |
699
+ |---|---|---|---|
700
+ | 5,000 tuples (3 × 1,000 + 2,000 foreign) | 3 | 150 `Read`, 502–558 ms | **50 `Read`, 170–209 ms** |
701
+ | 15,000 tuples (panel measurement) | 3 | 453 `Read`, ≈ 1.4 s | **151 `Read`, ≈ 0.46 s** |
702
+ | 15,000 tuples | 50 | ≈ 23 s | ≈ 0.5 s (extrapolated from the same sweep) |
703
+
704
+ The cost is O(tuples in the store) **per sweep**, whatever the size of the partitions asked, and the sweep is
705
+ exhaustive or loud: a page with zero tuples *and* a continuation token is not the end; a token that
706
+ repeats, more than 10,000 pages, or a response without `tuples` is 503 `E_AUTHZ_BACKEND_UNAVAILABLE`,
707
+ never a shorter enumeration. Run it from a job or a cron, **never from a tenant request**.
708
+
709
+ **Writing relations inside your transaction (2.4.0-alpha.2, lot L-4).** `relate`/`unrelate`/`purgeObject`/
710
+ `purgeSubject` with **`{ transaction: trx }`** on the `database` relations driver write the tuple **inside
711
+ your open Lucid transaction** of the driver's connection — the same rule, the same guard, the same pool ≥ 2
712
+ requirement, the same census in the published suite and the same per-engine behaviour as facts, all in one
713
+ place: [Writing inside your transaction](#writing-inside-your-transaction-240-alpha2). What is specific to
714
+ tuples is there too: with a transaction present `relate` no longer opens its own, so the partition trigger
715
+ fires inside yours; renewing an expiry rolls back to the previous one; two crossed writes deadlock into a
716
+ 409. A pool-of-1 deployment declares `new DatabaseRelationsDriver(config, { transactionalWrites: false })`;
717
+ on `openfga` it is the 500 and [the recipe by direction](#openfga-the-recipe-by-direction). **And the unique
718
+ index guards holder tuples too (lot L-4b).** Until L-4b `subject_relation` was `NULL` for a holder and every engine treats two
719
+ `NULL`s as distinct in a unique index, so `authz_rel_tuple_uq` only guarded **userset** tuples: two concurrent
720
+ `relate`s of the *same holder tuple* in two open transactions both went in and committed **two identical
721
+ rows** (no over-grant, but `listSubjects`/`enumerateRelations` listed the subject twice and the reconcile
722
+ census counted it twice). Since L-4b the column is **`NOT NULL DEFAULT ''`** (`''` = holder; the only option
723
+ with parity on the three engines — MySQL has no partial index, `NULLS NOT DISTINCT` is PostgreSQL 15+) and the
724
+ driver writes `''`, so the same holder tuple in two transactions ends in **exactly one row**: the loser gets
725
+ the same **409 `E_AUTHZ_WRITE_CONFLICT`** as a userset (PostgreSQL/MySQL) or `SQLITE_BUSY` (SQLite file), pinned
726
+ on the three engines, with the mutant (column back to nullable ⇒ two rows) red. An installation that migrated
727
+ before L-4b runs [the recipe](#upgrading-from-240-alpha2-before-l-4b-authz_relationssubject_relation): the
728
+ driver tolerates old `NULL` rows on read meanwhile.
729
+
730
+ ### The relation write event (2.4.0-alpha.3)
731
+
732
+ `onRelationWrite` gives **the same guarantees as `onWrite`** — every one of them was missing or half-true on the relations port until `2.4.0-alpha.3`, and COGNITIV found the first two in real consumption. The event (`RelationWriteEvent`) mirrors the shape of `AuthzWriteEvent`: one type, optional fields, no discriminated union.
733
+
734
+ | `operation` | What the event carries | What it does not |
735
+ |---|---|---|
736
+ | `relate` | `subject`, `relation`, `object`, `partition`, `expiresAt` if you passed it, `actor` if you passed it | — |
737
+ | `unrelate` | `subject`, `relation`, `object`, `partition`, `actor` | — |
738
+ | `purgeObject` | `object`, `partition`, `actor` | no `subject`, no `relation` |
739
+ | `purgeSubject` | `subject`, `partition`, `actor` | no `object`, no `relation` |
740
+
741
+ A purge notifies **one event per call** — even when the driver sweeps both spellings of the partition uuid underneath, and even when it deletes many tuples — and it **does not count**: the port returns `void` (a count would break the port for third-party drivers, and inside `{ transaction }` it would be a number your rollback contradicts). If you need the "how many", call `enumerateRelations` (or `listSubjects`) before purging — inside your transaction, through your transaction. And a purge that deletes nothing notifies all the same: the auditable fact is *someone asked to delete the keys of X*, whether or not there were any. `scopes.detached` on the roles port emits one `scope_purged` per spelling because there the **manager** canonicalises; the relations manager does not, so the divergence is on purpose and pinned (*"E6 · UN evento por llamada aunque el driver barra DOS ortografías"*). A purge does not go through `assertWrite` — `RelationRef.operation` is `'relate' | 'unrelate'`, the published type of the R-13 hook — and that is pinned too (*"E9 · negativo declarado"*). But F-05 runs first on the purges too (2.4.0-alpha.3, closing the audit's critical finding): `purgeObject` validates the object type and `purgeSubject` the userset of its subject **before** the actor, the scope and the driver, so a purge of a `role_binding` is 422 with zero driver calls and zero events — see [F-05 on the seven operations](#relations-rebac-24) above. `purgeSubject` also validates its subject like `relate` does (a malformed holder or `{}` is 422 `E_AUTHZ_INVALID_IDENTITY` and never reaches the event).
742
+
743
+ **The four guarantees, on the four writes** (`relate`, `unrelate`, `purgeObject`, `purgeSubject`):
744
+
745
+ - **`transactional: true`** when the write went through your `{ transaction }` (lot L-4): at that instant the tuple exists only inside your transaction and is a fact if and only if you commit — the package never sees that; a sink that wants the last word hangs itself on `trx.after('commit')`, and the suite runs exactly that recipe: rollback ⇒ the deferred sink holds zero firm records and the census holds zero tuples, commit ⇒ one and one (*"B2 · rollback ⇒ ni tupla NI evento contabilizado como definitivo"*, *"E4 · purgeObject/purgeSubject dentro de una trx"*). Absent — not `false` — without a transaction, and `{ transaction: undefined }`/`null` count as none. The published runner judges it as the other face of the `transactionalWrites` capability: with `true`, the eight events of the four writes inside a transaction carry it; with `false`, a refused write emits nothing.
746
+ - **`indeterminate: true`** — invariant 13, now on this port too: a relation write that elapses its deadline notifies `onRelationWrite` with `indeterminate: true` before propagating the 503 `E_AUTHZ_BACKEND_TIMEOUT`, with the rest of the event intact (*"C1 · relate/unrelate que vencen el deadline notifican indeterminate: true"*, *"E5 · purgeObject/purgeSubject que vencen el deadline"*). Both drivers have a deadline to elapse: `database` through `guardSql` (`timeoutMs`), `openfga` through the SDK's `baseOptions.timeout` with `retryParams.maxRetry: 0` — measured against a socket that accepts and never answers, without a server (*"C4 · openfga (relations) contra un servidor MUDO"*). Inside your transaction the event carries `indeterminate: true` *and* `transactional: true` (PostgreSQL leaves your transaction aborted, MySQL kills the statement and leaves it alive, SQLite cannot elapse a deadline: `SQLITE_BUSY` is a 503 that is not a timeout and emits nothing — *"C3 · deadline vencido DENTRO de la trx externa"*); on `openfga` the event never carries `transactional` (gate 1 makes `{ transaction }` impossible there). A 422 (F-05, R-16, a bad `expiresAt`), an `assertWrite` that throws and the freeze emit **nothing** and call nothing: that write did not happen (*"C2"*). A 503 that is not a timeout emits nothing **when it arrives before the first statement or `Write`** — and that is the only case on `relate`/`unrelate` (one statement, one `Write`); on `database` a purge is one `DELETE`, so it holds there too; on `openfga` only when nothing had been deleted yet, because a purge on `openfga` is **not one request**: it is one `Read` + `deleteTuples` + `Read` (the proof of zero) per spelling of the partition uuid and, for `purgeSubject`, per declared type. When such a purge fails **after** it has already deleted part of its tuples — a connection error, or the 500 `E_AUTHZ_PURGE_INCOMPLETE` of the proof of zero — the driver marks the error (`markPartialWrite`, exported from the package: a property, the class and the `code` are unchanged) and the manager notifies `indeterminate: true` as well, before propagating, exactly like a deadline: *may have happened* is the honest word there (*"purgeObject: la demostración de cero falla tras el deleteTuples ⇒ … indeterminate: true"*, and its control: a failure on the first `Read` emits nothing). A third-party driver whose purge is multi-request has the same duty.
747
+ - **A hook that throws — sync or async — is logged and swallowed**: the write is already applied when the hook runs, so propagating would report a failure for an operation that did happen and invite a retry of something done. Until alpha.3 the hook was called with neither `await` nor `try/catch`: a failing audit sink turned an applied `relate` into an error for the caller, and an `async` hook that rejected vanished as an unhandled rejection. Now the returned promise is awaited, the failure is logged naming the operation (`logger.error`, or `console.error` outside an app), and the caller gets its result (*"D1 · un onRelationWrite SÍNCRONO que lanza no propaga"*, *"D2 · un onRelationWrite ASYNC que rechaza tampoco propaga"*). The flip side of that guarantee: the sink is **awaited**, so it is on the critical path of the write — its latency is the write's latency — and, with `{ transaction }`, inside your transaction: a sink that never resolves keeps it open (and, since `{ transaction }` requires a pool ≥ 2, a few of those exhaust the engine's connections and the freeze barrier starts answering 503 to everybody). There is no deadline on the hook, on purpose — parity with `hooks.onWrite`, and a deadline would be a design decision of its own — so defer anything slow to `trx.after('commit')` or to a queue, and never do I/O without a timeout in there.
748
+ - **`actor` is validated like any identity, then required**: a malformed `actor` is 422 `E_AUTHZ_INVALID_IDENTITY` before the driver, on the four writes, and never travels to the event (until alpha.3 only its *presence* was checked, so `{ uuid: 'A#B' }` poisoned the trail and `actor: {}` satisfied `requireActor`); `requireActor: true` + `actor: {}` is the identity 422, not "actor required" (*"D4"*). **Breaking**: `requireActor` now reaches `purgeObject`/`purgeSubject` — a mass deletion of keys is the write that most needs an actor — so a deployment with `requireActor: true` that purged without one gets 422 `E_AUTHZ_ACTOR_REQUIRED` (*"E3"*); the purges take `RelationPurgeOptions` (`transaction` + `actor`).
749
+
750
+ **`assertWrite`.** `assertWrite` is **synchronous, pure and has no `actor`** on purpose (R-13): it receives the full tuple reference (`subject`, `relation`, `object`, `partition`, `operation`, `expiresAt`) and throws to refuse; it runs after F-05 and before the driver, so a refusal is zero driver calls and zero events. Because a function returning `Promise<void>` is assignable to `void`, `assertWrite: async (ref) => { throw … }` compiles — and until alpha.3 the promise was discarded and the write went in: a policy gate that refused nothing, in silence. Since alpha.3 **returning a promise is 500 `E_AUTHZ_CONFIG`** before touching the driver — the message names `assertWrite`, "synchronous", and the recipe — whether the promise would have rejected or resolved (*"D3 · un assertWrite ASYNC no es un assertWrite"*). And more generally `assertWrite` **does not return verdicts**: it throws or it does not. `assertWrite: (ref) => allowed.has(ref.object.type)` compiles under `--strict` just the same, and until the alpha.3 closing lot returning `false` did not refuse anything — the write went in — so now returning **any value at all** — `false`, `true`, a thenable *function*, `null`, an object — is 500 `E_AUTHZ_CONFIG` before the driver, with zero calls and zero events (*"assertWrite que DEVUELVE algo (false, true, thenable-función, null) es 500"*). The policy that needs the actor, your database or an `await` belongs in your service, before calling `relate`/`unrelate`.
485
751
 
486
- **Not in 2.4 — relation expiry (R-15).** `authz_relations` is insert/delete-only; a relation tuple
487
- has no `expiresAt`. Time-boxed shares are **deferred to 2.6** (an additive `expires_at` column plus
488
- the `BEFORE UPDATE` trigger the `database` driver already carries). Also deferred: `includes` with
489
- `from` (cross-object inheritance like `viewer from parent`), which would add a TTU between object
490
- types and force re-measuring depth.
752
+ **Wiring.** The three live in `relations` of `defineConfig` — `relations.assertWrite`, `relations.onRelationWrite`, `relations.requireActor` — and not in `hooks`: one home for the port, no precedence rule (the published stub declares them there, and a case compiles it). Until alpha.3 the provider passed neither hook to the service manager (`authz.relations`), so through the service there was no policy gate and no audit trail at all; `requireActor` *was* wired (inherited from the root). `relations.requireActor` overrides the root per port, `relations.requireActor ?? requireActor`, exactly like `relations.requireTransactionalWrites`: unset inherits, `true` requires it here even if the root does not, `false` waives it here even if the root requires it (*"A3 · relations.requireActor anula el raíz por PUERTO, en los tres sentidos"*). Read the third direction as what it is: `relations.requireActor: false` under a root `requireActor: true` **overrides the root for relations, silently** — `warnOnOptInSecurity` does not cover it — so a template that carries the key waives the actor on the four relation writes without a warning; write it only on purpose. `manager.driver()` still skips all three, as documented above.
753
+
754
+ **Still not in 2.4.** `includes` with `from` (cross-object inheritance like `viewer from parent`),
755
+ which would add a TTU between object types and force re-measuring depth. Relation expiry (R-15)
756
+ **landed in 2.4.0-alpha.2** (see above); an installation that migrated 2.4.0-alpha.1 adds the column with
757
+ the recipe in [Upgrading](#upgrading-from-240-alpha1-authz_relationsexpires_at). `{ transaction }` on
758
+ `relate`/`unrelate`/`purge*` **landed with lot L-4** (above); on `openfga` it is the honest 500 and
759
+ [the recipe by direction](#openfga-the-recipe-by-direction).
491
760
 
492
761
  ## Errors
493
762
 
@@ -512,26 +781,30 @@ Every error the package raises carries `status` and `code`. A standard AdonisJS
512
781
  | `E_AUTHZ_NOT_WITHIN` | 422 | any of the nine writes with `within` not in the chain of the scope it writes to (the new parent **and** the child's current chain for `scopes.moved`, and for `scopes.attached` of an existing child; the role's owner for the delegation API since 2.2) (2.1) |
513
782
  | `E_AUTHZ_WITHIN_REQUIRED` | 422 | `requireWithin` set and a write without `within` (2.1) |
514
783
  | `E_AUTHZ_WITHIN_ROOT_FORBIDDEN` | 422 | `requireWithin: 'non-root'` and `within: APP_SCOPE` (2.1) |
515
- | `E_AUTHZ_ACTOR_REQUIRED` | 422 | `requireActor: true` and a write without `actor` (2.1) |
784
+ | `E_AUTHZ_ACTOR_REQUIRED` | 422 | `requireActor: true` and a write without `actor` (2.1); on the relations port (`relations.requireActor ?? requireActor`) the four writes, `purgeObject`/`purgeSubject` included since 2.4.0-alpha.3 |
785
+ | `E_AUTHZ_SCOPE_DEPTH_LIMIT` | 422 | `scopes.attached`/`scopes.moved` with `scopes.maxDepth` declared: the child would end up more than `maxDepth` `parent` hops from `app` (the parent's chain + 1). Refused **before** writing or enqueueing; the gate is opt-in and partial — see [Limits](#limits) (2.4.0-alpha.5). Since 2.4.0-alpha.8 the `message` is written for the tenant that made the request — the depth reached, `scopes.maxDepth`, "nothing written or enqueued" — and names no backend: *why* the ceiling exists (`FACTS_MAX_RESOLVE_DEPTH`, `OPENFGA_RESOLVE_NODE_LIMIT`) travels in `error.details` for the operator's log |
516
786
  | `E_AUTHZ_TOO_MANY_SCOPES` | 422 | `authorizedScopes`/`expandExcludedSubtrees` over `maxScopes`, or `descendantsOf` over `maxNodes` (`sqlDescendantsOf`: also a possible cycle) — never a partial list (2.1) |
517
787
  | `E_AUTHZ_BACKEND_UNAVAILABLE` | 503 | facts backend or SQL catalog did not answer (both drivers, catalog sync/diff and the `authz_catalog_version` check included); the version row is missing or unreadable ("migración 2.0 no aplicada": fail-closed, never version 0); a per-check `error` in an OpenFGA `batchCheck` |
518
788
  | `E_AUTHZ_BACKEND_TIMEOUT` | 503 | `timeoutMs` elapsed (subclass of the above) |
519
789
  | `E_AUTHZ_FROZEN` | 503 | the engine's writes are frozen by a platform operation (`authz:reconcile`, or the cutover window of `authz:freeze`) — **durably, fleet-wide** (row `id = 2` of `authz_catalog_version`, 2.3): reads keep working and the error is **retryable** (`error.retryable === true`) — reissue the write when the window ends (the message says how it lifts) |
520
790
  | `E_AUTHZ_FREEZE_HELD` | 423 | `freeze()` (or a second `authz:reconcile`) found a live freeze owned by someone else: two windows never interleave, and only the owner's token — or `authz:unfreeze` — lifts one. The message names the holder, the reason and the fence |
521
791
  | `E_AUTHZ_RESOLVER_FAILED` | 503 | your `resolveChain`, `parentOf` or `descendantsOf` threw or answered a malformed scope; `descendantsOf` and `resolveChain` disagree in `authorizedScopes`; a subtree to exclude cannot be enumerated |
522
- | `E_AUTHZ_WRITE_CONFLICT` | 409 | an `openfga` write kept clashing with another transaction over the same tuples (FGA answers `Aborted`/409, or 400 "cannot write a tuple which already exists"): the driver re-reads and re-applies, and only gives up after three rounds. The backend answered, so this is never a 503 — retry the write |
523
- | `E_AUTHZ_CONFIG` | 500 | contradictory config (`holderTypes` not injective or a holder type not declared in it, `scopes.*` without resolver, `appAccess` without `permission`, `catalog` together with `catalogRevalidate`, an invalid `maxAgeMs`); `bumpAuthzCatalogVersion` called without the writing transaction's client |
792
+ | `E_AUTHZ_WRITE_CONFLICT` | 409 | an `openfga` write kept clashing with another transaction over the same tuples (FGA answers `Aborted`/409, or 400 "cannot write a tuple which already exists"): the driver re-reads and re-applies, and only gives up after three rounds. The backend answered, so this is never a 503 — retry the write. Also (2.4.0-alpha.2, lot L-3) a `database` `grant`/`deny` **inside your transaction** (`{ transaction }`) that hits the UNIQUE because another transaction committed the same fact while yours was open: outside a transaction the driver re-reads and keeps the winner's row; inside yours it cannot (PostgreSQL has already aborted your transaction — every later statement is `25P02` until you roll back — and under REPEATABLE READ the re-read would not see the winner), so it **poisons your transaction**: roll back and retry. Measured on all three engines, see [writing inside your transaction](#writing-inside-your-transaction-240-alpha2). The same for a `database` `relate`/`unrelate`/`purge*` inside your transaction (lot L-4) — on a UNIQUE clash of a tuple (userset, and since lot L-4b holder too: `subject_relation` is `''`, never `NULL`), and on a **deadlock** (two transactions writing two tuples in crossed order: PostgreSQL `40P01`, MySQL `1213`; the engine's victim gets this 409, never a hang nor the raw error) |
793
+ | `E_AUTHZ_CONFIG` | 500 | `relayScopeChanges({ only: [] })`, a `ScopeOutbox` that ignores `pending(limit, after, { ids })` and returns an entry that was not asked for, `requeueScopeChanges` with an id that is not parked (or an empty list), or a `scopes.maxDepth` that is not an integer ≥ 1 (2.4.0-alpha.5); contradictory config (`holderTypes` not injective or a holder type not declared in it, `scopes.*` without resolver, `appAccess` without `permission`, `catalog` together with `catalogRevalidate`, an invalid `maxAgeMs`); `bumpAuthzCatalogVersion` called without the writing transaction's client; a `{ transaction }` handed to `sqlScopeOutbox` that is not an open Lucid transaction of the outbox's own connection (another connection, a `QueryClient`, or the whole `db` — the message names both connections), **or one that is already committed or rolled back** (`isCompleted`, 2.4.0-alpha.8 — the same check in the roles and relations drivers; it used to surface as a 503); `sqlScopeOutbox({ requireTransaction: true })` enqueueing without `{ transaction }` (2.4.0-alpha.8; the default becomes `true` in 2.5); `relayScopeChanges({ deadDetail })` with a value other than `'full'`; `requireTransactionalWrites: true` (root, or `relations.requireTransactionalWrites`) with an active driver that does not declare `transactionalWrites: true` — raised **when the driver is resolved**, so reads fail too and the deployment does not start (2.4); a `relations.assertWrite` that returns a promise — or any value at all: `false`, `true`, a thenable function (it must be synchronous and it does not return verdicts: it throws or it does not; the write is refused before the driver, 2.4.0-alpha.3) |
524
794
  | `E_AUTHZ_ROLE_IS_NOT_ACCESS` | 500 | `appAccess({ role })` |
525
795
  | `E_AUTHZ_INTERNAL` | 500 | package invariant violated (empty scope set on a write, misaligned batch, a third-party `authorizeMany` answering the wrong shape, a `Read` continuation token that never advances or more than 10,000 pages, a corrupt `assignable_at`/`owner_scope_key` row) |
526
- | `E_AUTHZ_PURGE_INCOMPLETE` | 500 | `purgeScope` could not prove zero |
796
+ | `E_AUTHZ_PURGE_INCOMPLETE` | 500 | `purgeScope` could not prove zero; on the relations port, `purgeObject`/`purgeSubject` of `openfga` could not prove zero after deleting — the error carries the `markPartialWrite` mark and the event was emitted with `indeterminate: true` (2.4.0-alpha.3) |
527
797
  | `E_AUTHZ_RECONCILE_TOO_LARGE` | 500 | the destination's dump does not fit the declared `--max-tuples` cap (default 1 000 000) and reconciling needs the whole snapshot to know what is left over. Nothing was written; raise the cap if your process can take it |
798
+ | `E_AUTHZ_STALE_SOURCE_REFUSED` | 500 | `authz:reconcile --to=<config.default>` (or `authz:relations:reconcile --to=<relations.default ?? default>`) with any resolved source other than the destination itself — `--from`, picked by elimination, or `authz_*` (2.4.0-alpha.10): the stale direction — the source stopped seeing the active driver's facts at the cutover, so the pass would resurrect every revocation made since. Nothing was read, frozen or written; for roles the maintenance pass is `--to=<active>` without `--from`, and `--allow-stale-source` is the human decision (`--dry-run` flags `report.staleSource` instead of throwing) |
799
+ | `E_AUTHZ_OPERATOR_WINDOW_EXPIRED` | 503 | `freeze()` — and with it `authz:reconcile`, `authz:relations:reconcile`, `withFrozenWrites`, `authz:freeze` — found the OPERATOR window expired (2.4.0-alpha.10): taking a freeze over it would erase the trace that writers resumed mid-cutover. Acknowledge it with `authz:unfreeze`, reopen with `authz:freeze` |
528
800
  | `E_AUTHZ_MASS_RECONCILE_REFUSED` | 500 | `authz:reconcile --prune` would delete facts from the destination while the source has not contributed a single **usable** one — empty, or every fact it returned was skipped (expired, on scopes your tree no longer resolves): the signature of a wrong connection, of the wrong source, or of a blind resolver. Nothing was deleted; pass `--allow-mass-delete` if you really mean to empty it (`--dry-run` flags it instead of throwing). A source that is merely **stale** but still usable is covered by reading the facts from whoever owns them |
529
801
  | `E_AUTHZ_MASS_PURGE_REFUSED` | 500 | `pruneOrphanRoles({ force: true })` would purge every distinct owner (or more than half the local roles): the signature of a blind `resolveChain`. Nothing was deleted; pass `allowMassPurge: true` (`--allow-mass-purge`) if the prune is real |
530
802
  | `E_AUTHZ_TOO_MANY_LOCAL_ROLES` | 500 | more local roles than `maxLocalRoles` (10 000) in a `prune-orphans` pass; never a partial list |
531
- | `E_AUTHZ_UNSUPPORTED` | 500 | a primitive needs an optional port method the active driver lacks: `listDenies` (2.1; also behind `defineScopedRole`), `purgeRole` (2.2 — behind `deleteScopedRole`, `authz:catalog:prune-orphans` and, before writing anything, `defineScopedRole`; the `openfga` driver until 2.3); `reconcile` (2.3 — `authz:reconcile --to=<driver>`); `enumerateFacts` (2.3 — being the *source* of `authz:reconcile`; the `database` driver does not implement it: its facts are `authz_*`. Also raised when `--to` is the **active** driver and declares `hierarchyFacts`: its facts are its own, so the pass has to be able to read them instead of rebuilding it from `authz_*`) |
803
+ | `E_AUTHZ_UNSUPPORTED` | 500 | a primitive needs an optional port method the active driver lacks: `listDenies` (2.1; also behind `defineScopedRole`), `purgeRole` (2.2 — behind `deleteScopedRole`, `authz:catalog:prune-orphans` and, before writing anything, `defineScopedRole`; the `openfga` driver until 2.3); `reconcile` (2.3 — `authz:reconcile --to=<driver>`); `enumerateFacts` (2.3 — being the *source* of `authz:reconcile`; the `database` driver does not implement it: its facts are `authz_*`. Also raised when `--to` is the **active** driver and declares `hierarchyFacts`: its facts are its own, so the pass has to be able to read them instead of rebuilding it from `authz_*`). Also `{ transaction }` on `grant`/`revoke`/`deny`/`removeDeny` or `relate`/`unrelate`/`purge*` when the active driver declares `transactionalWrites: false` (2.4 — the message names the driver and the operation, nothing was called; the way out is the `database` driver or `requireTransactionalWrites`; the `openfga` drivers raise it themselves too, so `manager.driver()` is not a way out — see [the recipe by direction](#openfga-the-recipe-by-direction)), and `{ transaction }` on the delegation API (`defineScopedRole`/`updateScopedRole`/`deleteScopedRole`: the catalog is written through `withAuthzCatalogWrite`, the cross-process serializer, which a caller's transaction would defeat) |
532
804
  | `E_AUTHZ_MODEL_TOO_LARGE` | 500 | the catalog does not fit in an OpenFGA authorization model (262,144 **bytes** — around **450 permissions with realistic slugs**; see [the model ceiling](#the-model-ceiling-is-bytes-not-permissions)): checked in `syncAuthzCatalog` **before** writing, with a warning past 80 % |
533
805
  | `E_AUTHZ_RELATION_CONFIG` | 422 | a `relations/` object type or relation cannot be **fused** into the shared `facts` model: it duplicates a reserved `facts` type (`scope`/`role`/`role_binding`/`deny_binding`/`group`) or relation family (`can_<P>`/`denied_<P>`/`permits_<P>`/`parent`/`rooted`/`assignee`…), or a relation name collides with a catalog permission (F-04). The generator raises it — not OpenFGA's opaque 400 — because in the shared store the id-spaces overlap |
534
806
  | `E_AUTHZ_SCOPE_DRIFT_UNGUARDED` | 500 | the `openfga` driver was constructed without `scopes.outbox` and without `acceptScopeDriftRisk: true`. Thrown at construction: a rollback of your transaction would otherwise leave the store's tree ahead of yours, and that escalation is invisible from your database |
807
+ | `E_AUTHZ_REQUEUE_REFUSED` | 500 | `requeueScopeChanges` / `authz:scopes:requeue`: an entry applied *after* the parked one shares a scope key with it, so requeueing would re-apply the old change on top of the new (the store reverted, with a single edge). Nothing was requeued; the error carries `conflicts` (the diff); `authz:reconcile --to=<driver>` is the remedy and `--force` (ace command only) the override (2.4.0-alpha.5) |
535
808
  | `E_AUTHZ_SCOPE_TREE_DRIFT` | 500 | the materialized tree has more than one `parent` edge for the same scope: someone else writes to the store. A write never "fixes" it by guessing; `authz:reconcile` rebuilds the tree from yours and reports the scope |
536
809
  | `E_AUTHZ_NO_DESCENDANTS_RESOLVER` | 500 | `authorizedScopes`/`expandExcludedSubtrees` without `scopes.descendantsOf` |
537
810
  | `E_AUTHZ_VIEW_EXPIRED` | 500 | a `forRequest()` view used to read (`expandExcludedSubtrees` included) after its `maxAgeMs` (default 30 s, monotonic clock) |
@@ -572,6 +845,7 @@ Every driver **declares** this through `driver.capabilities`, and each declared
572
845
  | `countRoleAssignments` | `true` | **`true`** | how many live facts a role has — what makes `stillGranting` true instead of a guess (2.3; same enumeration `purgeRole` needs) |
573
846
  | `canonicalScopeReads` | `true` | **`false`** | whether a read canonicalises the caller's scope spelling against your tree before looking for facts (see below) |
574
847
  | `enumerateFacts` | `false` | **`true`** | the driver can be the **source** of a migration: `enumerateFacts` hands its live facts back page by page, unfiltered and with their expiry (2.3). `database` declares `false` on purpose — its facts *are* `authz_assignments`/`authz_denies`, this package's published schema, so the destination reads them straight from there |
848
+ | `transactionalWrites` | **`true`** (default; declare `transactionalWrites: false` in the driver options on a pool of 1) | **`false`** | `grant`/`revoke`/`deny`/`removeDeny` with **`{ transaction }`** write the fact **inside the caller's transaction** — *both or neither*, never *"not lost"* (2.4, the `{trx}` panel). `database` does it since 2.4.0-alpha.2 (lot L-3): the write goes through your open Lucid transaction of the driver's connection (`assertCallerTransaction`: another connection, a `QueryClient` or the whole `db` are 500 `E_AUTHZ_CONFIG` before any statement), the authority never does, and the published suite pins **rollback ⇒ zero rows by census** for the four writes — see [writing inside your transaction](#writing-inside-your-transaction-240-alpha2). `openfga` cannot be anything but `false`: a tuple does not enter a SQL transaction and there is no 2PC; what it offers instead is [the recipe by direction](#openfga-the-recipe-by-direction). With `false`, `{ transaction }` is **500 `E_AUTHZ_UNSUPPORTED`** naming driver and operation, with **zero driver calls** (the manager's gate *and* the driver's own) — never ignored, never a warning; `requireTransactionalWrites: true` in the config makes it **500 `E_AUTHZ_CONFIG` when the driver is resolved** (the deployment does not start). Same name and same two gates on `RelationsDriverCapabilities` (`relate`/`unrelate`/`purgeObject`/`purgeSubject`; `relations.requireTransactionalWrites`, inherited from the root) — and since lot L-4 the `database` **relations** driver declares **`true`** too (`new DatabaseRelationsDriver(config, { transactionalWrites: false })` on a pool of 1; see [writing relations inside your transaction](#relations-rebac-24)). The only pair whose **two** faces are mandatory in the suite. Not to be confused with `scopes.*`'s `{ transaction }`, which **enqueues** in the outbox — enqueueing ≠ writing |
575
849
 
576
850
  **Two answers `facts` does not share with `database`, declared — not scheduled fixes (2.3).** `authorize` decides with the tree that lives in the store; it never calls your `resolveChain`. That is the property you are buying: a PDP that answers when your database does not. It has two consequences, and each one is a **capability pair with a negative case** in the published contract suite — the package does not skip them, it judges both faces.
577
851
 
@@ -581,6 +855,22 @@ Every driver **declares** this through `driver.capabilities`, and each declared
581
855
 
582
856
  **The anti-cycle checks are the package's, in both modes, and they are not optional.** Measured against OpenFGA v1.19: the server *accepts* an edge that closes a cycle, does not hang, answers in 2–7 ms, and from then on inheritance runs both ways — a grant on a descendant grants on its ancestor, and with the root inside the cycle it grants everywhere. Nothing is logged and there is no error to catch. That is why `child ≠ app`, "the parent exists" and `child ∉ ancestors(parent)` are checked before anything is written (422, no edge), and why you should not expect the backend to be a second line of defence.
583
857
 
858
+ ### Reading capabilities at runtime (2.4.0-alpha.4)
859
+
860
+ The table above is what each driver **declares**; your service code can read it from the manager instead of guessing from the driver name — and instead of calling `driver()`:
861
+
862
+ ```ts
863
+ // The same service code serves both drivers, without a single driver() in it.
864
+ if ((await authorization.capabilities()).transactionalWrites) {
865
+ await authorization.grant(user, 'editor', unit, { transaction: trx }) // both or neither, with YOUR transaction
866
+ } else {
867
+ await authorization.grant(user, 'editor', unit) // openfga: the recipe by direction
868
+ }
869
+ if (relations.capabilities().transactionalWrites) { /* relate(…, { transaction: trx }) */ }
870
+ ```
871
+
872
+ `authorization.capabilities()` is `async` — it resolves the active driver through `driver()`, so it pays the driver factory once (cached from then on) and **inherits its two gates**: with `requireTransactionalWrites: true` and a driver that does not comply it is the same 500 `E_AUTHZ_CONFIG` the deployment already fails with, and a `facts` driver without `scopes.outbox` or the signature is the same 500 `E_AUTHZ_SCOPE_DRIFT_UNGUARDED`. That is deliberate: capabilities belong to the *instance* (`database` decides `transactionalWrites` from its options), and a reading that resolved the driver by any other path would leave `driver()` serving an instance that never passed the gates. Call it once at boot (a `booted`/`ready` hook), not per request. `relations.capabilities()` is synchronous: the relations driver is built eagerly and its gate already ran. Both return a **read-only, frozen, normalised copy** typed as `AuthorizationDriverCapabilities` / `RelationsDriverCapabilities` (both exported): every known key is present, only `true` is `true` — the same reading the write gates apply — so a third-party driver with partial or no `capabilities` reads as `false`, **never `undefined`**; mutating the copy throws in strict mode and changes nothing. The read does not soften anything: `{ transaction }` to a driver that declares `false` is still 500 `E_AUTHZ_UNSUPPORTED` whether or not you asked first, and **`driver()` is still platform API** — tenant code should not call it, and a code review can keep grepping for it.
873
+
584
874
  ## Performance
585
875
 
586
876
  Two optimisations landed in 2.1, both measured and both **without changing a single answer** (the contract suite is the proof: same cases, both drivers, before and after). Reproduce the numbers with `OPENFGA_TEST_URL=http://localhost:8101 node --import @poppinss/ts-exec scripts/bench_authorize.mjs` (chain of 3 scopes through your resolver, 5 roles per level, 20 permissions, N=200 after 30 warm-up calls, HTTP round-trip included; OpenFGA v1.19.0 on the same machine):
@@ -632,7 +922,7 @@ runAuthorizationDriverContract({
632
922
  level: '2.2', // '2.1' = up to Phase 2, '2.0' = up to Phase 1; omit for the 1.x cases only
633
923
  capabilities: { // what the driver declares; each one has its own cases
634
924
  hierarchyFacts: false,
635
- transactions: false,
925
+ transactionalWrites: false, // can the driver write grant/revoke/deny/removeDeny INSIDE the caller's transaction (2.4)? false ⇒ { transaction } is 500 E_AUTHZ_UNSUPPORTED, with zero driver calls; true ⇒ the suite opens a transaction and pins rollback ⇒ ZERO rows by census (the `transactions` hook below; default Lucid's db.transaction() + authz_assignments/authz_denies)
636
926
  truncationSignal: false,
637
927
  singleCheckAuthorize: false,
638
928
  injectableClock: false,
@@ -649,6 +939,9 @@ runAuthorizationDriverContract({
649
939
  // process would be). Default: a prototype view of the driver with a fresh `CatalogCache` when it exposes `catalog`.
650
940
  makeTwin: (driver, tree) => new MyDriver({ resolveChain: resolveChainFrom(tree), sameBackendAs: driver }),
651
941
  seedCatalog: (catalog) => syncAuthzCatalog(catalog),
942
+ // Only with `transactionalWrites: true` (2.4): how the suite opens a transaction of YOUR driver's connection
943
+ // and counts a holder's facts in YOUR backend (the census). Omit it for Lucid's `db.transaction()` + `authz_*`.
944
+ transactions: { begin: async () => openMyTransaction(), census: async (subject) => countMyFacts(subject) },
652
945
  cleanup: () => wipeEverything(),
653
946
  })
654
947
  ```
@@ -700,6 +993,34 @@ What passing means: **for everything the suite covers, both drivers answer the s
700
993
 
701
994
  The package runs that suite on itself: `npm test` judges the `database` driver over in-memory SQLite — no host application — and `OPENFGA_TEST_URL=… npm test` adds the `openfga` driver to the same verdict. `npm run test:pg` and `npm run test:mysql` run the **same** suite over PostgreSQL 18 and MySQL 8.4 (`TEST_PG_URL` / `TEST_MYSQL_URL`; each run creates a database with a random suffix and drops it), `npm run test:sqlite-file` over a SQLite file with a pool of 2–5 connections (real connection-level concurrency: a case pins `pool.max ≥ 2` and a read that answers while another connection holds an open transaction — *"una lectura responde mientras OTRA conexión mantiene una transacción abierta"*; the two-concurrent-grants case itself is a JavaScript check-then-insert race and dies with a single connection too). On PostgreSQL and MySQL the judge additionally runs with the scope tree in a real SQL table (`hierarchicalScopeResolver` + `sqlDescendantsOf` over `demo_scopes`), which is where the uuid-alias bypass lived. CI runs all of it: SQLite in memory and as a file, PostgreSQL and MySQL, each with and without OpenFGA, plus a second OpenFGA server with `ListObjects`/`ListUsers` capped at 3; a case also checks that the child process the suite spawns leaves no database behind. Two capability pairs are exercised on both drivers: `listDenies` and **`injectableClock`** (`true` ⇒ the judge fixes the instant through `withClock(now)` and observes exact expiry, renewal and "expires right now" without waiting; `false` ⇒ it can only observe the three states of `expiresAt` in real time, with a 1.5 s wait).
702
995
 
996
+ ### The outbox contract: `runScopeOutboxContract` (2.4.0-alpha.5)
997
+
998
+ The `ScopeOutbox` port has duties nobody judged until alpha.5 — the receipt, oldest first, `after` as a continuation, an idempotent `markApplied`, a `markFailed` that counts and keeps the row, the actor that travels — and since alpha.5 every optional method is a **capability pair**: a `true` face that judges the duty and a `false` face that judges that the manager *says so* (or that the implementation degrades towards safe), never a skip. A third-party outbox proves itself the same way the package's does:
999
+
1000
+ ```ts
1001
+ import { runScopeOutboxContract } from '@jantstack/adonis-authz/testing'
1002
+
1003
+ runScopeOutboxContract({
1004
+ name: 'my-outbox',
1005
+ capabilities: {
1006
+ transactionalEnqueue: true, // enqueue writes through context.transaction: rollback ⇒ nothing pending (needs `transactions`)
1007
+ filter: true, // pending(limit, after, { ids }) filters in the port; false ⇒ `only` is 500 E_AUTHZ_CONFIG
1008
+ transient: true, // markFailed(..., { transient: true }) does not count; false ⇒ it counts anyway
1009
+ dead: true, // dead() exists: parked after limits.maxAttempts; false ⇒ nothing is ever parked
1010
+ acquire: true, // acquire() is a held, exclusive lease; false ⇒ the relay never says busy
1011
+ earlierPending: true, // the dependency probe of `only`; false ⇒ the manager pages pending(), same verdict
1012
+ requeue: true, // requeue + laterApplied (needs dead); false ⇒ requeueScopeChanges is 500 E_AUTHZ_UNSUPPORTED
1013
+ createdAt: true, // entries carry createdAt (the enqueue stamp): the relay reports the queue age; false ⇒ queue is { null, null } (2.4.0-alpha.8)
1014
+ },
1015
+ limits: { maxAttempts: 3 }, // with dead: true — what your outbox parks at (≥ 3)
1016
+ makeOutbox: () => myOutbox({ maxAttempts: 3 }), // fresh, over an empty queue
1017
+ cleanup: () => emptyMyQueue(),
1018
+ transactions: { begin: async () => openMyTransaction() }, // with transactionalEnqueue: true
1019
+ })
1020
+ ```
1021
+
1022
+ Seventeen cases per harness (2.4.0-alpha.8): nine core duties — including O-07b, the root `app` is not a scope key — and one face per each of the eight capabilities. Declaring `dead: true` without `limits.maxAttempts`, `requeue: true` without `dead: true`, or `transactionalEnqueue: true` without `transactions` throws at registration — a promise without a judge does not pass. The package runs it on `sqlScopeOutbox` over the four engines and on an in-memory double (`makeScopeOutbox`, exported too) with all capabilities and with none, so both faces of every pair run on every `npm test`.
1023
+
703
1024
  ## OpenFGA tooling
704
1025
 
705
1026
  ```bash
@@ -733,12 +1054,14 @@ This is the **only** migration and verification primitive of the package, and th
733
1054
 
734
1055
  **Whoever owns the facts is where they are read from (2.3, and it is the whole safety of this command).** `--to=openfga` used to read `authz_assignments` / `authz_denies` *always*. In a deployment that has already cut over to `facts` those tables are **not** the source of truth of the facts — the store is, and nothing keeps them in sync after the cutover — so a pass that rebuilt from them re-wrote grants you had revoked (no flag needed) and, with `--prune`, deleted the denies that only lived in the store. So the pass now asks first, and **says the answer in its first line** (`report.factsFrom`):
735
1056
 
736
- - **`--to` is the *active* driver and its facts live in its own backend** (`capabilities.hierarchyFacts`, which is what `openfga` declares): then its facts are its own and it is read from *itself*. This is the **maintenance pass**: it rebuilds everything **derived** — root marker, catalog projection, tree — and applies the visibility sweep of invariant 18 with the tree and the catalog of *today*, and it **writes and deletes no fact at all**. It is also what makes `--dry-run` usable as the CI verifier of a `facts` deployment: a correct store now comes out **clean** instead of reporting every live fact as `extra-fact`. (A driver in that position that cannot enumerate its own facts is 500 `E_AUTHZ_UNSUPPORTED` naming `enumerateFacts` — reading `authz_*` for it would be the bug.)
737
- - **`--from=<driver>` is given**: you decide. If that driver's facts are `authz_*` (the package's `database`), `--to=openfga` is the **one-way migration** it always was, and it will happily overwrite the destination's facts with what those tables hold. That is what you want while migrating — and it is a loaded gun pointed at a store that is already live, which is why it now takes an explicit `--from` to fire it.
1057
+ - **`--to` is the *active* driver and its facts live in its own backend** (`capabilities.hierarchyFacts`, which is what `openfga` declares): then its facts are its own and it is read from *itself*. This is the **maintenance pass**: it rebuilds everything **derived** — root marker, catalog projection, tree — and applies the visibility sweep of invariant 18 with the tree and the catalog of *today*, and **it writes no fact; with `--prune` it deletes the facts of scopes that no longer resolve**, the expired ones and those of roles or permissions the catalog or the model no longer has (and the leftovers) — without `--prune` it deletes no assignment or deny (only the role-visibility edges invariant 18 forbids) and reports them as `extra-fact`. (Until 2.4.0-alpha.9 this line, and the command's first line, claimed the pass deleted nothing while `--prune` deleted them — measured by a consumer.) It is also what makes `--dry-run` usable as the CI verifier of a `facts` deployment: a correct store now comes out **clean** instead of reporting every live fact as `extra-fact`. (A driver in that position that cannot enumerate its own facts is 500 `E_AUTHZ_UNSUPPORTED` naming `enumerateFacts` — reading `authz_*` for it would be the bug.)
1058
+ - **`--from=<driver>` is given**: you decide. If that driver's facts are `authz_*` (the package's `database`), `--to=openfga` is the **one-way migration** it always was, and it will happily overwrite the destination's facts with what those tables hold. That is what you want while migrating — and it is a loaded gun pointed at a store that is already live, which is why it now takes an explicit `--from` to fire it. **Except towards the active driver (2.4.0-alpha.10, breaking):** if `--to` is `config.default`, the only legitimate source is `--to` itself (the maintenance pass above); any other **resolved** source — named with `--from`, picked by elimination without `--from` (with `database` active, `--to=database` alone picks `openfga`), or the `authz_*` tables — makes the manager refuse with 500 `E_AUTHZ_STALE_SOURCE_REFUSED` before resolving the source, reading or freezing anything. That is the **stale direction**: once the engine serves from `--to`, its facts are written there and the source stopped seeing them, so the pass would re-write what was revoked since the cutover (with or without `--prune`; and the mass-deletion guard does not fire, because the source is stale, not empty). The guard looks at `--to` and at the source it resolves, never at the flag — and at the *resolved* driver, not the key: the same factory registered under two keys is the same driver, so an alias of `config.default` is refused too (**declared limit**: identity is the factory *reference*, so two different factories that build the same backend are not detected and such an alias passes; register an alias with the same reference, and a backend identity declared by the driver is on the 2.5 list) — so the four rows are: maintenance pass — runs; initial cutover (`--to=<new>` while the engine still serves from the old one) — runs; going back **before** switching `default` — runs; re-running either of the two **after** switching `default` — refused. `authz:relations:reconcile` applies the same rule with the active relations driver (below). `--allow-stale-source` (`allowStaleSource: true`) is the human decision; `--dry-run` never throws, it flags `report.staleSource = { to, from, allowed }`, prints a warning and exits non-zero. **The guard is only as reliable as the `config.default` of the process that runs the command**: it has no durable record of what the fleet serves from, so a migration job or pod started with a different environment (`default: env.get('AUTHZ_DRIVER', 'database')` without that variable) believes another driver is active and the guard is silently off. Run it with the fleet's config; a durable seal of the active driver is on the 2.5 list.
738
1059
  - **Otherwise** (the destination is not the active driver): the migration of always, facts read from `authz_*`.
739
1060
 
740
1061
  **It needs `scopes.enumerateEdges`** in your config: the whole tree, paginated with a cursor. `sqlScopeEdges({ table, uuidColumn, parentColumn, typeColumn })` implements it over a table with a parent column, exactly like `sqlDescendantsOf`. Without it the command refuses (500 `E_AUTHZ_CONFIG`) instead of assuming a flat tree — a flat tree would be an invented hierarchy, and an invented hierarchy grants.
741
1062
 
1063
+ **`sqlScopeEdges` covers ONE table, and concatenating several does not cover a tree that lives in more than one (2.4.0-alpha.4).** The helper does a `LEFT JOIN` of the table *against itself* on `parentColumn = uuidColumn`, and a row whose declared parent has no match in *that* table is **skipped in silence** — that is the documented "a parent that does not exist is not emitted", and it has no way to tell "orphan" from "the parent lives in another table". So if your tree is spread over several tables (say `organizations` → `organization_units` → `ia_spaces`), the obvious composition — one `sqlScopeEdges` per table, drained one after the other — **does not emit the edges between tables**: every `ia_space → organization_unit` and `organization_unit → organization` edge is dropped without a word, and the parent's *type* would come out wrong anyway (it is the child table's fixed `scopeType`). With `authz:reconcile --to=openfga` that is not "some edges are missing": the destination sees them as surplus and deletes them, and by invariant 9 (`can_<P>` requires `rooted`) **the whole subtree denies**. Fail-closed, noisy, nobody escalates — and a day lost. Write your own `enumerateEdges` instead: one query that concatenates the tables (`UNION ALL` of `(child_uuid, child_type, parent_uuid, parent_type)` across the tables, `parent IS NULL` meaning `app`), ordered by a total and stable key (the child uuid works) with the cursor over that key, and emitting an edge only when the parent exists in *any* of the tables; then compare with `authz:reconcile --to=<driver> --dry-run` before the first real pass. Alternatively chain the tables in a fixed order and encode *which table* inside the opaque cursor — the contract only asks for a total, stable order across calls. *Recipe not verified by the suite* (it has no multi-table fixture); a composition helper (`composeScopeEdges`, or `parentTable` on `sqlScopeEdges`) is deferred to 2.6.
1064
+
742
1065
  What one pass does, in order: the **root marker** (`scope:app#rooted`, without which the whole store denies), the **catalog projection** (`role:<uuid>#permits_<P>`, read with the very same function `syncAuthzCatalog` uses), the **tree** from `enumerateEdges`, and the **facts** from `authz_assignments` / `authz_denies` — each assignment as its `assignee` tuple plus the two (c2) edges, each deny as `scope:<key>#denied_<P>`. It is **idempotent** (a second pass writes zero — the word the requirement used, and it has its case), **resumable** (the source is read in batches of 100 with a cursor over the primary key; repeating a pass converges) and **never silent**: the report carries `{ written, updated, unchanged, extra, deleted, skipped{reason} }` per phase, plus each row that did not migrate with its reason (`unknown-scope`, `unknown-role`, `unknown-permission`, `unknown-holder-type`, `expired`, `role-not-visible`, `cycle`, …).
743
1066
 
744
1067
  **The source is read in one consistent snapshot — in the direction where the source is `authz_*` (2.3).** The two sweeps (`authz_assignments`, then `authz_denies`) used to run on the global connection, one after the other, so the gap between them — the time it takes to walk the first table in batches of 100 — let *composite* business operations slip through. An offboarding is `revoke` + `removeDeny`: landing in that gap, the pass kept one half of each and wrote **the role without its deny** into the destination, granting a permission that **neither the previous nor the following state granted**, while the report said `written=13 extra=0 skipped={} clean=true`. That is not a lost permission, it is a fabricated one — an escalation, with nothing in the report to distrust. Both sweeps now run inside **one repeatable-read transaction**, so the worst outcome of the window is *the consistent state of `t0`* — recoverable drift that the next pass repairs — instead of a state that never existed. What each engine guarantees is not the same and is stated rather than assumed: PostgreSQL takes the snapshot with `BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ`; MySQL/InnoDB is sent `SET TRANSACTION ISOLATION LEVEL REPEATABLE READ` explicitly (it is InnoDB's default, but a server setting is not a promise of this package) and fixes the consistent read on the first query; SQLite takes no isolation level at all (knex warns and ignores it) because a read transaction is already a snapshot there, so the package does not send it one.
@@ -747,6 +1070,10 @@ What one pass does, in order: the **root marker** (`scope:app#rooted`, without w
747
1070
 
748
1071
  **What it deletes on its own, and what needs `--prune`.** The root marker, the catalog projection and the tree are mirrors of local data nobody else writes, so whatever is left over goes — that is what repairs a scope that ended up with **two parents** in the store (the drift `scopes.moved` refuses to guess about) and what removes edges your tree no longer backs. **Facts are only deleted with `--prune`**: the facts of a scope that no longer resolves (the ones that would grant again if the scope came back with the same uuid) and anything an older version of the store left behind. One deliberate exception: a `scope#binding` edge that your source backs but whose visibility rule says *no* (invariant 18) is deleted **without** `--prune` and counted in `drift.roleVisibility` — leaving it is fail-open, and it is exactly the write `scopes.moved` / `projectCatalogRole` lose when the relay does not get there.
749
1072
 
1073
+ **The store is SHARED, and this pass only governs its own four object types (2.4.0-alpha.9).** With `openfga`, the facts of this engine and the tuples of [`relations/`](#relations-rebac-24) live in the **same store**, and this pass has to read it whole (that is the only way to see what is left over). Until 2.4.0-alpha.8 everything it did not recognise was classified as a leftover *fact*, so **`--prune` deleted every relation tuple in the store** — 60 out of 60 measured by a consumer, the built-in `group` type included — and the destructive pass reported `clean = true` with exit 0, because it had just deleted the very thing that made it dirty. What this pass governs is now an explicit, frozen list of **object types**: `scope`, `role`, `role_binding` and the obsolete `deny_binding` (the deny of the ≤ 2.2 `resolver` mode, which `enumerateFacts` still reads and emits — so the promise that *one pass cleans a store written by the previous version* is kept whole). **Anything else is foreign**: it is not deleted, not even with `--prune`, it is not drift, and it does not enter `skipped` (which is why `clean` can be `true` again on a shared store). The classification **reads nothing at runtime** — not your relations config, not the published model, not a table: the process that runs a cutover is exactly the one that may not have that config, and a failed read must never turn somebody else's data into something deletable.
1074
+
1075
+ The report carries them in a field of their own, `foreign: { tuples, types, relations?, unknown? }` (`phases` does not grow: it is a published type), and the command prints them with their type: `ajenas: 60 tupla(s) de tipos que esta pasada NO gobierna [document (40), group (10), space (10)]`, plus `--prune ha respetado 60 tupla(s) ajenas` on the destructive pass. When `relations.config` is wired, `foreign` is split into `relations` (your declared types, plus `group`) and `unknown` (nobody claims them) — **for the report only**. The way back has no symmetric defect and never had one: the relations reconcile has always enumerated by an allowlist of types and cannot see, let alone delete, a fact (measured). Two limits worth writing down: foreign tuples still count against `--max-tuples` (the snapshot is of the *store*, not of this pass), and **the tuples of a relation type you no longer declare are swept by nobody** — take `document` out of `objectTypes` and its tuples stay in the store (`enumerateRelations` filters by the allowlist, `unrelate`/`purgeObject` are 422 by F-05, and this pass now respects them), so they **grant again if you ever re-declare that type**. The way out today is to declare the type again and run `authz:relations:reconcile --prune` **for that partition** — and two caveats before you do (audit of alpha.9): the pass refuses with `E_AUTHZ_MASS_RECONCILE_REFUSED` when the source (`database`) holds none of those tuples, and the flag that lifts that refusal (`--allow-mass-delete`) will happily empty the **live** relations of the partition you point it at, so check `--partition-type`/`--partition-uuid` twice; and the command reconciles **one partition per pass**, so a multi-tenant cleanup is a loop you write. A `--prune-undeclared` is on the 2.5 list. There is deliberately **no `--prune-foreign`**: a mass deletion of another subsystem's data, triggerable from CI, is not a flag.
1076
+
750
1077
  **`--dry-run` is the verifier, and it is read-only by contract**: same walk, same numbers, zero writes, exit 1 if there is anything to do. Run it in CI or in a cron. **`expired` does not fail the pass** (2.3): it is the migration's one *declared* loss — an expired assignment grants nothing in either driver, nothing sweeps expired rows out of the source (observable expiration without a scheduler, on purpose), and no write to the destination can "fix" it — so counting it as drift made the green verifier unreachable with real data: the first expiry pinned exit 1 forever. It is still reported (`N sin migrar por 'expired'`); it just is not drift. Expired facts *left over in the destination* are still drift (`extra-fact`), and their sweep exists: `--prune`. **There is no `--fix` and there will not be one** — a repair flag on a verifier is a grant mechanism. **And it does not freeze** (2.3): a pass that writes nothing has nothing to protect, so freezing there would buy zero and cost a write-outage mechanism fired by a cron job — which is precisely what "run it in a cron" invites. The pass that *does* write still freezes.
751
1078
 
752
1079
  **Cycles are reported, not just edge differences.** OpenFGA accepts a `parent` cycle and evaluates it, which makes inheritance bidirectional (a grant in a descendant would grant in its ancestor), so **no edge of a cycle is written**: those nodes stop reaching the root and therefore deny, and the cycle is named in the report. The **relay window** is reported too (how many tree changes are queued and unrelayed — the window in which the backend decides with the old tree) and so are the **parked** entries, which are not a window but permanent divergence.
@@ -755,7 +1082,11 @@ What one pass does, in order: the **root marker** (`scope:app#rooted`, without w
755
1082
 
756
1083
  The freeze has an **owner and a lease**. `manager.freeze(reason?, { leaseMs?, kind? })` takes the row (a live freeze held by someone else is 423 `E_AUTHZ_FREEZE_HELD`, never two owners) and returns a **token** (`{ fence, holder }`); `unfreeze(token)` only lifts the freeze whose token matches, so a nested or stale window can never lift somebody else's barrier — a `reconcile` running *inside* a frozen window runs inside it and leaves it standing. The lease (default 15 s) is renewed conditionally every `leaseMs / 3` while the freezing process lives; if that process dies (`SIGKILL`, OOM, pod eviction) **the fleet resumes writing on its own within `leaseMs`** — nobody cleans a row by hand. And the guarantee is **demonstrated, not assumed**: the writing pass reports `frozen: { durable, lapsed, leaseMs, fence }`, and `lapsed: true` — the lease was lost mid-pass (an event-loop stall longer than the lease, the database down, someone lifting the window) — means the pass is **not certified** and the command exits non-zero.
757
1084
 
758
- **What the freeze does NOT freeze, by name**: `syncAuthzCatalog` (a free function that never sees the manager — a sync during the window changes what the catalog grants), `manager.driver()` (the documented way out of *all* the manager's barriers), and your own scope-tree tables (your SQL never passes through this package). While the window is open the relay cannot drain either, so the queued-tree window *grows* with the freeze — the report counts it. Two more honest boundaries: the guarantee holds only between processes that **share the `authz_*` tables** (that is invariant 14's deployment shape; a process pointed at another database sees nothing), and it is a guarantee of **this package's manager** — a third-party driver inherits the wording but the published contract suite never checks it (`MigrationContractHarness` has no manager, no second writer and no second process).
1085
+ **What the freeze does NOT freeze, by name**: `syncAuthzCatalog` (a free function that never sees the manager — a sync during the window changes what the catalog grants), `manager.driver()` (the documented way out of *all* the manager's barriers), and your own scope-tree tables (your SQL never passes through this package). While the window is open the relay cannot drain either, so the queued-tree window *grows* with the freeze — the report counts it.
1086
+
1087
+ **What it DOES freeze since 2.4.0-alpha.2 (lot L-1): the relations engine too.** `RelationsManager.relate`/`unrelate`/`purgeObject`/`purgeSubject` pass **the same barrier** (the same row, the same function, the same retryable 503 `E_AUTHZ_FROZEN`) before validating anything; `check`/`listObjects`/`listSubjects`/`membersOf` keep answering. Until L-1 nothing in `relations/` looked at the freeze: during a cutover role writes got 503 and relation writes went in. And **the barrier is read through the engine's own connection, never through a caller's `transaction`** — that reading, introduced in 2.3 "so a pool of 1 would not deadlock", let the caller's snapshot decide whether the engine was frozen; it is gone, and `{ transaction }` now requires a pool of at least 2 (see [the tree outbox](#the-tree-outbox-and-the-relay-lag-you-are-accepting)). Two more honest boundaries: the guarantee holds only between processes that **share the `authz_*` tables** (that is invariant 14's deployment shape; a process pointed at another database sees nothing), and it is a guarantee of **this package's manager** — a third-party driver inherits the wording but the published contract suite never checks it (`MigrationContractHarness` has no manager, no second writer and no second process).
1088
+
1089
+ **And `{ transaction }` neither shortens nor skips the window (2.4.0-alpha.2, lot L-6).** The authority is read through the engine's own connection, so **a snapshot taken before the freeze does not skip it** — a transaction of yours opened before the freeze gets the same retryable 503 as everyone else (pinned on the three engines, with the barrier's own deadline on a pool of 1). What changes is the *other* arm of the sentence above: a write that had passed its barrier before the freeze landed still lands, and with `{ transaction }` it lands **when your transaction commits** — the window between barrier and commit is now **as long as your transaction**, minutes if you keep it open, at your discretion. The freeze still promises "another process gets a retryable 503", never "no write enters the window", and what it does NOT freeze does not change: `syncAuthzCatalog`, `manager.driver()` and your own scope-tree tables.
759
1090
 
760
1091
  ### The cutover window: `authz:freeze` / `authz:unfreeze` (2.3)
761
1092
 
@@ -764,19 +1095,40 @@ The dangerous interval is not the pass: it is **[end of the last pass → the la
764
1095
  ```bash
765
1096
  node ace authz:freeze --reason="cutover to openfga" # open the window: fleet-wide 503 on writes
766
1097
  node ace authz:reconcile --to=openfga --from=database # the pass RECOGNISES the operator window
767
- # … switch config.default, redeploy, verify …
1098
+ # if you use relations/: one pass PER PARTITION, inside the same window
1099
+ node ace authz:relations:reconcile --to=openfga --from=database [--partition-type=<t> --partition-uuid=<id>]
1100
+ # VERIFY HERE, before switching config.default: openfga is not active yet, so this is a plain verifier
1101
+ node ace authz:reconcile --to=openfga --from=database --dry-run
1102
+ node ace authz:relations:reconcile --to=openfga --from=database --dry-run [--partition-type=<t> --partition-uuid=<id>]
1103
+ # … only when both come out clean: switch config.default (and relations.default, if you set it), redeploy …
1104
+ node ace authz:reconcile --to=openfga --dry-run # after the switch: the MAINTENANCE verifier, no --from
768
1105
  node ace authz:unfreeze # close the window
769
1106
  ```
770
1107
 
1108
+ **Verify before the switch, never with `--from` after it (2.4.0-alpha.10).** Once `config.default` is `openfga`,
1109
+ `--to=openfga --from=database` is the stale direction by definition — even inside the same live operator window,
1110
+ where nobody has written since the migration — so its `--dry-run` comes out as drift (exit 1) and the real pass is
1111
+ 500 `E_AUTHZ_STALE_SOURCE_REFUSED`. There is deliberately **no exception for a live window**: a check that is only
1112
+ green in one narrow moment teaches operators to reach for `--allow-stale-source`, which is the one flag that must stay
1113
+ rare. So the runbook verifies while the destination is not active yet, and after the switch the roles verifier is the
1114
+ maintenance pass (`--to=openfga --dry-run`, no `--from`: it reads the store itself). Relations have no maintenance
1115
+ pass: their verification is the pre-switch `--dry-run`.
1116
+
1117
+ **Relations move with `config.default` (2.4.0-alpha.10).** The relations service resolves its driver as **`relations.default ?? config.default`**: if you did not set `relations.default`, switching `config.default` switches `relations/` too — and a store with zero relation tuples denies every `check` (fail-closed, total), while `authz:reconcile --to=openfga --dry-run` comes out clean (it does not govern relation tuples). So the relations pass belongs to the cutover window, **one partition per pass** (`authz:relations:reconcile` is per partition; a multi-tenant migration is a loop you write), before the switch. If you set `relations.default`, it is that key — not `config.default` — that decides which relations driver serves, and the stale-direction guard of `authz:relations:reconcile` uses the same resolution: once `relations.default ?? config.default` is the destination, no other source is accepted (500 `E_AUTHZ_STALE_SOURCE_REFUSED`, with or without `--from`, unless `--allow-stale-source`; `--dry-run` flags it). There is no maintenance pass for relations: after the cutover the active relations driver *is* the source of truth.
1118
+
771
1119
  `authz:reconcile` treats a live **operator** freeze as its own context: it runs inside it, does not take a second freeze, does not renew it and does not lift it when the pass ends — and its report's `frozen.fence` names the window it ran in (with `lapsed: true` if the window did not survive the whole pass). A live freeze of *another* pass is 423 instead: two migrations never interleave. **The operator window does not expire by default** — a cutover has no known duration in advance (window length and outage tolerance are independent magnitudes, the same argument that killed a fixed TTL), and a window that expires mid-cutover silently hands back exactly the fail-open this mechanism exists to close. The declared price: forget `authz:unfreeze` and the fleet cannot write until someone runs it — a *loud* incident (every 503 names the reason and the command that lifts it), not an invisible loss. `--lease-ms` is the opposite opt-in: the window lifts itself after that many milliseconds with nobody renewing it (the command has exited), and *its* declared price is that a cutover slower than the lease resumes the writers silently, mid-cutover. `authz:unfreeze` lifts an operator window; it refuses to lift a live pass's freeze unless you pass `--fence=<n>` (the explicit human decision for a process that died without a lease — and a stale fence lifts nothing).
772
1120
 
773
- **`--prune` refuses to run blind.** If it would delete facts while the source has not contributed a single **usable** one, it stops with 500 `E_AUTHZ_MASS_RECONCILE_REFUSED` before writing anything: that is the signature of a wrong connection, of the wrong source, or of a resolver that no longer resolves any of the source's scopes. Until 2.3 the guard looked at the *raw* count — incremented before each skip — so a source whose facts were **all discarded** (every one expired, or on scopes your tree no longer knows) disarmed it and `--prune` emptied the destination with a green report; now a fact only counts if it actually backs something the pass would keep, and the error says how many were read and discarded. `--allow-mass-delete` is the human decision; `--dry-run` never throws, it flags it. A source that is merely **stale** but still partially usable is not this guard's job: what protects you there is the rule above (the facts are read from whoever owns them).
1121
+ **An expired operator window is evidence, not free space (2.4.0-alpha.10).** A `reconcile` (roles or relations) — or any `freeze()` — that finds the operator window **expired** no longer takes a freeze of its own over it: until 2.4.0-alpha.9 it certified itself with its own 15 s window and, on release, cleared the row, erasing the only trace that writers had resumed mid-cutover. It is now 503 `E_AUTHZ_OPERATOR_WINDOW_EXPIRED` naming when the window expired; acknowledge it with `authz:unfreeze` and reopen it with `authz:freeze`. (An expired freeze of a *pass* is still taken over — that is the lease after a `SIGKILL`.) And both reconcile commands now always say **which window** the pass ran in: inside the operator window (fence N), or a window of its own because no operator window was live — until alpha.9 those two outputs were identical.
1122
+
1123
+ **`authz:unfreeze` on a window that already EXPIRED is an error, not a no-op (2.4.0-alpha.10).** A window opened with `--lease-ms` that expired before `authz:unfreeze` used to look exactly like "there was no window" and the command said success — the last step of the cutover hid that the writers had resumed halfway, i.e. writes the new engine may not have seen. The row tells them apart (lifting clears the reason; an expired window keeps it, with its expiry in the past), so `authz:unfreeze` now prints an error naming when the window expired (ISO) and for how long the fleet has been writing, **exits non-zero**, and clears the row — a second `authz:unfreeze` is a plain success, and "there never was a window / already lifted" is still success. `manager.lastFreezeStatus()` is the reader that also returns the expired row (`expired: true`); `freezeStatus()` still returns only a live freeze. Don't call the cutover done: repeat the migration inside a window without expiry. And a pass that runs inside an operator window with an expiry now says so: `report.frozen.untilMs` is the window's expiry and `frozen.leaseMs` what it had left when the pass adopted it (`null` only for a window that does not expire).
1124
+
1125
+ **`--prune` refuses to run blind.** If it would delete facts while the source has not contributed a single **usable** one, it stops with 500 `E_AUTHZ_MASS_RECONCILE_REFUSED` before writing anything: that is the signature of a wrong connection, of the wrong source, or of a resolver that no longer resolves any of the source's scopes. Until 2.3 the guard looked at the *raw* count — incremented before each skip — so a source whose facts were **all discarded** (every one expired, or on scopes your tree no longer knows) disarmed it and `--prune` emptied the destination with a green report; now a fact only counts if it actually backs something the pass would keep, and the error says how many were read and discarded. `--allow-mass-delete` is the human decision; `--dry-run` never throws, it flags it. A source that is merely **stale** but still partially usable is not this guard's job: what protects you there is the rule above (the facts are read from whoever owns them) and, when `--from` points at a stale source towards the active driver, `E_AUTHZ_STALE_SOURCE_REFUSED`.
774
1126
 
775
1127
  **The declared cap on the dump (2.3).** Reconciling means comparing against the **whole** state of the destination — without that snapshot there is no way to know what is left over, and "what is left over" is half the job. The *source* is read in batches with a cursor; the destination snapshot is not, and it is held in memory. That is declared rather than hidden: above `--max-tuples` (default 1 000 000) the pass refuses with 500 `E_AUTHZ_RECONCILE_TOO_LARGE` **before writing anything**, naming the cap. There is no partitioned migration in this version, and "resumable" means *idempotent and repeatable* — a pass that was interrupted is resumed by running it again; the second one converges and what was already written comes back as `unchanged` — not "a cursor persisted between runs".
776
1128
 
777
1129
  **Migration is not lossless, and the losses are declared.** Run `runMigrationContract` (below) against your own pair: it seeds a fixed fixture, asks **448 identical questions** on both ends and fails if a single answer changes without a declared loss, if the pass counted a skip you did not declare, or if a seeded fact is simply **missing from the destination** with nothing declaring it — that last one is the census, and it does not depend on any of the 448 moving.
778
1130
 
779
- **The way out of a store written by the previous version.** After 2.3 "a store written by the previous version is not read by this one": the old importer wrote assignments without the two (c2) edges and denies as `deny_binding` objects, a type the current model does not declare. `authz:reconcile --to=openfga --from=database --prune` rebuilds the store from `authz_*` — the source of truth *of that migration*, named out loud because the store you are rebuilding may already be the live one (see [whoever owns the facts](#migrating-and-verifying-authzreconcile-23)) — and clears the leftovers — a tuple whose type the model no longer declares can still be read and deleted (measured against the server). That store grants again after one pass.
1131
+ **The way out of a store written by the previous version.** After 2.3 "a store written by the previous version is not read by this one": the old importer wrote assignments without the two (c2) edges and denies as `deny_binding` objects, a type the current model does not declare. `authz:reconcile --to=openfga --from=database --prune` rebuilds the store from `authz_*` — the source of truth *of that migration*, named out loud — and clears the leftovers — a tuple whose type the model no longer declares can still be read and deleted (measured against the server). That store grants again after one pass. **Run it while `database` is still `config.default`** (2.4.0-alpha.10): if `openfga` is already the active driver, that command is the stale direction and the manager refuses it with 500 `E_AUTHZ_STALE_SOURCE_REFUSED` before reading or freezing anything — `authz_*` stopped seeing the store's writes at the cutover, so rebuilding from them would resurrect every revocation since (and `--prune` would delete what was granted since). If the store is already live *and* you know for a fact that nothing has been written to it since — a store written by the previous version that nobody served from — `--allow-stale-source` is the explicit human decision; run the same command with `--dry-run` first, which flags `report.staleSource` instead of throwing.
780
1132
 
781
1133
  ### Operational notes for this driver
782
1134
 
@@ -821,6 +1173,32 @@ legal for the `database` driver, which has no such ceiling — the same tree is
821
1173
  503 in the other, and it is a DoS within reach of whoever can nest sub-scopes. Raise
822
1174
  `OPENFGA_RESOLVE_NODE_LIMIT` on the server if your tree is deeper.
823
1175
 
1176
+ ### Limits
1177
+
1178
+ Every bound the package enforces or measured, in one place, each with the constant or option that carries it — the suite imports those constants and asserts that **these numbers are the ones in the code** (a constant that changes without this table turns red). What lies past each bound is written next to it, because "past the limit" is never a silent `false` here.
1179
+
1180
+ | Limit | Constant / option | Value | Where | Past it |
1181
+ |---|---|---|---|---|
1182
+ | Scope-chain depth the `facts` model resolves reliably (`parent` hops from a scope to `app`) | `FACTS_MAX_RESOLVE_DEPTH` | 22 | `openfga` only, `database` has no ceiling | `authorize` is 503, never `false`, for the whole subtree below (measured: 23 fails between 4 % and 26 % of the time, 24 always). Raise `OPENFGA_RESOLVE_NODE_LIMIT` on the server if your tree is deeper — and declare `scopes.maxDepth`, below |
1183
+ | Depth gate on writes (opt-in, 2.4.0-alpha.5) | `scopes.maxDepth` | none by default; set it to 22 for `facts` | the manager, `scopes.attached`/`scopes.moved`, every driver | 422 `E_AUTHZ_SCOPE_DEPTH_LIMIT` **before** writing or enqueueing; see below |
1184
+ | Authorization-model size | `FACTS_MODEL_MAX_BYTES` | 262,144 | `openfga`, `syncAuthzCatalog` | 500 `E_AUTHZ_MODEL_TOO_LARGE` before writing, warning past 80 % (`FACTS_MODEL_WARN_RATIO`); how many permissions those bytes are is [not a property of the model](#the-model-ceiling-is-bytes-not-permissions) |
1185
+ | Checks per `batchCheck` | `FGA_MAX_BATCH_CHECK` | 50 | `openfga`, `authorizeMany` | the driver splits into batches of 50 (the SDK's cap), nothing to do |
1186
+ | `Read` page size (enumerations, `enumerateFacts`, `enumerateRelations`) | — | 100 | `openfga` | the server's hard cap: `pageSize: 1000` is a 400; the driver pages until the continuation token is empty — a page with zero tuples **and** a token is not the end; a token that repeats, more than 10,000 pages, or a response without `tuples` is 503 `E_AUTHZ_BACKEND_UNAVAILABLE`, never a shorter enumeration |
1187
+ | `enumerateRelations(partition)` / `enumerateRelationsMany(partitions)` cost | — | O(tuples in the store) **per sweep** — one sweep per `enumerateRelations` call, **one sweep for K partitions** with `enumerateRelationsMany` / `runRelationsReconcile(…, { partitions })` (2.4.0-alpha.6) | `openfga`, the *source* of `authz:relations:reconcile` | there is no prefix `Read` in OpenFGA (a prefix object answers 200 with **zero** tuples, measured: never "optimise" it that way — the suite spies the filter of every `read`, it is always `{}`); measured 15,000 tuples ⇒ 151 pages ≈ 0.5 s per sweep whatever the partition size; 3 partitions in a 5,000-tuple store: **150 reads / 502–558 ms** as three sweeps → **50 reads / 170–209 ms** as one ([the table](#the-source-sweep-measured)). Run it from a job or a cron, **never from a tenant request** |
1188
+ | Hops the `only` probe walks back to inherit a block's origin | `RELAY_ORIGIN_MAX_HOPS` | 100 | the manager, `relayScopeChanges({ only })` | the origin is taken as a real block and the entry stays `deferred` (the purge waits for a full pass); one `earlierPending` query per hop |
1189
+ | Interval between `acquire()` retries under `waitForLeaseMs` | `RELAY_LEASE_POLL_MS` | 25 ms | the manager, `relayScopeChanges({ waitForLeaseMs })` and `requeueScopeChanges({ apply, waitForLeaseMs })` | the budget is spent in polls of this size; past it the pass reports `busy` (default `waitForLeaseMs` is 0: one attempt) |
1190
+ | Scopes a `pending()` pass reads per round trip | `DEFAULT_RELAY_BATCH` | 100 | `authz:scopes:relay` (`--batch-size`) | the pass keeps paging |
1191
+ | Changes one relay pass applies | `DEFAULT_RELAY_LIMIT` | 10,000 | `authz:scopes:relay` (`--limit`) | the rest stays pending (`remaining: true`); run again |
1192
+ | Failed attempts before an outbox entry is parked | `maxAttempts` | 5 | `sqlScopeOutbox` | the entry leaves `pending()`, shows in `dead()` on every pass and the command exits ≠ 0; a 503 of the backend does not count; `authz:scopes:requeue --id` brings it back |
1193
+ | Longest wait for the relay lease (`waitForLeaseMs`) | `RELAY_WAIT_LEASE_MAX_MS` | 60,000 | `relayScopeChanges` / `requeueScopeChanges({ apply })` | 500 `E_AUTHZ_CONFIG` above it — a wait without a cap hangs the tenant's request; shorten the pass (`only`, `limit`) instead. The wait is counted in polls of `RELAY_LEASE_POLL_MS`, so it ends even under a frozen injected clock |
1194
+ | Parked outbox entries the relay can census per pass | `PARKED_CENSUS_MAX` | 50,000 | the manager, every relay pass (read in full, whatever `--limit`) | 500 `E_AUTHZ_CONFIG` before anything is applied — the relay of **every** tenant stops, loudly, never a blind plug. Measured cost of the census under the lease: 1,000 parked ⇒ 16 ms, 10,000 ⇒ 69 ms, 49,000 ⇒ 199 ms per pass. The way out is below (*When the parked set grows*) |
1195
+ | Answer size of `authorizedScopes` / `expandExcludedSubtrees` | `maxScopes` | 1,000 | the manager | 422 `E_AUTHZ_TOO_MANY_SCOPES`, never a partial list (per call it can only be lowered) |
1196
+ | Nodes one `descendantsOf` call may return | `maxDescendants` | 10,000 | the manager (`maxNodes` handed to your resolver) | 422 `E_AUTHZ_TOO_MANY_SCOPES`; both bounds are capped at `MAX_SCOPE_BOUND` (10,000,000) |
1197
+ | Local roles a `prune-orphans` pass may read | `maxLocalRoles` | 10,000 | `pruneOrphanRoles` | 500 `E_AUTHZ_TOO_MANY_LOCAL_ROLES`, never a partial list |
1198
+ | Ancestors `hierarchicalScopeResolver` walks | `maxDepth` (resolver option) | 64 in the README example | your `resolveChain` | 500 `E_AUTHZ_SCOPE_TOO_DEEP`, never a truncated chain |
1199
+
1200
+ **`scopes.maxDepth` — the depth gate (2.4.0-alpha.5).** Declare it in `config.scopes` and `scopes.attached`/`scopes.moved` refuse, with **422 `E_AUTHZ_SCOPE_DEPTH_LIMIT`**, any child that would end up more than `maxDepth` `parent` hops from `app` — the parent's chain plus one — **before writing or enqueueing** (zero driver calls, zero outbox rows) and at zero extra I/O: the parent's chain is already resolved for the cycle check. Without the option there is no gate: a tree of any depth stays legal, and the `database` driver pays nothing. It lives in the config and not in `capabilities()` because a capability is a boolean by doctrine (`capabilities()` normalises with `=== true`); and it is the same knob `authz:reconcile` uses to report chains above the ceiling as drift (`drift.tooDeep`, exit ≠ 0 — a scope legal in `database` and a 503 in `openfga` belongs in CI, not in the first tenant that reaches it; without the option the verifier uses `FACTS_MAX_RESOLVE_DEPTH`). Set it to `22` for the `facts` model: then every question stays within the measured ceiling whichever ancestor carries the binding. **How to size it: `maxDepth` counts `parent` hops from the child to `app`, so a tree of N levels below `app` needs `scopes.maxDepth = N`** — not N + 1: the parent's chain includes `app`, but a child at exactly `maxDepth` hops *enters* (`chain.length > maxDepth` is the refusal). Pinned by an executable case (`scope_depth.spec.ts`): a tree of exactly three levels (organization → unit → sub-unit), `maxDepth: 2` refuses the third level with 422, `maxDepth: 3` lets it in. The 422's `message` is for the tenant (depth, `scopes.maxDepth`, nothing written or enqueued); the reason the ceiling exists goes to `error.details` (2.4.0-alpha.8). **The gate is **partial**, and sold as such**: it looks at the *destination* of the child, not at the height of the subtree a `moved` drags along — a subtree of height *h* moved under a parent at depth *d* leaves its leaves at *d + h + 1*, and the gate does not see them; measuring that would need `descendantsOf` on the write path, which is forbidden. The reconcile report is what catches those afterwards. Why a gate at all: past the ceiling the model answers 503 for the whole subtree (a DoS within reach of whoever can nest sub-scopes), and the cost of each relayed `attached` grows with the depth of its neighbour (measured ×5.8 from depth 1 to 34) on the queue every tenant shares.
1201
+
824
1202
  ### Operational notes for the SQL engines
825
1203
 
826
1204
  The published migration (`stubs/migration.stub`) carries three decisions that were **observed** by running the suite on PostgreSQL and MySQL, not guessed — each one was a red test first — plus, since 2.2, `authz_roles.owner_scope_key` (`varchar(80)`, byte-wise like the other identity columns, `DEFAULT 'global'`, in the role unique index) and `authz_permissions.assignable_at` (see [Scoped roles](#scoped-roles-22)). The suite also **executes** the migration on a scratch database of each engine and compares what the engine reports for every column (type, length, precision, nullability, collation) with the schema the tests run on (*"el esquema que CONSTRUYE el stub y el espejo del harness son el mismo"*).
@@ -857,7 +1235,8 @@ changes:
857
1235
  - **`authz_relations` and `authz_relations_config`** — the ReBAC tables of 2.4. A fresh install gets
858
1236
  them from the published forward migration (`node ace configure` publishes all eight tables); the
859
1237
  ALTER recipe below does **not** create them, because they are new tables, not a transformation of
860
- 1.x ones.
1238
+ 1.x ones. `authz_relations.expires_at` (`DATETIME(3)`, nullable) arrived in **2.4.0-alpha.2**: see
1239
+ [the alpha.1 → alpha.2 recipe](#upgrading-from-240-alpha1-authz_relationsexpires_at).
861
1240
  - Identity columns become **`varchar(64)` `collate utf8mb4_bin`** (not `uuid`), so a non-UUID id
862
1241
  (`user-42`, a ULID) is valid and case is compared byte-wise; `expires_at` becomes **`DATETIME(3)`**
863
1242
  (millisecond-exact, valid past 2038). Each was a red test first — see [Operational notes for the SQL
@@ -940,6 +1319,94 @@ ALTER TABLE authz_roles
940
1319
  ALTER TABLE authz_permissions ADD COLUMN assignable_at varchar(500) NULL;
941
1320
  ```
942
1321
 
1322
+ ### Upgrading from 2.4.0-alpha.1: `authz_relations.expires_at`
1323
+
1324
+ An installation that migrated 2.4.0-alpha.1 runs **both** recipes below, **in this order** — `expires_at` first (additive, R-15), then `subject_relation` (lot L-4b) — in the **same deploy** as the package upgrade: until the second one is applied, every holder `relate` is refused by the old partition trigger (measured, below). Both are executed verbatim by the suite on the three engines.
1325
+
1326
+ Relation expiry (R-15) adds **one nullable column** to `authz_relations`; nothing else changes and
1327
+ existing rows keep granting (NULL = no expiry). It is the same type decision as
1328
+ `authz_assignments.expires_at` (millisecond precision, valid past 2038 — 2.5 · J3). A Lucid
1329
+ migration does it engine-agnostically:
1330
+
1331
+ ```ts
1332
+ this.schema.alterTable('authz_relations', (table) => {
1333
+ table.datetime('expires_at', { precision: 3 }).nullable()
1334
+ })
1335
+ ```
1336
+
1337
+ Or by hand:
1338
+
1339
+ ```sql
1340
+ -- PostgreSQL
1341
+ ALTER TABLE authz_relations ADD COLUMN expires_at timestamptz(3) NULL;
1342
+ -- MySQL
1343
+ ALTER TABLE authz_relations ADD COLUMN expires_at datetime(3) NULL;
1344
+ -- SQLite
1345
+ ALTER TABLE authz_relations ADD COLUMN expires_at datetime NULL;
1346
+ ```
1347
+
1348
+ The `openfga` driver needs **no store migration**: the fused model gains `with not_expired` on every
1349
+ relation subject, so republish it (`node ace openfga:provision --store-id <id>` with your
1350
+ `relations.config` loaded, or any `saveRelationsConfig`/catalog sync that republishes the fused model)
1351
+ — existing tuples have no condition and keep granting without expiry.
1352
+
1353
+ ### Upgrading from 2.4.0-alpha.2 (before L-4b): `authz_relations.subject_relation`
1354
+
1355
+ Lot L-4b makes `subject_relation` **`NOT NULL DEFAULT ''`** (`''` = holder, a value = userset) so the unique
1356
+ index `authz_rel_tuple_uq` guards holder tuples too — with `NULL` it only guarded usersets, because every
1357
+ engine treats two `NULL`s as distinct, and two concurrent `relate`s of the same holder committed two identical
1358
+ rows (see [writing relations inside your transaction](#relations-rebac-24)). The `database` driver now writes
1359
+ `''` and **tolerates an old `NULL` row on read** (it is a holder: `check`/`list*`/`membersOf` count it, `relate`
1360
+ of the same holder finds it and is a no-op, `unrelate`/`purgeSubject` delete it). **But the recipe belongs to the
1361
+ same deploy as the package upgrade, not "later"**: until step 4 the *old* partition trigger (`IS NOT NULL`)
1362
+ fires on every holder the new driver inserts (`''` with `subject_partition` NULL), so **every holder `relate`
1363
+ is refused** — 503 `E_AUTHZ_BACKEND_UNAVAILABLE` carrying the trigger's message, fail-closed, the driver's
1364
+ internal transaction undone (measured on the three engines). Reads and deletes keep working; the census counts
1365
+ the old duplicates until you run it. The recipe is **idempotent** (the suite runs it twice). Four steps, **in
1366
+ this order**: **re-create the partition trigger first** — its "is a userset" test becomes
1367
+ `subject_relation <> ''`; the old `IS NOT NULL` fires on `''`, so with it in place *the backfill itself is
1368
+ refused* by the `BEFORE UPDATE` trigger (measured on the three engines) —, then **de-duplicate** the holder
1369
+ rows the old index let through (keeps the oldest `uuid` per tuple; without it the backfill hits the unique
1370
+ index), then **backfill** `NULL` → `''`, then **alter** the column. One statement per line; the trigger bodies
1371
+ carry `;` inside, so run each line **whole** (never through a runner that splits on `;`). The suite executes
1372
+ each block verbatim on its engine (`upgrade_recipe.spec`).
1373
+
1374
+ ```sql
1375
+ -- L-4b · PostgreSQL
1376
+ CREATE OR REPLACE FUNCTION authz_relations_partition_guard() RETURNS trigger LANGUAGE plpgsql AS $authz$ BEGIN IF NEW.subject_relation <> '' AND NEW.subject_partition IS DISTINCT FROM NEW.partition_key THEN RAISE EXCEPTION 'authz_relations: un userset no puede pertenecer a otra partición que su tupla (subject_partition=%, partition_key=%)', NEW.subject_partition, NEW.partition_key; END IF; RETURN NEW; END; $authz$;
1377
+ DELETE FROM authz_relations WHERE subject_relation IS NULL AND CAST(uuid AS CHAR(36)) NOT IN (SELECT keep FROM (SELECT MIN(CAST(uuid AS CHAR(36))) AS keep FROM authz_relations WHERE subject_relation IS NULL GROUP BY partition_key, object_type, object_uuid, relation, subject_type, subject_uuid) AS k);
1378
+ UPDATE authz_relations SET subject_relation = '' WHERE subject_relation IS NULL;
1379
+ ALTER TABLE authz_relations ALTER COLUMN subject_relation SET DEFAULT '', ALTER COLUMN subject_relation SET NOT NULL;
1380
+ ```
1381
+
1382
+ ```sql
1383
+ -- L-4b · MySQL
1384
+ DROP TRIGGER IF EXISTS authz_relations_partition_bi;
1385
+ DROP TRIGGER IF EXISTS authz_relations_partition_bu;
1386
+ CREATE TRIGGER authz_relations_partition_bi BEFORE INSERT ON authz_relations FOR EACH ROW BEGIN IF NEW.subject_relation <> '' AND NOT (NEW.subject_partition <=> NEW.partition_key) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'authz_relations: un userset no puede pertenecer a otra partición que su tupla'; END IF; END;
1387
+ CREATE TRIGGER authz_relations_partition_bu BEFORE UPDATE ON authz_relations FOR EACH ROW BEGIN IF NEW.subject_relation <> '' AND NOT (NEW.subject_partition <=> NEW.partition_key) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'authz_relations: un userset no puede pertenecer a otra partición que su tupla'; END IF; END;
1388
+ DELETE FROM authz_relations WHERE subject_relation IS NULL AND CAST(uuid AS CHAR(36)) NOT IN (SELECT keep FROM (SELECT MIN(CAST(uuid AS CHAR(36))) AS keep FROM authz_relations WHERE subject_relation IS NULL GROUP BY partition_key, object_type, object_uuid, relation, subject_type, subject_uuid) AS k);
1389
+ UPDATE authz_relations SET subject_relation = '' WHERE subject_relation IS NULL;
1390
+ ALTER TABLE authz_relations MODIFY subject_relation varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '';
1391
+ ```
1392
+
1393
+ ```sql
1394
+ -- L-4b · SQLite
1395
+ DROP TRIGGER IF EXISTS authz_relations_partition_bi;
1396
+ DROP TRIGGER IF EXISTS authz_relations_partition_bu;
1397
+ CREATE TRIGGER authz_relations_partition_bi BEFORE INSERT ON authz_relations FOR EACH ROW WHEN NEW.subject_relation <> '' AND NEW.subject_partition IS NOT NEW.partition_key BEGIN SELECT RAISE(ABORT, 'authz_relations: un userset no puede pertenecer a otra partición que su tupla'); END;
1398
+ CREATE TRIGGER authz_relations_partition_bu BEFORE UPDATE ON authz_relations FOR EACH ROW WHEN NEW.subject_relation <> '' AND NEW.subject_partition IS NOT NEW.partition_key BEGIN SELECT RAISE(ABORT, 'authz_relations: un userset no puede pertenecer a otra partición que su tupla'); END;
1399
+ DELETE FROM authz_relations WHERE subject_relation IS NULL AND CAST(uuid AS CHAR(36)) NOT IN (SELECT keep FROM (SELECT MIN(CAST(uuid AS CHAR(36))) AS keep FROM authz_relations WHERE subject_relation IS NULL GROUP BY partition_key, object_type, object_uuid, relation, subject_type, subject_uuid) AS k);
1400
+ UPDATE authz_relations SET subject_relation = '' WHERE subject_relation IS NULL;
1401
+ ```
1402
+
1403
+ **SQLite has no `ALTER COLUMN`**: after the backfill the column stays nullable there (the suite measures that
1404
+ this is the *only* difference from the published schema). The driver never writes `NULL` and the unique index
1405
+ already guards `'' = ''`, so the guarantee holds; if you want the constraint itself, recreate the table with the
1406
+ DDL of the published migration (`stubs/migration.stub`) and copy the rows. The de-duplication keeps the oldest
1407
+ row per tuple: if the two rows raced with different expiries, the survivor is the first written. The `openfga`
1408
+ driver needs nothing — a tuple is unique by construction.
1409
+
943
1410
  ## Compatibility
944
1411
 
945
1412
  | | |