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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (224) hide show
  1. package/README.md +934 -40
  2. package/build/commands/authz_catalog_diff.d.ts.map +1 -1
  3. package/build/commands/authz_catalog_diff.js +1 -2
  4. package/build/commands/authz_catalog_diff.js.map +1 -1
  5. package/build/commands/authz_catalog_prune_orphans.d.ts +78 -0
  6. package/build/commands/authz_catalog_prune_orphans.d.ts.map +1 -0
  7. package/build/commands/authz_catalog_prune_orphans.js +135 -0
  8. package/build/commands/authz_catalog_prune_orphans.js.map +1 -0
  9. package/build/commands/authz_catalog_sync.d.ts +17 -0
  10. package/build/commands/authz_catalog_sync.d.ts.map +1 -1
  11. package/build/commands/authz_catalog_sync.js +27 -4
  12. package/build/commands/authz_catalog_sync.js.map +1 -1
  13. package/build/commands/authz_freeze.d.ts +44 -0
  14. package/build/commands/authz_freeze.d.ts.map +1 -0
  15. package/build/commands/authz_freeze.js +94 -0
  16. package/build/commands/authz_freeze.js.map +1 -0
  17. package/build/commands/authz_reconcile.d.ts +111 -0
  18. package/build/commands/authz_reconcile.d.ts.map +1 -0
  19. package/build/commands/authz_reconcile.js +417 -0
  20. package/build/commands/authz_reconcile.js.map +1 -0
  21. package/build/commands/authz_relations_reconcile.d.ts +144 -0
  22. package/build/commands/authz_relations_reconcile.d.ts.map +1 -0
  23. package/build/commands/authz_relations_reconcile.js +438 -0
  24. package/build/commands/authz_relations_reconcile.js.map +1 -0
  25. package/build/commands/authz_scopes_relay.d.ts +97 -0
  26. package/build/commands/authz_scopes_relay.d.ts.map +1 -0
  27. package/build/commands/authz_scopes_relay.js +344 -0
  28. package/build/commands/authz_scopes_relay.js.map +1 -0
  29. package/build/commands/authz_scopes_requeue.d.ts +80 -0
  30. package/build/commands/authz_scopes_requeue.d.ts.map +1 -0
  31. package/build/commands/authz_scopes_requeue.js +230 -0
  32. package/build/commands/authz_scopes_requeue.js.map +1 -0
  33. package/build/commands/authz_unfreeze.d.ts +45 -0
  34. package/build/commands/authz_unfreeze.d.ts.map +1 -0
  35. package/build/commands/authz_unfreeze.js +157 -0
  36. package/build/commands/authz_unfreeze.js.map +1 -0
  37. package/build/commands/main.d.ts +16 -8
  38. package/build/commands/main.d.ts.map +1 -1
  39. package/build/commands/main.js +59 -6
  40. package/build/commands/main.js.map +1 -1
  41. package/build/commands/openfga_provision.d.ts +92 -4
  42. package/build/commands/openfga_provision.d.ts.map +1 -1
  43. package/build/commands/openfga_provision.js +155 -7
  44. package/build/commands/openfga_provision.js.map +1 -1
  45. package/build/configure.d.ts +11 -0
  46. package/build/configure.d.ts.map +1 -1
  47. package/build/configure.js +37 -1
  48. package/build/configure.js.map +1 -1
  49. package/build/index.d.ts +50 -11
  50. package/build/index.d.ts.map +1 -1
  51. package/build/index.js +49 -7
  52. package/build/index.js.map +1 -1
  53. package/build/providers/authz_provider.d.ts +31 -2
  54. package/build/providers/authz_provider.d.ts.map +1 -1
  55. package/build/providers/authz_provider.js +65 -2
  56. package/build/providers/authz_provider.js.map +1 -1
  57. package/build/services/relations.d.ts +14 -0
  58. package/build/services/relations.d.ts.map +1 -0
  59. package/build/services/relations.js +17 -0
  60. package/build/services/relations.js.map +1 -0
  61. package/build/src/{catalog.d.ts → catalog/catalog.d.ts} +41 -2
  62. package/build/src/catalog/catalog.d.ts.map +1 -0
  63. package/build/src/{catalog.js → catalog/catalog.js} +120 -14
  64. package/build/src/catalog/catalog.js.map +1 -0
  65. package/build/src/{catalog_cache.d.ts → catalog/catalog_cache.d.ts} +46 -22
  66. package/build/src/catalog/catalog_cache.d.ts.map +1 -0
  67. package/build/src/{catalog_cache.js → catalog/catalog_cache.js} +53 -43
  68. package/build/src/catalog/catalog_cache.js.map +1 -0
  69. package/build/src/define_config.d.ts +206 -3
  70. package/build/src/define_config.d.ts.map +1 -1
  71. package/build/src/define_config.js +11 -0
  72. package/build/src/define_config.js.map +1 -1
  73. package/build/src/drivers/database_driver.d.ts +128 -7
  74. package/build/src/drivers/database_driver.d.ts.map +1 -1
  75. package/build/src/drivers/database_driver.js +510 -24
  76. package/build/src/drivers/database_driver.js.map +1 -1
  77. package/build/src/drivers/database_relations_driver.d.ts +140 -0
  78. package/build/src/drivers/database_relations_driver.d.ts.map +1 -0
  79. package/build/src/drivers/database_relations_driver.js +748 -0
  80. package/build/src/drivers/database_relations_driver.js.map +1 -0
  81. package/build/src/drivers/openfga_driver.d.ts +777 -122
  82. package/build/src/drivers/openfga_driver.d.ts.map +1 -1
  83. package/build/src/drivers/openfga_driver.js +2228 -477
  84. package/build/src/drivers/openfga_driver.js.map +1 -1
  85. package/build/src/drivers/openfga_facts.d.ts +384 -0
  86. package/build/src/drivers/openfga_facts.d.ts.map +1 -0
  87. package/build/src/drivers/openfga_facts.js +836 -0
  88. package/build/src/drivers/openfga_facts.js.map +1 -0
  89. package/build/src/drivers/openfga_relations_driver.d.ts +186 -0
  90. package/build/src/drivers/openfga_relations_driver.d.ts.map +1 -0
  91. package/build/src/drivers/openfga_relations_driver.js +724 -0
  92. package/build/src/drivers/openfga_relations_driver.js.map +1 -0
  93. package/build/src/errors.d.ts +430 -6
  94. package/build/src/errors.d.ts.map +1 -1
  95. package/build/src/errors.js +453 -6
  96. package/build/src/errors.js.map +1 -1
  97. package/build/src/freeze.d.ts +200 -0
  98. package/build/src/freeze.d.ts.map +1 -0
  99. package/build/src/freeze.js +306 -0
  100. package/build/src/freeze.js.map +1 -0
  101. package/build/src/http/app_access_middleware.d.ts.map +1 -0
  102. package/build/src/http/app_access_middleware.js.map +1 -0
  103. package/build/src/http/resource_access_middleware.d.ts +105 -0
  104. package/build/src/http/resource_access_middleware.d.ts.map +1 -0
  105. package/build/src/http/resource_access_middleware.js +81 -0
  106. package/build/src/http/resource_access_middleware.js.map +1 -0
  107. package/build/src/identity.d.ts +74 -1
  108. package/build/src/identity.d.ts.map +1 -1
  109. package/build/src/identity.js +100 -2
  110. package/build/src/identity.js.map +1 -1
  111. package/build/src/manager.d.ts +549 -6
  112. package/build/src/manager.d.ts.map +1 -1
  113. package/build/src/manager.js +2243 -193
  114. package/build/src/manager.js.map +1 -1
  115. package/build/src/models/authz_assignment.d.ts +7 -7
  116. package/build/src/models/authz_assignment.d.ts.map +1 -1
  117. package/build/src/models/authz_deny.d.ts +7 -7
  118. package/build/src/models/authz_deny.d.ts.map +1 -1
  119. package/build/src/models/authz_permission.d.ts +7 -7
  120. package/build/src/models/authz_permission.d.ts.map +1 -1
  121. package/build/src/models/authz_role.d.ts +7 -7
  122. package/build/src/models/authz_role.d.ts.map +1 -1
  123. package/build/src/models/authz_role_permission.d.ts +7 -7
  124. package/build/src/models/authz_role_permission.d.ts.map +1 -1
  125. package/build/src/openfga.d.ts +18 -2
  126. package/build/src/openfga.d.ts.map +1 -1
  127. package/build/src/openfga.js +15 -1
  128. package/build/src/openfga.js.map +1 -1
  129. package/build/src/reconcile.d.ts +37 -0
  130. package/build/src/reconcile.d.ts.map +1 -0
  131. package/build/src/reconcile.js +69 -0
  132. package/build/src/reconcile.js.map +1 -0
  133. package/build/src/relation_partition_trigger.d.ts +8 -0
  134. package/build/src/relation_partition_trigger.d.ts.map +1 -0
  135. package/build/src/relation_partition_trigger.js +93 -0
  136. package/build/src/relation_partition_trigger.js.map +1 -0
  137. package/build/src/relations/define_relations_config.d.ts +109 -0
  138. package/build/src/relations/define_relations_config.d.ts.map +1 -0
  139. package/build/src/relations/define_relations_config.js +211 -0
  140. package/build/src/relations/define_relations_config.js.map +1 -0
  141. package/build/src/relations/manager.d.ts +138 -0
  142. package/build/src/relations/manager.d.ts.map +1 -0
  143. package/build/src/relations/manager.js +494 -0
  144. package/build/src/relations/manager.js.map +1 -0
  145. package/build/src/relations/reconcile.d.ts +137 -0
  146. package/build/src/relations/reconcile.d.ts.map +1 -0
  147. package/build/src/relations/reconcile.js +294 -0
  148. package/build/src/relations/reconcile.js.map +1 -0
  149. package/build/src/relations_config_store.d.ts +22 -0
  150. package/build/src/relations_config_store.d.ts.map +1 -0
  151. package/build/src/relations_config_store.js +74 -0
  152. package/build/src/relations_config_store.js.map +1 -0
  153. package/build/src/scope_outbox.d.ts +123 -0
  154. package/build/src/scope_outbox.d.ts.map +1 -0
  155. package/build/src/scope_outbox.js +532 -0
  156. package/build/src/scope_outbox.js.map +1 -0
  157. package/build/src/{drivers → shared}/backend_guard.d.ts +42 -0
  158. package/build/src/shared/backend_guard.d.ts.map +1 -0
  159. package/build/src/{drivers → shared}/backend_guard.js +78 -1
  160. package/build/src/shared/backend_guard.js.map +1 -0
  161. package/build/src/{drivers → shared}/sql_expiry.d.ts +2 -0
  162. package/build/src/shared/sql_expiry.d.ts.map +1 -0
  163. package/build/src/{drivers → shared}/sql_expiry.js +4 -0
  164. package/build/src/shared/sql_expiry.js.map +1 -0
  165. package/build/src/shared/transaction_guard.d.ts +53 -0
  166. package/build/src/shared/transaction_guard.d.ts.map +1 -0
  167. package/build/src/shared/transaction_guard.js +74 -0
  168. package/build/src/shared/transaction_guard.js.map +1 -0
  169. package/build/src/sql_descendants.d.ts +55 -1
  170. package/build/src/sql_descendants.d.ts.map +1 -1
  171. package/build/src/sql_descendants.js +75 -1
  172. package/build/src/sql_descendants.js.map +1 -1
  173. package/build/src/testing/contract.d.ts +129 -4
  174. package/build/src/testing/contract.d.ts.map +1 -1
  175. package/build/src/testing/contract.js +915 -177
  176. package/build/src/testing/contract.js.map +1 -1
  177. package/build/src/testing/main.d.ts +10 -2
  178. package/build/src/testing/main.d.ts.map +1 -1
  179. package/build/src/testing/main.js +5 -1
  180. package/build/src/testing/main.js.map +1 -1
  181. package/build/src/testing/migration_contract.d.ts +293 -0
  182. package/build/src/testing/migration_contract.d.ts.map +1 -0
  183. package/build/src/testing/migration_contract.js +589 -0
  184. package/build/src/testing/migration_contract.js.map +1 -0
  185. package/build/src/testing/relations_contract.d.ts +73 -0
  186. package/build/src/testing/relations_contract.d.ts.map +1 -0
  187. package/build/src/testing/relations_contract.js +1238 -0
  188. package/build/src/testing/relations_contract.js.map +1 -0
  189. package/build/src/testing/relations_reconcile_contract.d.ts +24 -0
  190. package/build/src/testing/relations_reconcile_contract.d.ts.map +1 -0
  191. package/build/src/testing/relations_reconcile_contract.js +220 -0
  192. package/build/src/testing/relations_reconcile_contract.js.map +1 -0
  193. package/build/src/testing/scope_outbox_contract.d.ts +98 -0
  194. package/build/src/testing/scope_outbox_contract.d.ts.map +1 -0
  195. package/build/src/testing/scope_outbox_contract.js +677 -0
  196. package/build/src/testing/scope_outbox_contract.js.map +1 -0
  197. package/build/src/traits/authz_scopes.js +1 -1
  198. package/build/src/traits/authz_scopes.js.map +1 -1
  199. package/build/src/traits/has_uuid.d.ts +8 -8
  200. package/build/src/traits/has_uuid.d.ts.map +1 -1
  201. package/build/src/types.d.ts +1537 -84
  202. package/build/src/types.d.ts.map +1 -1
  203. package/build/src/types.js +65 -0
  204. package/build/src/types.js.map +1 -1
  205. package/build/stubs/config/authorization.stub +149 -5
  206. package/build/stubs/migration.stub +136 -0
  207. package/build/stubs/scopes_outbox_migration.stub +57 -0
  208. package/package.json +4 -2
  209. package/build/commands/openfga_import.d.ts +0 -34
  210. package/build/commands/openfga_import.d.ts.map +0 -1
  211. package/build/commands/openfga_import.js +0 -97
  212. package/build/commands/openfga_import.js.map +0 -1
  213. package/build/src/catalog.d.ts.map +0 -1
  214. package/build/src/catalog.js.map +0 -1
  215. package/build/src/catalog_cache.d.ts.map +0 -1
  216. package/build/src/catalog_cache.js.map +0 -1
  217. package/build/src/drivers/backend_guard.d.ts.map +0 -1
  218. package/build/src/drivers/backend_guard.js.map +0 -1
  219. package/build/src/drivers/sql_expiry.d.ts.map +0 -1
  220. package/build/src/drivers/sql_expiry.js.map +0 -1
  221. package/build/src/middleware/app_access_middleware.d.ts.map +0 -1
  222. package/build/src/middleware/app_access_middleware.js.map +0 -1
  223. /package/build/src/{middleware → http}/app_access_middleware.d.ts +0 -0
  224. /package/build/src/{middleware → http}/app_access_middleware.js +0 -0
@@ -1,15 +1,19 @@
1
1
  var _a;
2
2
  import { Exception } from '@adonisjs/core/exceptions';
3
3
  import { v7 as uuidv7 } from 'uuid';
4
- import { assertCatalogUuid, assertIdentity, assertScope, assertScopeType, assertSubject, assertValidSlug, chainKeysFrom, normalizeRoleQuery, scopeFromKey, scopeKey, } from './identity.js';
4
+ import { activeRelationsDriverKey } from './define_config.js';
5
+ import { assertCatalogUuid, assertIdentity, assertScope, assertScopeType, assertSubject, assertValidSlug, chainKeysFrom, normalizeRoleQuery, scopeFromKey, scopeKey, scopeSpellings, } from './identity.js';
5
6
  import { expiryChanged } from './expiry.js';
6
- import { assertKnownScope, isAuthzError, resolveChain, rootOnlyResolver } from './drivers/backend_guard.js';
7
- import { CatalogCache, GLOBAL_OWNER_KEY, invalidateAuthzCatalog, isRoleVisibleWith, readRolesOwnedBy, withAuthzCatalogWrite } from './catalog_cache.js';
8
- import { assertAssignableAt } from './catalog.js';
7
+ import { randomBytes } from 'node:crypto';
8
+ import { DEFAULT_FREEZE_LEASE_MS, acquireFreezeOutcome, UNKNOWN_HOLDER, freezeIsLive, freezeKindOf, readFreezeRow, assertNotFrozenRow, releaseFreeze, renewFreeze, } from './freeze.js';
9
+ import { assertKnownScope, isAuthzError, resolveChain, rootOnlyResolver } from './shared/backend_guard.js';
10
+ import { CatalogCache, GLOBAL_OWNER_KEY, invalidateAuthzCatalog, isRoleVisibleWith, readLocalRoles, withAuthzCatalogWrite } from './catalog/catalog_cache.js';
11
+ import { assertAssignableAt } from './catalog/catalog.js';
9
12
  import { systemClock } from './clock.js';
10
- import { ActorRequiredError, AuthorizationBackendTimeoutError, AuthorizationConfigError, AuthorizationInternalError, CatalogConflictError, InvalidIdentityError, NoDescendantsResolverError, NotWithinError, PermissionNotDelegableError, RankExceededError, RoleImmutableError, RoleLevelAboveOwnerError, ScopeCycleError, ScopeResolverError, TooManyScopesError, UnknownPermissionError, UnknownRoleError, UnsupportedOperationError, ViewExpiredError, WithinRequiredError, WithinRootForbiddenError, } from './errors.js';
11
- import { APP_SCOPE_TYPE } from './types.js';
13
+ import { ActorRequiredError, AuthorizationBackendError, AuthorizationBackendTimeoutError, AuthorizationFrozenError, AuthorizationConfigError, AuthorizationInternalError, CatalogConflictError, InvalidIdentityError, MassPurgeRefusedError, PruneInterruptedError, NoDescendantsResolverError, NotWithinError, PermissionNotDelegableError, RankExceededError, RoleImmutableError, RoleLevelAboveOwnerError, ScopeCycleError, ScopeResolverError, TooManyScopesError, UnknownPermissionError, UnknownRoleError, FreezeHeldError, UnsupportedOperationError, ViewExpiredError, WithinRequiredError, WithinRootForbiddenError, ScopeDriftUnguardedError, ScopeDepthLimitError, ScopeRequeueRefusedError, StaleSourceRefusedError, OperatorWindowExpiredError, } from './errors.js';
14
+ import { APP_SCOPE_TYPE, normalizeAuthorizationCapabilities } from './types.js';
12
15
  import { memoizeAncestors } from './memoize_ancestors.js';
16
+ import { AUTHZ_TABLES_ORIGIN } from './reconcile.js';
13
17
  /** Longitudes de `authz_roles.name`/`description` (el esquema publicado). */
14
18
  const ROLE_NAME_MAX = 100;
15
19
  const ROLE_DESCRIPTION_MAX = 500;
@@ -49,8 +53,59 @@ export const DEFAULT_MAX_DESCENDANTS = 10_000;
49
53
  * Una cota mayor es config rota (500), nunca una pregunta.
50
54
  */
51
55
  export const MAX_SCOPE_BOUND = 10_000_000;
56
+ /**
57
+ * Cotas por defecto de `authz:scopes:relay` (3b-2d). El lote es el tamaño de
58
+ * cada `pending()`; el límite, cuántos cambios aplica una pasada antes de
59
+ * volver (lo que quede sigue pendiente: drenar es reanudable por diseño y
60
+ * una pasada eterna no es reanudable).
61
+ */
62
+ export const DEFAULT_RELAY_BATCH = 100;
63
+ export const DEFAULT_RELAY_LIMIT = 10_000;
64
+ /** Páginas que `authz:reconcile` pasea para MEDIR la ventana del relay (3b-3a). */
65
+ const RELAY_WINDOW_MAX_PAGES = 1_000;
66
+ /**
67
+ * **Saltos que la sonda de `only` sigue hacia atrás para heredar el ORIGEN de
68
+ * un bloqueo** (2.4.0-alpha.6). Cada salto es una consulta `earlierPending`
69
+ * (0,69 ms) sobre una entrada NO pedida; la cadena es de ids estrictamente
70
+ * decrecientes, así que termina sola — la cota solo acota el coste dentro de
71
+ * una petición inline. Pasada la cota el origen se toma por FALLO (la purga se
72
+ * aplaza a la pasada completa): degrada hacia lo seguro, nunca aplica sin saber.
73
+ */
74
+ export const RELAY_ORIGIN_MAX_HOPS = 100;
75
+ /**
76
+ * **Cota PROPIA del censo de aparcadas** (alpha.5 · cierre, auditor 🟠 2). Las
77
+ * claves de `dead()` que taponan `attached`/`moved` se leen COMPLETAS en cada
78
+ * pasada, sean cuales sean `limit` y `batchSize` (con `--limit 50` o la receta
79
+ * inline `{ only, limit: 1 }` un censo acotado por `limit` dejaba a la aparcada
80
+ * 101 sin taponar y el `attached` colgaba del padre VIEJO — y la sonda de
81
+ * `only` no la ve porque `earlierPending` excluye lo aparcado). Si hay más
82
+ * aparcadas que esto, la pasada se NIEGA (500) en vez de aplicar con un tapón
83
+ * incompleto: una cola así es un incidente, no un lote.
84
+ */
85
+ export const PARKED_CENSUS_MAX = 50_000;
52
86
  /** Vida por defecto de una vista de `forRequest()` para LEER (F9): un request, no un módulo. */
53
87
  export const DEFAULT_VIEW_MAX_AGE_MS = 30_000;
88
+ /**
89
+ * Cada cuánto reintenta `acquire()` una pasada con `waitForLeaseMs` (2.4.0-alpha.8;
90
+ * panel relay 2, cuestión 7). Medido en PG: 0,29 ms por sondeo ocupado, y el
91
+ * sondeo no retiene conexión entre intentos (la transacción del intento fallido
92
+ * se revierte). El default de `waitForLeaseMs` es 0: el `busy` inmediato de
93
+ * siempre.
94
+ */
95
+ export const RELAY_LEASE_POLL_MS = 25;
96
+ /**
97
+ * Tope de `waitForLeaseMs` (cierre alpha.8, 🟡 1 del auditor): una espera sin tope
98
+ * cuelga la petición de un tenant; por encima es 500 `E_AUTHZ_CONFIG`. Y la espera
99
+ * se cuenta en SONDEOS (`waitForLeaseMs / RELAY_LEASE_POLL_MS`), no solo con el
100
+ * reloj de pared: con `config.clock` fijo el sondeo por reloj no terminaba nunca.
101
+ */
102
+ export const RELAY_WAIT_LEASE_MAX_MS = 60_000;
103
+ /** Las opciones que `relayScopeChanges` conoce (2.4.0-alpha.8 · X-1: cualquier otra es 500). */
104
+ const RELAY_OPTIONS = ['limit', 'batchSize', 'dryRun', 'only', 'waitForLeaseMs', 'deadDetail'];
105
+ /** Precedencia de los orígenes de un bloqueo por clave (2.4.0-alpha.8): un fallo real pisa todo; una pendiente pisa a un aparcado. */
106
+ const BLOCK_RANK = { parked: 0, pending: 1, failed: 2 };
107
+ /** Las opciones que `requeueScopeChanges` conoce (2.4.0-alpha.8 · X-1: cualquier otra es 500). */
108
+ const REQUEUE_OPTIONS = ['force', 'apply', 'waitForLeaseMs'];
54
109
  /** Reloj monótono del proceso: inmune a NTP, snapshots y `Date.now` parcheado. */
55
110
  const monotonicNow = () => performance.now();
56
111
  /**
@@ -76,6 +131,15 @@ export class AuthorizationManager {
76
131
  #readsUntil = null;
77
132
  /** Reloj monótono con el que se mide `#readsUntil` (inyectable solo en tests). */
78
133
  #clock = monotonicNow;
134
+ /**
135
+ * El freeze que ESTE manager sostiene (su token, su renovador), o `null`.
136
+ * Vive en el manager RAÍZ —una vista de `forRequest()` no es otro motor—
137
+ * pero desde 3b-7 el ESTADO del freeze es la fila `id = 2` de
138
+ * `authz_catalog_version`: esto es solo el lado del dueño (quién renueva y
139
+ * qué token puede levantarlo). Que la barrera alcance a las vistas y al
140
+ * resto de la FLOTA lo garantiza la fila, no esta referencia.
141
+ */
142
+ #heldFreeze = null;
79
143
  constructor(config) {
80
144
  this.#config = config;
81
145
  if (config.clock !== undefined && typeof config.clock !== 'function') {
@@ -174,9 +238,468 @@ export class AuthorizationManager {
174
238
  }
175
239
  driver = driver.withClock(clock);
176
240
  }
241
+ this.#assertTransactionalWritesRequired(driver);
242
+ this.#assertScopeDriftGuarded(driver);
177
243
  this.#driver = driver;
178
244
  return driver;
179
245
  }
246
+ /**
247
+ * **Lo que el driver ACTIVO declara, de solo lectura** (2.4.0-alpha.4;
248
+ * pregunta 1 de COGNITIV, panel alpha.4). Para que el código de tenant
249
+ * pueda ramificar —`if ((await authorization.capabilities()).transactionalWrites)
250
+ * grant(…, { transaction }) else grant(…)`— sin llamar a `driver()`, que
251
+ * sigue siendo API de PLATAFORMA (el grep del README sigue significando lo
252
+ * que significa).
253
+ *
254
+ * Es `(await this.driver()).capabilities` y NADA más, a propósito:
255
+ * - las capacidades son de la INSTANCIA (`database` decide
256
+ * `transactionalWrites` desde sus opciones), así que hay que resolver;
257
+ * - y se resuelve POR `driver()`, con sus dos puertas
258
+ * (`requireTransactionalWrites` y el gate de deriva del árbol) y su
259
+ * caché. Resolverlo por otro camino «para que la lectura no lance» y
260
+ * guardarlo en `#driver` dejaría `driver()` sirviendo un driver que
261
+ * nunca pasó por las puertas: un bypass permanente del gate de deriva
262
+ * (🔴 1 del auditor). Por eso hereda los 500 de las puertas
263
+ * (`E_AUTHZ_CONFIG`, `E_AUTHZ_SCOPE_DRIFT_UNGUARDED`) y la factory del
264
+ * consumidor: llámalo al arrancar (un hook `booted`/`ready`), no por
265
+ * request en un endpoint de metadatos.
266
+ *
267
+ * Devuelve una copia CONGELADA y NORMALIZADA: todas las claves de
268
+ * `AuthorizationDriverCapabilities` presentes, solo `true` es `true`
269
+ * (la misma lectura que la puerta 1), nunca `undefined`; mutarla no toca
270
+ * el objeto que leen las puertas. La vista de `forRequest()` delega en el
271
+ * padre (por `driver()`).
272
+ */
273
+ async capabilities() {
274
+ const driver = await this.driver();
275
+ return normalizeAuthorizationCapabilities(driver.capabilities);
276
+ }
277
+ /**
278
+ * **Puerta 2 de `{ transaction }`** (L-2, opt-in): con
279
+ * `requireTransactionalWrites: true` el driver activo tiene que declarar
280
+ * `transactionalWrites: true` o el manager falla AL RESOLVERLO — antes de
281
+ * cachearlo, así que toda lectura, toda escritura y `driver()` fallan igual:
282
+ * el despliegue no arranca. Es la forma honesta de «fallar al construirse»:
283
+ * el roadmap lo pedía incondicional y eso haría `openfga` inconstruible en
284
+ * cualquier app que solo lo tenga registrado (y el driver se resuelve
285
+ * perezosamente y por nombre, así que al construir no se sabe si alguien
286
+ * pasará `{ transaction }`). Quien quiera fallar al arrancar, lo pide.
287
+ */
288
+ #assertTransactionalWritesRequired(driver) {
289
+ if (this.#config.requireTransactionalWrites !== true)
290
+ return;
291
+ if (driver.capabilities?.transactionalWrites === true)
292
+ return;
293
+ throw new AuthorizationConfigError(`config.requireTransactionalWrites está en true y el driver '${this.#config.default}' declara ` +
294
+ `transactionalWrites: ${driver.capabilities ? String(driver.capabilities.transactionalWrites) : 'nada (sin capabilities)'}: ` +
295
+ `no puede inscribir grant/revoke/deny/removeDeny en la transacción del consumidor («los dos o ninguno»), así ` +
296
+ `que el manager no se resuelve. Usa un driver que la declare (database) o quita requireTransactionalWrites.`);
297
+ }
298
+ /**
299
+ * **Puerta 1 de `{ transaction }`** (L-2, siempre activa): una escritura de
300
+ * HECHOS con `{ transaction }` a un driver que no declara
301
+ * `transactionalWrites: true` es 500 `E_AUTHZ_UNSUPPORTED` nombrando driver
302
+ * y operación, ANTES de la barrera, de la identidad y del driver (cero
303
+ * llamadas). Nunca se ignora, nunca un `logger.warn`: aceptarla y no
304
+ * cumplirla sería publicar con nuestra firma la fuga del cruce 4 · S5.
305
+ * `scopes.*` NO pasa por aquí: su `transaction` ENCOLA (encolar ≠ escribir).
306
+ */
307
+ async #assertTransactionalWrite(options, operation) {
308
+ if (options?.transaction === undefined || options.transaction === null)
309
+ return;
310
+ const driver = await this.driver();
311
+ if (driver.capabilities?.transactionalWrites === true)
312
+ return;
313
+ throw UnsupportedOperationError.transactional(operation, this.#config.default, 'roles');
314
+ }
315
+ /**
316
+ * La marca del evento de una escritura inscrita en la transacción del
317
+ * llamante (L-3): `onWrite` se dispara cuando el DRIVER vuelve, y en ese
318
+ * momento la fila existe solo dentro de esa transacción — es un hecho si y
319
+ * solo si el llamante confirma, cosa que el paquete nunca ve. Quien audita
320
+ * lo necesita para no registrar como firme lo que un rollback deshace.
321
+ */
322
+ #transactional(options) {
323
+ return options?.transaction === undefined || options.transaction === null ? {} : { transactional: true };
324
+ }
325
+ /** La API de delegación no admite `{ transaction }` (§1.4 del veredicto `{trx}`): 500 antes de tocar nada. */
326
+ #assertNoTransaction(options, operation) {
327
+ if (options?.transaction === undefined || options.transaction === null)
328
+ return;
329
+ throw UnsupportedOperationError.transactionalCatalog(operation);
330
+ }
331
+ /**
332
+ * **El gate de deriva del árbol, en el MANAGER** (3b-2e · E3; cierra el
333
+ * agujero que declaró el 3b-2d).
334
+ *
335
+ * El driver `facts` ya se niega a construirse sin `outbox` ni firma, pero
336
+ * ese gate mira SU opción `outbox` — y quien ENCOLA es el manager, que lee
337
+ * `config.scopes.outbox`. Pasarle la instancia solo al driver dejaba el
338
+ * gate contento y la mitigación apagada: `manager.scopes.*` seguía
339
+ * escribiendo en el backend dentro de la transacción del consumidor, que
340
+ * es exactamente S5. Aquí se cierra, y se cierra porque el driver DECLARA
341
+ * su `hierarchy` (`capabilities.hierarchyFacts`, la pieza de capacidades de
342
+ * este lote).
343
+ *
344
+ * Un driver sin `capabilities` (2.x, o de terceros) se trata como
345
+ * `hierarchyFacts: false`: no hay dos árboles y no hay deriva que mitigar.
346
+ */
347
+ #assertScopeDriftGuarded(driver) {
348
+ if (!driver.capabilities?.hierarchyFacts)
349
+ return;
350
+ if (this.#config.scopes?.outbox)
351
+ return;
352
+ if (this.#config.scopes?.acceptScopeDriftRisk === true)
353
+ return;
354
+ throw new ScopeDriftUnguardedError(`El driver '${this.#config.default}' declara el árbol como hechos propios (hierarchy: 'facts') y ` +
355
+ "config/authorization.ts no trae 'scopes.outbox': el manager escribiría el árbol en el backend DENTRO de tu " +
356
+ 'transacción, y un rollback posterior no lo deshace (el backend queda adelantado a tu base y esa escalada no ' +
357
+ "se ve desde ella). Declarar la outbox solo en el driver NO basta: quien encola es el manager. Pon la MISMA " +
358
+ "instancia en scopes.outbox, o firma el riesgo con scopes.acceptScopeDriftRisk: true.");
359
+ }
360
+ /**
361
+ * **Congela las ESCRITURAS del motor, DURABLE** (3b-7; decisión del dueño
362
+ * del 2026-08-31 (3b): B + E-analista). Operación de PLATAFORMA, como
363
+ * `driver()`: no se expone por HTTP.
364
+ *
365
+ * El estado ya NO vive en el proceso: vive en la fila `id = 2` de
366
+ * `authz_catalog_version`, así que alcanza a **todos los procesos que
367
+ * comparten las tablas `authz_*`** (invariante 14: el comando ace y los
368
+ * workers hablan con la misma base). Mientras el freeze está vivo, toda
369
+ * escritura del manager —las cuatro de hechos, las tres de árbol, la API
370
+ * de delegación, `pruneOrphanRoles({force})` y `relayScopeChanges`—
371
+ * responde 503 `E_AUTHZ_FROZEN` **reintentable** y no llega al driver; las
372
+ * LECTURAS siguen respondiendo con normalidad (la asimetría deliberada:
373
+ * `authorize` no se congela ni un milisegundo).
374
+ *
375
+ * Devuelve el **token del dueño** (`{ fence, holder }`): `unfreeze(token)`
376
+ * solo levanta el freeze cuyo token coincide — el `finally` de una ventana
377
+ * ajena o rezagada no puede levantar la tuya (auditor A1.3). Un freeze
378
+ * VIVO de otro dueño ⇒ 423 `E_AUTHZ_FREEZE_HELD`, nunca dos dueños.
379
+ *
380
+ * El **lease** (default 15 s) se renueva solo (`leaseMs / 3`, `unref()`)
381
+ * mientras este proceso vive; si el proceso muere (`SIGKILL`, OOM), el
382
+ * lease vence y la flota vuelve a escribir SOLA en ≤ `leaseMs` — nadie
383
+ * limpia nada a mano. `leaseMs: null` = sin caducidad: la ventana del
384
+ * OPERADOR (`authz:freeze`), que dura hasta su `authz:unfreeze`.
385
+ *
386
+ * Lo que el freeze **NO congela**, a propósito y documentado (auditor
387
+ * 🟠 5): `syncAuthzCatalog` (función libre que no ve al manager),
388
+ * `manager.driver()` (la salida documentada de TODAS las barreras) y el
389
+ * árbol SQL del consumidor (sus tablas, su SQL). Y lo que no puede
390
+ * prometer: una escritura que ya pasó su barrera cuando el freeze aterriza
391
+ * ENTRA (no hay atomicidad entre una fila SQL y un backend externo) — la
392
+ * promesa publicada es «otro proceso recibe 503», jamás «ninguna escritura
393
+ * entra en la ventana».
394
+ */
395
+ async freeze(reason, options = {}) {
396
+ const root = this.#root();
397
+ if (root.#heldFreeze) {
398
+ throw new FreezeHeldError(`freeze: este manager ya sostiene el freeze (fence ${root.#heldFreeze.token.fence}, ` +
399
+ `motivo: ${root.#heldFreeze.reason}). Una ventana dentro de otra corre DENTRO (withFrozenWrites/reconcile); ` +
400
+ `si quieres otra ventana, levanta antes la tuya con unfreeze(token).`);
401
+ }
402
+ const kind = options.kind ?? 'platform';
403
+ const leaseMs = options.leaseMs === undefined ? DEFAULT_FREEZE_LEASE_MS : options.leaseMs;
404
+ if (leaseMs !== null && (!Number.isInteger(leaseMs) || leaseMs < 1)) {
405
+ throw new AuthorizationConfigError(`freeze: leaseMs debe ser un entero >= 1 o null (llegó ${String(leaseMs)})`);
406
+ }
407
+ const finalReason = reason ?? 'una operación de plataforma';
408
+ const holder = `${kind}:${process.pid}:${randomBytes(4).toString('hex')}`;
409
+ const nowMs = root.#wallMs();
410
+ // alpha.10 · S2 (+ cierre del auditor · R2): una ventana de OPERADOR
411
+ // caducada no se pisa en silencio. Una fila con el lease vencido se toma
412
+ // encima (así la flota vuelve sola tras un SIGKILL de una pasada), pero la
413
+ // del operador es EVIDENCIA: pisarla y soltarla después la deja a NULL y
414
+ // borra el único rastro de que las escrituras volvieron a entrar a mitad
415
+ // del cutover. La exclusión vive DENTRO del UPDATE de `acquireFreezeOutcome`
416
+ // (atómica): aquí solo se lee el veredicto y la fila para redactarlo — un
417
+ // «compruebo y luego tomo» con dos lecturas del reloj era un TOCTOU.
418
+ const outcome = await acquireFreezeOutcome({ reason: finalReason, holder, untilMs: leaseMs === null ? null : nowMs + leaseMs, nowMs }, { driver: this.#config.default });
419
+ if (outcome.status === 'expired-operator') {
420
+ const row = outcome.row;
421
+ const when = row.untilMs === null ? '?' : new Date(row.untilMs).toISOString();
422
+ throw new OperatorWindowExpiredError(`freeze (${reason ?? kind}): la ventana del OPERADOR (${row.holder ?? '?'}, ${row.reason}, fence ` +
423
+ `${row.fence}) CADUCÓ el ${when} y nadie la cerró: desde entonces las escrituras volvieron a entrar en ` +
424
+ `toda la flota. No se toma una ventana propia encima —borraría ese rastro al soltarla y la pasada se ` +
425
+ `certificaría como si hubiera corrido dentro del cutover—. Reconócela con authz:unfreeze (dice cuánto ` +
426
+ `tiempo estuvo abierta y limpia la fila) y reabre la ventana con authz:freeze antes de repetir.`);
427
+ }
428
+ if (outcome.status === 'held') {
429
+ const row = outcome.row;
430
+ throw new FreezeHeldError(`freeze: ya hay un freeze VIVO de otro dueño (${row.holder ?? '?'}, fence ${row.fence}, motivo: ${row.reason ?? '?'}). ` +
431
+ `Espera a que termine, o levántalo con authz:unfreeze si su proceso murió sin lease.`);
432
+ }
433
+ const token = outcome.token;
434
+ const held = { token, reason: finalReason, kind, leaseMs, timer: null, lapsed: false };
435
+ if (leaseMs !== null) {
436
+ const interval = Math.max(250, Math.floor(leaseMs / 3));
437
+ held.timer = setInterval(() => void root.#renewHeldFreeze(held), interval);
438
+ held.timer.unref?.();
439
+ }
440
+ root.#heldFreeze = held;
441
+ return token;
442
+ }
443
+ /**
444
+ * Renovación CONDICIONAL del lease (fence + holder + «aún no venció»).
445
+ * 0 filas ⇒ el lease se PERDIÓ a mitad (pausa de GC más larga que el
446
+ * lease, base caída, otro dueño): se marca `lapsed`, se deja de renovar y
447
+ * NUNCA se «recupera» — la pasada que lo sostenía no se certifica.
448
+ */
449
+ async #renewHeldFreeze(held) {
450
+ if (held.lapsed || held.leaseMs === null)
451
+ return;
452
+ const nowMs = this.#wallMs();
453
+ try {
454
+ const renewed = await renewFreeze(held.token, { untilMs: nowMs + held.leaseMs, nowMs }, { driver: this.#config.default });
455
+ if (!renewed) {
456
+ held.lapsed = true;
457
+ if (held.timer)
458
+ clearInterval(held.timer);
459
+ }
460
+ }
461
+ catch {
462
+ // Base caída: transitorio. Los escritores tampoco pueden escribir (su
463
+ // barrera es la misma base, fail-closed); si la caída dura más que el
464
+ // lease, la SIGUIENTE renovación toca 0 filas y marca lapsed.
465
+ }
466
+ }
467
+ /**
468
+ * Levanta el freeze de ESTE token; uno ajeno o rezagado no toca nada (esa
469
+ * es toda la garantía del fence). Devuelve si de verdad lo levantó.
470
+ */
471
+ async unfreeze(token) {
472
+ if (!token || typeof token.fence !== 'number' || typeof token.holder !== 'string') {
473
+ throw new AuthorizationConfigError('unfreeze: hace falta el token que devolvió freeze() ({ fence, holder }). Levantar el freeze de otro es authz:unfreeze.');
474
+ }
475
+ const root = this.#root();
476
+ const { released, lapsed } = await releaseFreeze(token, { nowMs: root.#wallMs() }, { driver: this.#config.default });
477
+ const held = root.#heldFreeze;
478
+ if (held && held.token.fence === token.fence && held.token.holder === token.holder) {
479
+ if (held.timer)
480
+ clearInterval(held.timer);
481
+ held.lapsed = held.lapsed || lapsed;
482
+ root.#heldFreeze = null;
483
+ }
484
+ return released;
485
+ }
486
+ /**
487
+ * ¿SOSTIENE este manager un freeze? (proceso-local: su token vive aquí.)
488
+ * Para saber si el MOTOR está congelado —por quien sea— pregunta
489
+ * `freezeStatus()`: eso es la fila, no la memoria.
490
+ */
491
+ get frozen() {
492
+ return this.#root().#heldFreeze !== null;
493
+ }
494
+ /**
495
+ * La clave (en `relations.drivers`) del driver de RELACIONES activo:
496
+ * `relations.default ?? default` (2.4.0-alpha.10). La lee el guard de la
497
+ * dirección rancia de `runRelationsReconcile`; desde el cierre del auditor
498
+ * (R4) el llamante ya no puede contradecirla con un `activeKey` propio.
499
+ */
500
+ relationsDriverKey() {
501
+ return activeRelationsDriverKey(this.#root().#config);
502
+ }
503
+ /** Las claves registradas en `relations.drivers` del config de ESTE manager (vacío si no hay relaciones). */
504
+ relationsDriverKeys() {
505
+ return Object.keys(this.#root().#config.relations?.drivers ?? {});
506
+ }
507
+ /**
508
+ * La factory registrada bajo `key` en `relations.drivers` de ESTE manager, o
509
+ * `undefined` (cierre del auditor de alpha.10 · R4). `runRelationsReconcile`
510
+ * resuelve el origen y el destino por aquí —del MISMO config que decide el
511
+ * activo— y juzga la identidad por la factory resuelta, no por la etiqueta:
512
+ * dos claves con la misma factory son el mismo driver (y dos factories DISTINTAS
513
+ * sobre el mismo backend NO: límite declarado, cierre-2 · C1).
514
+ *
515
+ * **API de PLATAFORMA, junto a `driver()`** (cierre-2 · C6): con esta factory
516
+ * cualquier código que tenga el manager —vistas de `forRequest()` incluidas—
517
+ * construye un driver de relaciones NO activo y escribe en él sin `assertWrite`,
518
+ * `requireActor`, freeze ni `onRelationWrite`. No añade capacidad (el config ya
519
+ * da las factories), pero es otra cosa que buscar en una revisión.
520
+ */
521
+ relationsDriverFactory(key) {
522
+ const drivers = this.#root().#config.relations?.drivers;
523
+ if (drivers === undefined || !Object.prototype.hasOwnProperty.call(drivers, key))
524
+ return undefined;
525
+ return drivers[key];
526
+ }
527
+ /** El freeze VIVO de la fila compartida, o `null`. Lo lee cualquiera; solo el token lo levanta. */
528
+ async freezeStatus() {
529
+ const row = await readFreezeRow({ driver: this.#config.default });
530
+ if (!freezeIsLive(row, this.#root().#wallMs()))
531
+ return null;
532
+ return {
533
+ reason: row.reason,
534
+ holder: row.holder ?? UNKNOWN_HOLDER,
535
+ kind: freezeKindOf(row.holder),
536
+ untilMs: row.untilMs,
537
+ fence: row.fence,
538
+ };
539
+ }
540
+ /**
541
+ * **La ÚLTIMA ventana de la fila, también la CADUCADA** (2.4.0-alpha.10, 🟠 2
542
+ * del sexto informe de COGNITIV). `freezeStatus()` responde solo lo VIVO —y
543
+ * así lo siguen usando la barrera y los comandos—, de modo que una ventana
544
+ * cuyo lease venció era indistinguible de «nunca hubo»: `authz:unfreeze`
545
+ * decía éxito y el último paso del cutover no delataba que las escrituras
546
+ * habían vuelto a entrar a mitad. Se distingue por la fila: `releaseFreeze`
547
+ * pone el motivo a `null` al levantar, así que motivo puesto + `untilMs` en
548
+ * el pasado es una ventana que CADUCÓ sin que nadie la cerrase.
549
+ *
550
+ * `null` = nunca hubo o ya se levantó; `expired: false` = viva (lo mismo que
551
+ * `freezeStatus()`); `expired: true` = caducada. `checkedAtMs` es el instante
552
+ * del reloj del config con el que se decidió.
553
+ */
554
+ async lastFreezeStatus() {
555
+ const row = await readFreezeRow({ driver: this.#config.default });
556
+ if (row.reason === null)
557
+ return null;
558
+ const nowMs = this.#root().#wallMs();
559
+ return {
560
+ reason: row.reason,
561
+ holder: row.holder ?? UNKNOWN_HOLDER,
562
+ kind: freezeKindOf(row.holder),
563
+ untilMs: row.untilMs,
564
+ fence: row.fence,
565
+ expired: !freezeIsLive(row, nowMs),
566
+ // El instante (reloj del config) con el que se decidió `expired`.
567
+ checkedAtMs: nowMs,
568
+ };
569
+ }
570
+ /**
571
+ * `freeze()` + `finally unfreeze(token)`. El `finally` es la parte que
572
+ * importa: una migración que revienta a la mitad no puede dejar la
573
+ * aplicación sin poder escribir (y si además el proceso muere sin
574
+ * `finally`, el lease vence solo). **El anidado corre DENTRO** (auditor
575
+ * A1.1/A1.3): si este manager ya sostiene el freeze, la ventana interior
576
+ * no toma otro ni lo levanta al salir — la exterior sigue en pie.
577
+ */
578
+ async withFrozenWrites(reason, fn, options = {}) {
579
+ // L-1 · J1: una pasada que quiera correr DENTRO de la ventana del
580
+ // operador (el cutover) y publicar su `lapsed` —`authz:relations:reconcile`,
581
+ // que vive fuera de este manager— pide `kind: 'reconcile'` y
582
+ // `operatorAsContext: true`, lo mismo que hace `reconcile` de roles.
583
+ const context = await this.#durableFreezeContext(reason, options.kind ?? 'platform', {
584
+ operatorAsContext: options.operatorAsContext === true,
585
+ });
586
+ try {
587
+ return await fn({
588
+ fence: context.fence,
589
+ leaseMs: context.leaseMs,
590
+ lapsed: context.lapsed,
591
+ ...(context.untilMs === undefined ? {} : { untilMs: context.untilMs }),
592
+ ...(context.window === undefined ? {} : { window: context.window }),
593
+ });
594
+ }
595
+ finally {
596
+ await context.release();
597
+ }
598
+ }
599
+ /**
600
+ * El contexto de una ventana congelada: quién la sostiene, cómo se cierra
601
+ * y cómo se sabe si el lease se perdió a mitad (`lapsed`). Tres formas:
602
+ *
603
+ * 1. Este manager YA sostiene un freeze ⇒ la ventana corre DENTRO y el
604
+ * `release` es un no-op (la exterior manda).
605
+ * 2. Hay un freeze de OPERADOR vivo y `operatorAsContext` ⇒ el cutover:
606
+ * `reconcile` corre dentro de la ventana del operador, no la renueva
607
+ * ni la levanta, y su `lapsed` es «¿seguía la MISMA ventana viva al
608
+ * terminar?».
609
+ * 3. Nadie ⇒ se toma uno propio (lease renovado) y se suelta al salir.
610
+ * Un freeze vivo de otro dueño ⇒ 423 (lo lanza `freeze()`).
611
+ */
612
+ async #durableFreezeContext(reason, kind, options) {
613
+ const root = this.#root();
614
+ const outer = root.#heldFreeze;
615
+ if (outer) {
616
+ return {
617
+ fence: outer.token.fence,
618
+ leaseMs: outer.leaseMs,
619
+ window: 'enclosing',
620
+ release: async () => { },
621
+ lapsed: () => root.#tokenLapsed(outer.token, outer.lapsed),
622
+ };
623
+ }
624
+ if (options.operatorAsContext) {
625
+ const status = await this.freezeStatus();
626
+ if (status !== null && status.kind === 'operator') {
627
+ const token = { fence: status.fence, holder: status.holder };
628
+ // alpha.10 · 3: la caducidad REAL de la ventana adoptada. Hasta alpha.9
629
+ // `leaseMs: null` fijo — el reporte decía «sin caducidad» de una ventana
630
+ // abierta con `--lease-ms` (la certificación sí leía la fila).
631
+ return {
632
+ fence: status.fence,
633
+ leaseMs: status.untilMs === null ? null : Math.max(0, status.untilMs - root.#wallMs()),
634
+ untilMs: status.untilMs,
635
+ window: 'operator',
636
+ release: async () => { },
637
+ lapsed: () => root.#tokenLapsed(token, false),
638
+ };
639
+ }
640
+ }
641
+ const token = await this.freeze(reason, { kind });
642
+ const held = root.#heldFreeze;
643
+ return {
644
+ fence: token.fence,
645
+ leaseMs: held.leaseMs,
646
+ window: 'own',
647
+ release: async () => {
648
+ try {
649
+ await this.unfreeze(token);
650
+ }
651
+ catch (error) {
652
+ // La base no respondió al soltar: el lease vence solo en <= leaseMs
653
+ // y los escritores ya están recibiendo 503 de esa misma base.
654
+ console.warn('authz: no se pudo soltar el freeze al cerrar la ventana (el lease vencerá solo)', error);
655
+ }
656
+ },
657
+ lapsed: () => root.#tokenLapsed(token, held.lapsed),
658
+ };
659
+ }
660
+ /** ¿Se perdió la ventana de ESTE token en algún momento? (la renovación fallida, o la fila ya no es suya / venció). */
661
+ async #tokenLapsed(token, alreadyLapsed) {
662
+ if (alreadyLapsed)
663
+ return true;
664
+ const row = await readFreezeRow({ driver: this.#config.default });
665
+ const mine = row.fence === token.fence && row.holder === token.holder;
666
+ return !mine || (row.untilMs !== null && row.untilMs <= this.#wallMs());
667
+ }
668
+ /** Milisegundos de PARED con el reloj del config (el mismo que decide caducidades). */
669
+ #wallMs() {
670
+ return (this.#config.clock ?? systemClock)().getTime();
671
+ }
672
+ /** El manager raíz: el de una vista de `forRequest()` es su padre. */
673
+ #root() {
674
+ return this.#parent ?? this;
675
+ }
676
+ /**
677
+ * La barrera del freeze, delante de TODA escritura del manager. Va antes
678
+ * de validar identidades y de tocar el árbol: durante la migración una
679
+ * escritura no se valida a medias, se rechaza entera. Desde 3b-7 es la
680
+ * FILA compartida (consulta propia por PK, sin memo: +0,14 ms p50 por
681
+ * escritura, medidos; 0 en `authorize`) — un freeze cacheado 30 s no es un
682
+ * freeze, y con `catalogRevalidate: { everyMs }` la fila del memo ni se
683
+ * lee.
684
+ */
685
+ async #assertNotFrozen(operation) {
686
+ const root = this.#root();
687
+ const held = root.#heldFreeze;
688
+ if (held) {
689
+ throw new AuthorizationFrozenError(`${operation}: el motor de autorización está congelado (${held.reason}) y no acepta escrituras. ` +
690
+ `Las lecturas siguen funcionando; reintenta esta escritura cuando la operación termine.`);
691
+ }
692
+ // La fila se lee SIEMPRE por la conexión del motor (L-1 · 🟠 8). Hasta
693
+ // L-1 se leía por la transacción del consumidor si la escritura llegaba
694
+ // con una —«para no interbloquear un pool de 1»—, y eso era el agujero:
695
+ // la barrera la decidía el snapshot del llamante (medido en SQLite, MySQL
696
+ // y PG). Con pool 1 el precio es un 503 con deadline, no un bypass.
697
+ await assertNotFrozenRow(operation, {
698
+ driver: this.#config.default,
699
+ nowMs: root.#wallMs(),
700
+ timeoutMs: this.#config.freezeTimeoutMs,
701
+ });
702
+ }
180
703
  /** Solo tests: fuerza re-resolución del driver. */
181
704
  clearCachedDriver() {
182
705
  this.#driver = null;
@@ -224,20 +747,45 @@ export class AuthorizationManager {
224
747
  // Por eso el consumidor notifica ANTES de recolgar su fila: la cadena que
225
748
  // se contrasta es la de origen, resuelta en fresco.
226
749
  attached: async (child, parent, options) => {
227
- this.#writeOptions(options, 'scopes.attached');
228
- const chain = await this.#assertEdge(child, parent, 'scopes.attached');
229
- this.#assertWithinChain(parent, chain, options, 'scopes.attached');
750
+ const actor = await this.#writeOptions(options, 'scopes.attached');
751
+ const edge = await this.#assertEdge(child, parent, 'scopes.attached');
752
+ this.#assertWithinChain(parent, edge.chain, options, 'scopes.attached');
230
753
  // Un hijo que el árbol ya conoce se está MOVIENDO (el `attach` de un
231
754
  // nodo existente es un `move`): su origen también tiene que estar dentro.
232
755
  await this.#assertWithinOrigin(child, options, 'scopes.attached', 'if-known');
756
+ const outbox = this.#outbox();
757
+ if (outbox) {
758
+ const receipt = await outbox.enqueue({ op: 'attached', child: edge.child, parent: edge.chain[0] }, { transaction: options?.transaction, ...actor });
759
+ return _a.#receiptOf([receipt]);
760
+ }
233
761
  await (await this.driver()).onScopeAttached?.(child, parent);
762
+ return { queued: [] };
234
763
  },
764
+ /**
765
+ * `moved` NO vuelve a juzgar el catálogo, y no tiene por qué (3b-1 · D3,
766
+ * auditor 3G): mover un scope es un hecho del árbol, no una escritura de
767
+ * catálogo. Lo que hay que tener escrito es la consecuencia: la relación
768
+ * «A ensombrece a B» es función del árbol de HOY, así que un `moved` que
769
+ * mete un subárbol bajo un scope que ya tiene el homónimo **crea la
770
+ * sombra sin que se juzgue ningún rango en ninguna parte** — y el dueño
771
+ * del subárbol movido puede no poder repararla (su rango se mide en la
772
+ * cadena del owner de la sombra). Por eso «sobre un rol solo actúa quien
773
+ * lo supera en rango» (3G · W3) es una comprobación de ESCRITURA y no un
774
+ * invariante del sistema. Es ruidosa: `authz:catalog:diff` la lista como
775
+ * `shadowedByAncestor` (y `--fail-on-shadows` la cuenta como deriva).
776
+ */
235
777
  moved: async (child, newParent, options) => {
236
- this.#writeOptions(options, 'scopes.moved');
237
- const chain = await this.#assertEdge(child, newParent, 'scopes.moved');
238
- this.#assertWithinChain(newParent, chain, options, 'scopes.moved');
778
+ const actor = await this.#writeOptions(options, 'scopes.moved');
779
+ const edge = await this.#assertEdge(child, newParent, 'scopes.moved');
780
+ this.#assertWithinChain(newParent, edge.chain, options, 'scopes.moved');
239
781
  await this.#assertWithinOrigin(child, options, 'scopes.moved', 'required');
782
+ const outbox = this.#outbox();
783
+ if (outbox) {
784
+ const receipt = await outbox.enqueue({ op: 'moved', child: edge.child, parent: edge.chain[0] }, { transaction: options?.transaction, ...actor });
785
+ return _a.#receiptOf([receipt]);
786
+ }
240
787
  await (await this.driver()).onScopeMoved?.(child, newParent);
788
+ return { queued: [] };
241
789
  },
242
790
  /**
243
791
  * Hechos primero (el driver demuestra cero o lanza), arista después
@@ -246,165 +794,1351 @@ export class AuthorizationManager {
246
794
  * scope exista (el consumidor puede haber borrado ya su fila); con
247
795
  * `within` (2D · F2) el hijo tiene que seguir en el árbol para
248
796
  * contrastar su cadena: purga ANTES de borrar la fila.
797
+ *
798
+ * **Purga HECHOS y solo hechos** (invariante 11; 3b-0 · Z1). Entre 3D y
799
+ * 3G esta operación arrastraba además los roles LOCALES cuyo owner era
800
+ * ese scope (y, con `descendantsOf`, los de todo el subárbol), con su
801
+ * propia policy de rango, su degradación y un valor de retorno que
802
+ * contaba lo purgado. Cinco lotes la tocaron y TRES de las cuatro
803
+ * regresiones de la Fase 3 nacieron ahí, siempre por COMPOSICIÓN de
804
+ * piezas correctas por separado (3E · P3 + 3F · S1/S2 ⇒ 3G · W1). El
805
+ * requisito que lo pedía —un rol cuyo owner desaparece queda
806
+ * indeleteable y ocupa su `(slug, nivel)`— se resuelve más simple y
807
+ * fuera del camino de un tenant: el rol queda DORMIDO (no concede, no es
808
+ * membresía, no se asigna) y la PLATAFORMA lo retira con
809
+ * `authz:catalog:prune-orphans` (Z2). Así `scopes.detached` vuelve a ser
810
+ * O(1), sin rango que medir, sin árbol que enumerar y sin nada que
811
+ * declarar a medias.
249
812
  */
250
813
  detached: async (child, options) => {
251
- const actor = this.#writeOptions(options, 'scopes.detached');
814
+ const actor = await this.#writeOptions(options, 'scopes.detached');
252
815
  this.#resolver('scopes.detached');
253
816
  assertScope(child);
254
817
  if (child.type === APP_SCOPE_TYPE) {
255
818
  throw new InvalidIdentityError('scopes.detached: la raíz `app` no se puede borrar ni purgar');
256
819
  }
257
820
  await this.#assertWithin(child, options, 'scopes.detached');
258
- const driver = await this.driver();
259
- // La identidad CANÓNICA, una sola vez y para TODO (3E · P2, auditor
260
- // A2): hasta aquí los hechos se canonizaban dentro del driver y los
261
- // roles no, así que un alias del uuid del scope —el mismo uuid sin
262
- // guiones, que el tipo `uuid` de PostgreSQL resuelve a la misma fila y
263
- // `assertScope` acepta— purgaba las asignaciones y dejaba VIVOS los
264
- // roles: la mina de V5 volvía, en silencio y sin error.
265
- // Una SOLA resolución para las dos cosas que dependen de ella: la
266
- // identidad canónica de los hechos y si el árbol todavía conoce el
267
- // scope (3G · W2, auditor P2: con `descendantsOf` declarado, un scope
268
- // que ya no resuelve NO permite demostrar que la purga alcanzó al
269
- // subárbol, y el resultado tiene que decirlo).
821
+ // La identidad CANÓNICA (3E · P2, auditor A2): hasta 3D los hechos se
822
+ // canonizaban dentro del driver, así que un alias del uuid del scope
823
+ // —el mismo uuid sin guiones, que el tipo `uuid` de PostgreSQL
824
+ // resuelve a la misma fila y `assertScope` acepta— purgaba unas cosas
825
+ // y dejaba otras. Se resuelve UNA vez, aquí, y vale para todo.
826
+ //
827
+ // Y cuando NO hay cadena —la fila ya no existe, que es el orden
828
+ // soportado de `detached` (3F · S1)— no hay con qué canonizar: se
829
+ // purgan TODAS las ortografías de las que el uuid del llamante puede
830
+ // ser alias (`scopeSpellings`, 3b-2h · 🟠 3). Con la fila viva esto es
831
+ // exactamente una, la de la tabla; sin ella, la del llamante y la
832
+ // canónica que un motor pudo fundir con la suya. Antes se usaba la del
833
+ // llamante a secas: `purgeScope` demostraba cero sobre un objeto que no
834
+ // existe, devolvía OK, y el scope real seguía concediendo para siempre.
270
835
  const chain = await resolveChain(this.#freshResolver(), child, 'scopes.detached');
271
- const purged = chain ? chain[0] : child;
272
- // Los roles LOCALES cuyo owner es este scope, PRIMERO (3D · M4, auditor
273
- // V5): un rol sin owner no es visible en ninguna parte —no concede, no
274
- // es membresía— pero su fila sobrevivía, `deleteScopedRole` respondía
275
- // 422 `E_AUTHZ_UNKNOWN_SCOPE` (resuelve el owner en fresco) y ese
276
- // `(slug, nivel)` quedaba bloqueado para el catálogo global PARA
277
- // SIEMPRE. Antes que los hechos, para que un driver que no sabe purgar
278
- // roles (openfga hasta 3b) lo diga con 500 `E_AUTHZ_UNSUPPORTED` sin
279
- // haber tocado nada.
280
- const outcome = await this.#purgeRolesOwnedBy(driver, purged, chain, actor.actor);
281
- const event = {
282
- action: 'scope_purged',
283
- scope: purged,
284
- ...actor,
285
- ...(outcome.reason ? { reason: outcome.reason } : {}),
286
- ...(outcome.truncated ? { truncated: true } : {}),
287
- };
288
- await this.#write(event, () => driver.purgeScope(purged));
289
- await driver.onScopeDetached?.(purged);
290
- await this.#notify(event);
291
- return outcome;
836
+ const targets = chain ? [chain[0]] : scopeSpellings(child);
837
+ const outbox = this.#outbox();
838
+ if (outbox) {
839
+ // La identidad se resuelve AQUÍ, con la fila del consumidor todavía
840
+ // viva si la hay: al relevar el cambio ya no resolvería. Y no se
841
+ // audita `scope_purged` todavía, porque todavía no ha pasado nada.
842
+ // El recibo es PLURAL por esto mismo: una fila por ortografía.
843
+ const receipts = [];
844
+ for (const target of targets) {
845
+ receipts.push(await outbox.enqueue({ op: 'detached', child: target }, { transaction: options?.transaction, ...actor }));
846
+ }
847
+ return _a.#receiptOf(receipts);
848
+ }
849
+ const driver = await this.driver();
850
+ for (const purged of targets) {
851
+ const event = { action: 'scope_purged', scope: purged, ...actor };
852
+ await this.#write(event, () => driver.purgeScope(purged));
853
+ await driver.onScopeDetached?.(purged);
854
+ await this.#notify(event);
855
+ }
856
+ return { queued: [] };
292
857
  },
293
858
  };
294
859
  /**
295
- * Purga los roles LOCALES cuyo owner es `scope` —ya CANÓNICO— y, cuando el
296
- * consumidor declara `scopes.descendantsOf`, los de todo su SUBÁRBOL (3D ·
297
- * M4; 3E · P2, auditor A4: `detached(padre)` es lo que un consumidor
298
- * notifica al borrar una rama, y los roles de los hijos quedaban huérfanos
299
- * e indeleteables, bloqueando su `(slug, nivel)` global para siempre).
300
- * Sin `descendantsOf` la promesa del invariante 18 se acota al scope
301
- * exacto y así está escrito.
302
- *
303
- * Los roles se leen de la BASE (`readRolesOwnedBy`), no del memo: con una
304
- * ventana `{ everyMs }` la foto puede no tener lo que otro proceso acaba
305
- * de confirmar (auditor A2 bis).
306
- *
307
- * Policy de rango (3E · P3, auditor A3): `scopes.*` puede colgar de la
308
- * sesión de un tenant —el invariante 15 lo invita—, así que esta purga de
309
- * CATÁLOGO exige lo mismo que `deleteScopedRole`: rank del actor MAYOR que
310
- * el de cada rol, comprobado sobre TODOS antes de tocar ninguno. Sin
311
- * `actor` (plataforma) se comporta como hasta ahora, y el README lo dice.
312
- * Si el árbol YA NO conoce el scope, la purga PROCEDE (3F · S1, auditor
313
- * N2): `detached` es la operación que limpia DESPUÉS de borrar la fila y
314
- * bloquearla dejaba vivos el rol, sus asignaciones y los denies de un
315
- * scope que ya no existe —sin ninguna salida por el manager con
316
- * `requireActor: true`—. Lo que se salta es la comprobación de rango **de
317
- * los roles cuyo PROPIO owner tampoco resuelve**, no la de todos (3G · W1,
318
- * auditor P1): medirla en la cadena del scope notificado y aplicarla a
319
- * roles de otros owners destruía roles de descendientes VIVOS —de
320
- * cualquier rango, concediendo en ese instante— que `deleteScopedRole` y
321
- * el `detached` del propio scope niegan con 422. El evento y el valor de
322
- * retorno lo dicen (`reason`).
323
- *
324
- * Cada `purgeRole` es atómico (asignaciones + vínculos + fila + versión) y
325
- * se notifica `role_purged`; el conjunto no lo es, pero un rol cuyo owner
326
- * ya no está en el árbol no es visible en ningún sitio, así que una purga
327
- * a medias no cambia ninguna decisión — solo deja filas que la siguiente
328
- * llamada recoge.
329
- */
330
- async #purgeRolesOwnedBy(driver, scope, chain, actor) {
331
- const ownerKeys = [scopeKey(scope)];
332
- const { below, declared, enumerated } = await this.#descendantsOrDegrade(scope, 'scopes.detached');
333
- for (const node of below)
334
- ownerKeys.push(scopeKey(node));
335
- // 3G · W2 (auditor P2): con el scope FUERA del árbol, un `descendantsOf`
336
- // que responde vacío (o `null`, que aquí es lo mismo) no demuestra que
337
- // debajo no quede nada — el puerto no le exige responder por un scope
338
- // que `resolveChain` ya no conoce (docblock de
339
- // `ScopeDescendantsResolver`)—, así que el resultado no puede decir
340
- // «completa»: `truncated: true`. Con una lista NO vacía sí se enumeró.
341
- const unknownScope = chain === null;
342
- const truncated = declared && (!enumerated || (unknownScope && below.length === 0));
343
- const owned = await readRolesOwnedBy(ownerKeys, { driver: this.#config.default });
344
- if (owned.length === 0) {
345
- // El `reason` sale también con cero roles (3G · W2): lo que dice es que
346
- // el árbol ya no conoce el scope, y eso vale igual para el consumidor
347
- // que audita una purga que no encontró nada que purgar.
348
- return { purgedRoles: 0, truncated, ...(unknownScope ? { reason: 'owner-detached-unknown' } : {}) };
349
- }
350
- const purgeRole = this.#optional(driver, 'purgeRole', 'scopes.detached');
351
- const skipped = actor ? await this.#assertAboveOwnedRoles(driver, actor, owned.map((o) => o.role)) : false;
352
- const reason = unknownScope || skipped ? 'owner-detached-unknown' : undefined;
353
- for (const { role, permissions } of owned) {
354
- const owner = scopeFromKey(role.owner) ?? scope;
355
- try {
356
- await purgeRole(role.uuid);
860
+ * El recibo del encolado (alpha.5 · R1): los ids que la outbox devolvió,
861
+ * en orden. Una outbox que devuelve `void` (terceros anteriores a alpha.5)
862
+ * no deja id, y el recibo sale vacío: nunca se inventa uno.
863
+ */
864
+ static #receiptOf(receipts) {
865
+ const queued = [];
866
+ for (const receipt of receipts) {
867
+ if (receipt && typeof receipt === 'object' && (typeof receipt.id === 'number' || typeof receipt.id === 'string')) {
868
+ queued.push(receipt.id);
357
869
  }
358
- finally {
359
- invalidateAuthzCatalog();
870
+ }
871
+ return { queued };
872
+ }
873
+ /**
874
+ * **Drena la outbox del árbol y aplica los cambios al driver** (3b-2d).
875
+ * Es lo que hay detrás de `node ace authz:scopes:relay`.
876
+ *
877
+ * Operación de PLATAFORMA, como `pruneOrphanRoles`: se salta `requireActor`
878
+ * y `requireWithin` a propósito —la policy ya se juzgó al ENCOLAR, con el
879
+ * árbol y la sesión de aquel momento— así que **no se expone por HTTP**.
880
+ * Aquí solo se propaga lo que ya se validó.
881
+ *
882
+ * Reanudable y nunca silenciosa: el reporte dice QUÉ se aplicó (no un
883
+ * contador: la pasada no es atómica), qué falló, qué se aplazó y si queda
884
+ * trabajo.
885
+ *
886
+ * **El orden del árbol importa, pero solo entre cambios que se tocan**
887
+ * (3b-2h · 🔴 2, auditor R2). Hasta el 2h la pasada PARABA en el primer
888
+ * fallo, y eso convertía una entrada que ya no se puede aplicar —el padre
889
+ * del `attached` encolado se borró antes del relevo, la arista cerraría
890
+ * ahora un ciclo, el nodo acabó con dos padres— en un **tapón permanente
891
+ * para todos los tenants**: `pending()` devuelve lo no aplicado ordenado
892
+ * por id, así que la envenenada era la cabecera de la cola en TODAS las
893
+ * pasadas siguientes y ningún cambio del árbol volvía a llegar al store
894
+ * (medido: una unit nueva nunca recibía su arista `parent`, el deny de su
895
+ * organization nunca la alcanzaba y un `detached` posterior nunca purgaba).
896
+ * Ahora un fallo **contamina los scopes que nombra**: los cambios
897
+ * posteriores que tocan alguno de ellos se APLAZAN sin intentarse (y
898
+ * contaminan a su vez, así que la dependencia es transitiva), y los demás
899
+ * se aplican. El par ordenado que importaba —`attached(P, org)` antes que
900
+ * `attached(C, P)`, `moved` antes que `detached`— sigue respetado porque
901
+ * comparten scope; lo que ya no pasa es que el tenant A congele el árbol
902
+ * del tenant B.
903
+ *
904
+ * **Escritor ÚNICO** (3b-2h · 🟠 4): si la outbox sabe dar un lease
905
+ * (`acquire`), la pasada lo toma y una segunda pasada simultánea no hace
906
+ * nada y lo dice (`busy`). Sin lease, dos pasadas trabajan sobre el mismo
907
+ * lote —`pending()` no reserva y el lote no se relee— y la rezagada
908
+ * re-aplica cambios viejos sobre el árbol nuevo.
909
+ *
910
+ * Lo que esta pieza NO arregla, y va escrito en el README con estas
911
+ * palabras: entre el commit del consumidor y esta pasada hay un lag
912
+ * (segundos) durante el cual el backend decide con el árbol VIEJO. Es un
913
+ * **fail-open temporal** —el tenant antiguo conserva acceso tras un
914
+ * `moved`, los denies heredados no aplican tras un `attached`—. No hay
915
+ * 2PC; es el precio de tener el árbol en dos sitios.
916
+ *
917
+ * **`only` (2.4.0-alpha.5 · R1)**: relevar SOLO estas entradas —los ids del
918
+ * recibo de `scopes.*` que ESTE proceso acaba de recibir, jamás del cuerpo
919
+ * de una petición— sin leer la cola de los demás (medido: 4 507 ms → 19 ms
920
+ * con 500 entradas ajenas por delante). El orden por clave NO se relaja:
921
+ * antes de aplicar cada entrada pedida se sondea la cola ENTERA
922
+ * (`earlierPending`, o paginando `pending()` si la outbox no lo tiene) y,
923
+ * si hay una pendiente ANTERIOR que comparte clave de scope con ella, sale
924
+ * `deferred` con `depends-on:<id>` — nunca aplicada fuera de orden (el 🔴 1
925
+ * del panel relay, medido: un `attached(child, unit)` aplicado antes que su
926
+ * `moved(unit → orgB)` dejaba al editor de la org VIEJA autorizando sobre el
927
+ * hijo). `remaining` y `dead` siguen siendo GLOBALES. `only: []` es 500
928
+ * (un `filter` vacío del consumidor no puede acabar en drenaje ni en un
929
+ * reporte vacío en verde), y una outbox que no honre el filtro también.
930
+ *
931
+ * **Lo aparcado tapona por clave (alpha.5 · R2a)**: las claves de `dead()`
932
+ * siembran el bloqueo al inicio de la pasada para lo que CONCEDE o recuelga
933
+ * (`attached`/`moved`, `deferred` con `parked:<id>`) y dejan pasar lo que
934
+ * RETIRA (`detached`: una purga nunca queda bloqueada, invariante 11). Y un
935
+ * 503 del backend NO consume intento (`markFailed(..., { transient })`).
936
+ */
937
+ async relayScopeChanges(options = {}) {
938
+ _a.#assertKnownOptions('relayScopeChanges', options, RELAY_OPTIONS);
939
+ // El relay ESCRIBE el árbol en el driver: durante una migración se aplaza
940
+ // como cualquier otra escritura (lo que quede en la cola sigue ahí).
941
+ await this.#assertNotFrozen('authz:scopes:relay');
942
+ const outbox = this.#outbox();
943
+ if (!outbox) {
944
+ throw new AuthorizationConfigError("authz:scopes:relay necesita 'scopes.outbox' en config/authorization.ts: sin cola no hay nada que drenar " +
945
+ '(y sin cola tampoco hay mitigación: el manager estaría escribiendo en el backend dentro de tu transacción).');
946
+ }
947
+ const limit = _a.#positive(options.limit, DEFAULT_RELAY_LIMIT, 'limit');
948
+ const batchSize = _a.#positive(options.batchSize, DEFAULT_RELAY_BATCH, 'batchSize');
949
+ const dryRun = options.dryRun === true;
950
+ const waitForLeaseMs = _a.#waitMs(options.waitForLeaseMs);
951
+ if (options.deadDetail !== undefined && options.deadDetail !== 'full') {
952
+ throw new AuthorizationConfigError(`authz:scopes:relay: deadDetail solo admite 'full' (llegó ${JSON.stringify(options.deadDetail)}); sin él, bajo only, dead[] va sin error.`);
953
+ }
954
+ const inputs = await _a.#relayInputs(outbox, options.only, batchSize, options.deadDetail);
955
+ const { only, filter, dead, deadCount, deadEntries, unseenOf } = inputs;
956
+ if (dryRun) {
957
+ const batch = await outbox.pending(limit, undefined, filter);
958
+ _a.#assertOnlyHonoured(batch, only);
959
+ const extra = batch.length >= limit ? true : false;
960
+ const seen = new Set([...batch.map((b) => String(b.id)), ...deadEntries.map((d) => String(d.id))]);
961
+ const unseen = await unseenOf(seen);
962
+ // La edad es GLOBAL: sin filtro la cabeza es `batch[0]`; con `only` hay que mirar la cola entera.
963
+ const queue = filter === undefined ? this.#queueOf(batch[0]) : (await this.#queueStatus(outbox)).queue;
964
+ return {
965
+ applied: [],
966
+ failed: null,
967
+ failures: [],
968
+ deferred: [],
969
+ dead,
970
+ deadCount,
971
+ queue,
972
+ busy: false,
973
+ remaining: batch.length > 0 || extra,
974
+ dryRun: true,
975
+ wouldApply: batch.map((item) => ({ id: item.id, change: item.change, attempts: item.attempts })),
976
+ entries: deadEntries,
977
+ ...unseen,
978
+ };
979
+ }
980
+ // El lease del escritor ÚNICO. Una outbox que no sabe darlo se comporta
981
+ // como hasta ahora (y el README dice que entonces el relay tiene que
982
+ // correr de uno en uno). Con `waitForLeaseMs` se reintenta hasta el tope.
983
+ const lease = await this.#acquireLease(outbox, waitForLeaseMs);
984
+ if (outbox.acquire && lease === null)
985
+ return this.#busyReport(outbox, inputs);
986
+ try {
987
+ return await this.#drainScopeChanges(outbox, { limit, batchSize, ...inputs });
988
+ }
989
+ finally {
990
+ await lease?.release();
991
+ }
992
+ }
993
+ /**
994
+ * Lo que una pasada necesita saber de la cola antes de tomar el lease
995
+ * (`only` normalizado, el filtro para el puerto, lo APARCADO —siempre
996
+ * global— y sus entradas para el reporte). Compartido por `relayScopeChanges`
997
+ * y por `requeueScopeChanges({ apply })` (2.4.0-alpha.8), que releva bajo
998
+ * el MISMO lease y necesita releer lo aparcado DESPUÉS de reencolar.
999
+ */
1000
+ static async #relayInputs(outbox, onlyIds, batchSize, deadDetail) {
1001
+ const only = _a.#onlyOf(onlyIds);
1002
+ // Al puerto van los ids TAL CUAL los dio el llamante (el recibo); el
1003
+ // conjunto de cadenas es solo para comparar sin depender del tipo.
1004
+ const filter = only === undefined ? undefined : { ids: [...onlyIds] };
1005
+ /** Lo aparcado por la outbox, si sabe aparcar: se reporta SIEMPRE, y GLOBAL. */
1006
+ const read = await _a.#deadLetters(outbox, batchSize);
1007
+ // Bajo `only` (2.4.0-alpha.8, coordinador): la lista sigue siendo global
1008
+ // —ids, `change`, `attempts`— pero SIN `error` salvo `deadDetail: 'full'`:
1009
+ // la causa es texto del backend sobre el árbol de otro tenant, y la receta
1010
+ // inline corre dentro de una petición. Lo pedido (`deadEntries`) sí la
1011
+ // lleva: es lo suyo.
1012
+ const dead = only === undefined || deadDetail === 'full' ? read : read.map(({ error: _error, ...safe }) => safe);
1013
+ // El censo COMPLETO (cierre alpha.5, 🟠 2): lo que `#parkedKeys` siembra y,
1014
+ // desde 2.4.0-alpha.8, lo que cuenta `deadCount` (`dead.length` miente al
1015
+ // llegar a `batchSize`). Una sola lectura de más, y solo si no cabía.
1016
+ const census = dead.length < batchSize ? dead : await _a.#deadLetters(outbox, PARKED_CENSUS_MAX);
1017
+ const deadCount = census.length;
1018
+ /**
1019
+ * Lo aparcado que se pidió (con `only`) o todo lo aparcado, para `entries`.
1020
+ * Cierre-2 · C2 (gemelo): con `only` sale del CENSO completo si la lectura se
1021
+ * cortó en `batchSize` — si no, un id pedido aparcado más allá del primer lote
1022
+ * era `missing` («no estaba ni pendiente ni aparcado») estando aparcado.
1023
+ */
1024
+ const deadSource = only !== undefined && read.length >= batchSize ? census : read;
1025
+ const deadEntries = deadSource
1026
+ .filter((item) => only === undefined || only.has(String(item.id)))
1027
+ .map((item) => ({
1028
+ id: item.id,
1029
+ change: item.change,
1030
+ status: 'dead',
1031
+ attempts: item.attempts,
1032
+ ...(item.error === undefined ? {} : { reason: item.error }),
1033
+ }));
1034
+ /**
1035
+ * **Qué fue de los ids pedidos que la pasada NO vio** (cierre-2 de alpha.10 ·
1036
+ * C2). Hasta el cierre-2 todos eran `missing`; pero un id fuera del lote porque
1037
+ * la pasada alcanzó `limit` sigue PENDIENTE. Se pregunta a la cola —filtrada por
1038
+ * esos ids, sin límite propio, UNA lectura y solo si hay alguno sin ver—: lo que
1039
+ * sigue pendiente es `notEvaluated`; el resto, `missing` (no está en la cola: lo
1040
+ * aparcado ya está en `seen`).
1041
+ */
1042
+ const unseenOf = async (seen) => {
1043
+ if (only === undefined)
1044
+ return { missing: [], notEvaluated: [] };
1045
+ const unseen = [...only.values()].filter((id) => !seen.has(id)).map((id) => _a.#rawId(onlyIds, id));
1046
+ if (unseen.length === 0)
1047
+ return { missing: [], notEvaluated: [] };
1048
+ const stillPending = await outbox.pending(unseen.length, undefined, { ids: unseen });
1049
+ _a.#assertOnlyHonoured(stillPending, new Set(unseen.map(String)));
1050
+ const pendingIds = new Set(stillPending.map((item) => String(item.id)));
1051
+ return {
1052
+ missing: unseen.filter((id) => !pendingIds.has(String(id))),
1053
+ notEvaluated: unseen.filter((id) => pendingIds.has(String(id))),
1054
+ };
1055
+ };
1056
+ return { only, filter, dead, census, deadCount, deadEntries, unseenOf };
1057
+ }
1058
+ /** El reporte de una pasada que NO entró: otra tiene el lease. Nada aplicado, nada tocado. */
1059
+ async #busyReport(outbox, inputs) {
1060
+ const { remaining, queue } = await this.#queueStatus(outbox);
1061
+ return {
1062
+ applied: [],
1063
+ failed: null,
1064
+ failures: [],
1065
+ deferred: [],
1066
+ dead: inputs.dead,
1067
+ deadCount: inputs.deadCount,
1068
+ queue,
1069
+ busy: true,
1070
+ remaining,
1071
+ dryRun: false,
1072
+ wouldApply: [],
1073
+ entries: inputs.deadEntries,
1074
+ missing: [],
1075
+ notEvaluated: [],
1076
+ };
1077
+ }
1078
+ /**
1079
+ * **La cabeza GLOBAL de la cola y su edad** (2.4.0-alpha.8; panel relay 2,
1080
+ * cuestión 5). Es el `pending(1)` que la pasada ya hacía al final para
1081
+ * `remaining` —cero consultas nuevas—, leído SIN filtro aunque haya `only`:
1082
+ * la edad, como `remaining` y `dead`, es de la cola entera. La edad la mide
1083
+ * el reloj INYECTABLE del manager (`#wallMs`), nunca `Date.now()`.
1084
+ */
1085
+ async #queueStatus(outbox) {
1086
+ const head = await outbox.pending(1);
1087
+ return { remaining: head.length > 0, queue: this.#queueOf(head[0]) };
1088
+ }
1089
+ /**
1090
+ * La edad de una cabeza: `Math.max(0, ahora − createdAt)` —nunca negativa:
1091
+ * el sello lo puso el reloj de OTRA réplica, con su skew—; sin cabeza o sin
1092
+ * `createdAt` (una outbox de terceros que no lo trae) ⇒ `null` en los dos
1093
+ * campos, jamás `0` ni «ahora».
1094
+ */
1095
+ #queueOf(head) {
1096
+ const createdAt = head?.createdAt;
1097
+ if (!(createdAt instanceof Date) || Number.isNaN(createdAt.getTime()))
1098
+ return { oldestPendingAt: null, pendingAgeMs: null };
1099
+ return { oldestPendingAt: createdAt, pendingAgeMs: Math.max(0, this.#root().#wallMs() - createdAt.getTime()) };
1100
+ }
1101
+ /**
1102
+ * **El lease del escritor único, con espera acotada** (2.4.0-alpha.8;
1103
+ * `waitForLeaseMs`, panel relay 2, cuestión 7). Con `0` (el default) es
1104
+ * UN intento y `null` = `busy`, como siempre — el caso «dos pasadas a la vez
1105
+ * ⇒ busy» no cambia. Con un tope, reintenta `acquire()` cada
1106
+ * `RELAY_LEASE_POLL_MS` hasta vencerlo: `busy` al vencer, jamás un cuelgue.
1107
+ * El reloj es el inyectable del manager (nunca `Date.now()` en `src/`).
1108
+ * Exige pool ≥ 2 (README): con pool 1 el que espera puede estar reteniendo
1109
+ * la única conexión que necesita el que tiene el lease.
1110
+ */
1111
+ async #acquireLease(outbox, waitForLeaseMs) {
1112
+ if (!outbox.acquire)
1113
+ return null;
1114
+ let lease = await outbox.acquire();
1115
+ if (lease !== null || waitForLeaseMs <= 0)
1116
+ return lease;
1117
+ const deadline = this.#root().#wallMs() + waitForLeaseMs;
1118
+ // Dos topes a la vez: el reloj de pared (inyectable) y el NÚMERO de sondeos —
1119
+ // con un reloj fijo el primero no vence jamás (medido por el auditor de alpha.8).
1120
+ const maxPolls = Math.ceil(waitForLeaseMs / RELAY_LEASE_POLL_MS);
1121
+ for (let polls = 0; lease === null && polls < maxPolls && this.#root().#wallMs() < deadline; polls++) {
1122
+ await new Promise((resolve) => setTimeout(resolve, RELAY_LEASE_POLL_MS));
1123
+ lease = await outbox.acquire();
1124
+ }
1125
+ return lease;
1126
+ }
1127
+ /** `waitForLeaseMs` normalizado: entero >= 0 (500 si no); ausente = 0. */
1128
+ static #waitMs(value) {
1129
+ if (value === undefined)
1130
+ return 0;
1131
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {
1132
+ throw new AuthorizationConfigError(`authz:scopes:relay: waitForLeaseMs debe ser un entero >= 0 en milisegundos (llegó ${String(value)})`);
1133
+ }
1134
+ if (value > RELAY_WAIT_LEASE_MAX_MS) {
1135
+ throw new AuthorizationConfigError(`authz:scopes:relay: waitForLeaseMs = ${value} supera el tope RELAY_WAIT_LEASE_MAX_MS (${RELAY_WAIT_LEASE_MAX_MS} ms): ` +
1136
+ `una espera sin tope cuelga la petición del tenant; acorta la pasada (only/limit) en vez de esperar más.`);
1137
+ }
1138
+ return value;
1139
+ }
1140
+ /**
1141
+ * **La pasada, ya con el lease tomado**: lo que hasta 2.4.0-alpha.7 era el
1142
+ * cuerpo de `relayScopeChanges`. Compartida con `requeueScopeChanges({
1143
+ * apply })`, que la llama bajo SU lease con `only` = los ids reencolados
1144
+ * (auditor 🟠 5: tomar el lease dos veces era `busy` en verde).
1145
+ */
1146
+ async #drainScopeChanges(outbox, input) {
1147
+ const { limit, batchSize, only, filter, dead, census, deadCount, deadEntries, unseenOf } = input;
1148
+ const driver = await this.driver();
1149
+ const applied = [];
1150
+ const deferred = [];
1151
+ const failures = [];
1152
+ const entries = [];
1153
+ /**
1154
+ * Claves de scope contaminadas ⇒ el id que las contaminó y el ORIGEN del
1155
+ * bloqueo (alpha.5 · cierre, auditor 🟠 1). `parked: true` = la causa
1156
+ * raíz (transitiva) es una entrada APARCADA: tapona solo lo que concede
1157
+ * o recuelga (`attached`/`moved`), NUNCA un `detached` — si no, un
1158
+ * `attached` aplazado por aparcado congelaba la purga del nodo que el
1159
+ * tenant ya borró, para siempre (medido: el scope conservaba sus hechos
1160
+ * y su arista, y seguía concediendo; invariante 11). `parked: false` =
1161
+ * un FALLO real de esta pasada (o una pendiente anterior no pedida):
1162
+ * contamina todo, `detached` incluido — el orden entre un `attached`
1163
+ * que falló y la purga de ese mismo nodo sigue importando (3b-2h).
1164
+ */
1165
+ const blocked = new Map();
1166
+ /** Origen ya sondeado de entradas NO pedidas (alpha.6): `parked` = retenida solo por un aparcado. Se olvida en cuanto la pasada registra un bloqueo que no es de aparcado (podría contaminar una clave ya sondeada). */
1167
+ const origins = new Map();
1168
+ const block = (key, id, origin) => {
1169
+ const current = blocked.get(key);
1170
+ // Precedencia `failed > pending > parked` (2.4.0-alpha.8; desde el
1171
+ // cierre de alpha.5: un fallo real pisa a un aparcado, nunca al revés).
1172
+ if (current === undefined || BLOCK_RANK[origin] > BLOCK_RANK[current.origin])
1173
+ blocked.set(key, { id, origin });
1174
+ if (origin !== 'parked')
1175
+ origins.clear();
1176
+ };
1177
+ /**
1178
+ * **Las claves de lo APARCADO** (alpha.5 · R2a): taponan lo que concede
1179
+ * o recuelga y dejan pasar lo que retira. El censo es COMPLETO —no
1180
+ * depende de `limit` ni de `batchSize` (cierre, 🟠 2)— con su propia
1181
+ * cota; el reporte sigue enseñando las primeras `batchSize`.
1182
+ */
1183
+ const parkedKeys = _a.#parkedKeys(census);
1184
+ // Una outbox que no marca lo aplicado devolvería el mismo pendiente
1185
+ // para siempre: el relay no puede quedarse dando vueltas ni
1186
+ // "arreglarlo" por su cuenta, así que lo denuncia (500) en cuanto
1187
+ // vuelve a ver un id que YA aplicó.
1188
+ const done = new Set();
1189
+ /** Ids que esta pasada dejó a propósito (fallo o aplazo): reaparecen. */
1190
+ const parked = new Set();
1191
+ /** El último id visto: la outbox pagina desde ahí (lo saltado se queda). */
1192
+ let after;
1193
+ const defer = (item, keys, reason, error, origin) => {
1194
+ for (const key of keys)
1195
+ block(key, item.id, origin);
1196
+ parked.add(String(item.id));
1197
+ deferred.push({ id: item.id, change: item.change, attempts: item.attempts, error });
1198
+ // `origin` se DERIVA del bloqueo (2.4.0-alpha.8), jamás del prefijo de `reason`.
1199
+ entries.push({ id: item.id, change: item.change, status: 'deferred', reason, attempts: item.attempts, origin });
1200
+ };
1201
+ outer: while (applied.length < limit) {
1202
+ // **La barrera del freeze se RE-AFIRMA por lote** (3b-8 · B3). La
1203
+ // mirada única de la entrada dejaba hasta `DEFAULT_RELAY_LIMIT`
1204
+ // (10.000) escrituras de árbol colándose DESPUÉS de que otra pasada
1205
+ // adquiriera el freeze durable: escrituras que no salen en ningún
1206
+ // contador de la pasada certificada y que pueden invalidar su
1207
+ // resultado. El trade-off documentado en freeze.ts cubre «una
1208
+ // escritura que ya pasó su barrera», no una pasada entera. El coste
1209
+ // (una lectura de la fila `id=2` por lote; 0,14 ms/escritura ya
1210
+ // medidos y aceptados) va fuera del camino caliente. Un freeze
1211
+ // adquirido a mitad corta AQUÍ con el 503 reintentable de siempre:
1212
+ // lo ya aplicado está marcado en la outbox (la pasada es reanudable)
1213
+ // y el resto sigue pendiente para después de la ventana.
1214
+ await this.#assertNotFrozen('authz:scopes:relay');
1215
+ const batch = await outbox.pending(Math.min(batchSize, limit - applied.length), after, filter);
1216
+ if (batch.length === 0)
1217
+ break;
1218
+ _a.#assertOnlyHonoured(batch, only);
1219
+ let progress = false;
1220
+ for (const item of batch) {
1221
+ const id = String(item.id);
1222
+ if (done.has(id)) {
1223
+ throw new AuthorizationConfigError(`authz:scopes:relay: la outbox sigue devolviendo el cambio ${id} como pendiente después de markApplied. ` +
1224
+ 'Tu implementación de ScopeOutbox no marca lo aplicado; el relay para antes de dar vueltas para siempre.');
1225
+ }
1226
+ if (parked.has(id))
1227
+ continue;
1228
+ progress = true;
1229
+ after = item.id;
1230
+ if (applied.length >= limit)
1231
+ break outer;
1232
+ const keys = _a.#changeKeys(item.change);
1233
+ // Una purga solo la frena un bloqueo cuyo origen sea un FALLO real;
1234
+ // el que nace de un aparcado la deja pasar (invariante 11).
1235
+ const collision = keys.find((key) => {
1236
+ const hit = blocked.get(key);
1237
+ return hit !== undefined && !(item.change.op === 'detached' && hit.origin === 'parked');
1238
+ });
1239
+ if (collision !== undefined) {
1240
+ const by = blocked.get(collision);
1241
+ defer(item, keys, `depends-on:${by.id}`, `aplazado: depende de ${collision}, que quedó sin aplicar en esta pasada (#${by.id})`, by.origin);
1242
+ continue;
1243
+ }
1244
+ // Lo aparcado tapona lo que CONCEDE o recuelga; una purga pasa.
1245
+ if (item.change.op !== 'detached') {
1246
+ const parkedKey = keys.find((key) => parkedKeys.has(key));
1247
+ if (parkedKey !== undefined) {
1248
+ const by = parkedKeys.get(parkedKey);
1249
+ defer(item, keys, `parked:${by}`, `aplazado: depende de ${parkedKey}, que nombra la entrada APARCADA #${by} (parked:${by}); no se aplica hasta que alguien la mire`, 'parked');
1250
+ continue;
1251
+ }
1252
+ }
1253
+ // Con `only`, la sonda contra la cola ENTERA: una pendiente anterior
1254
+ // que comparta clave y no se pidió ⇒ aplazado, nunca fuera de orden.
1255
+ // **Y la sonda hereda el ORIGEN de esa anterior** (alpha.6, auditor 🟡 A
1256
+ // de alpha.5): si la anterior está —transitivamente— retenida solo por
1257
+ // un APARCADO, es el mismo bloqueo `parked:` que en la pasada completa
1258
+ // deja pasar la purga (invariante 11): el `detached` SE APLICA. Si la
1259
+ // anterior es un `attached`/`moved` pendiente de verdad (aplicable, o
1260
+ // retenido por un FALLO real), el `detached` sigue `deferred`: aplicarlo
1261
+ // antes que un `moved` pendiente del mismo scope dejaría deriva.
1262
+ if (only !== undefined) {
1263
+ const earlier = await this.#dependencyOf(outbox, item, batchSize);
1264
+ if (earlier !== null) {
1265
+ const origin = await this.#blockOriginOf(outbox, earlier, blocked, parkedKeys, batchSize, origins);
1266
+ if (!(item.change.op === 'detached' && origin === 'parked')) {
1267
+ const shared = _a.#changeKeys(earlier.change).find((key) => keys.includes(key)) ?? keys[0];
1268
+ defer(item, keys, `depends-on:${earlier.id}`, `aplazado: depende de ${shared}, que nombra la entrada anterior #${earlier.id} todavía sin aplicar (depends-on:${earlier.id}); ` +
1269
+ (origin === 'parked' ? 'espera a una entrada APARCADA; mira dead()' : 'corre el relay completo'), origin);
1270
+ continue;
1271
+ }
1272
+ }
1273
+ }
1274
+ try {
1275
+ await this.#applyScopeChange(driver, item);
1276
+ }
1277
+ catch (error) {
1278
+ const message = error instanceof Error ? error.message : String(error);
1279
+ const transient = _a.#isTransientRelayFailure(error);
1280
+ await outbox.markFailed(item.id, message, { transient });
1281
+ for (const key of keys)
1282
+ block(key, item.id, 'failed');
1283
+ parked.add(id);
1284
+ failures.push({ id: item.id, change: item.change, error: message, transient });
1285
+ entries.push({ id: item.id, change: item.change, status: 'failed', reason: message, attempts: item.attempts });
1286
+ continue;
1287
+ }
1288
+ await outbox.markApplied(item.id);
1289
+ done.add(id);
1290
+ applied.push({ id: item.id, change: item.change, attempts: item.attempts });
1291
+ entries.push({ id: item.id, change: item.change, status: 'applied', attempts: item.attempts });
360
1292
  }
361
- await this.#notifyCatalog({ action: 'role_purged', actor, role, owner, permissions });
1293
+ // Una outbox que ignora `after` devuelve el mismo lote atascado: la
1294
+ // pasada termina aquí en vez de dar vueltas (lo que quede, y lo que
1295
+ // haya detrás, sigue pendiente para la siguiente).
1296
+ if (!progress)
1297
+ break;
1298
+ }
1299
+ entries.push(...deadEntries);
1300
+ // La cabeza global de la cola: `remaining` y, desde alpha.8, su EDAD.
1301
+ const { remaining, queue } = await this.#queueStatus(outbox);
1302
+ return {
1303
+ applied,
1304
+ failed: failures[0] ?? null,
1305
+ failures,
1306
+ deferred,
1307
+ dead,
1308
+ deadCount,
1309
+ queue,
1310
+ busy: false,
1311
+ remaining,
1312
+ dryRun: false,
1313
+ wouldApply: [],
1314
+ entries,
1315
+ ...(await unseenOf(new Set(entries.map((e) => String(e.id))))),
1316
+ };
1317
+ }
1318
+ /**
1319
+ * `only` normalizado (alpha.5 · R1): un conjunto de ids como cadena (el id
1320
+ * de `sqlScopeOutbox` es un entero que según el motor y el driver llega
1321
+ * como `number` o como `string`; comparar con `===` sería un no-op
1322
+ * silencioso). `undefined` = sin filtro. **`[]` es 500**, nunca «sin
1323
+ * filtro»: un `filter` vacío del consumidor —o una outbox de terceros sin
1324
+ * recibo, que deja `queued: []`— no puede acabar drenando la cola entera
1325
+ * bajo el lease de una petición HTTP, ni en un reporte vacío en verde.
1326
+ */
1327
+ static #onlyOf(only) {
1328
+ if (only === undefined)
1329
+ return undefined;
1330
+ if (!Array.isArray(only) || only.some((id) => typeof id !== 'number' && typeof id !== 'string')) {
1331
+ throw new AuthorizationConfigError(`authz:scopes:relay: 'only' debe ser una lista de ids (number | string); llegó ${JSON.stringify(only)}`);
1332
+ }
1333
+ if (only.length === 0) {
1334
+ throw new AuthorizationConfigError("authz:scopes:relay: 'only' está vacío. Un recibo vacío significa que no hay nada que relevar (sin outbox el cambio ya " +
1335
+ 'se aplicó en línea; con una outbox de terceros sin recibo no hay `only`): relevar «nada» no es relevar «todo». ' +
1336
+ 'Quita `only` para drenar la cola entera.');
1337
+ }
1338
+ return new Set(only.map(String));
1339
+ }
1340
+ /** El id tal como lo pasó el llamante en `only` (para `missing`). */
1341
+ static #rawId(only, key) {
1342
+ return only.find((id) => String(id) === key) ?? key;
1343
+ }
1344
+ /**
1345
+ * Una outbox que IGNORA `filter.ids` devuelve lo que no se pidió: el
1346
+ * backlog ajeno se aplicaría dentro de la petición del consumidor, en
1347
+ * silencio. Se detecta y se para (500), como el `markApplied` no honrado.
1348
+ */
1349
+ static #assertOnlyHonoured(batch, only) {
1350
+ if (only === undefined)
1351
+ return;
1352
+ const stranger = batch.find((item) => !only.has(String(item.id)));
1353
+ if (stranger !== undefined) {
1354
+ throw new AuthorizationConfigError(`authz:scopes:relay: la outbox devolvió el cambio ${String(stranger.id)}, que no está en 'only'. ` +
1355
+ 'Tu implementación de ScopeOutbox no honra `pending(limit, after, { ids })`: el relay para antes de aplicar ' +
1356
+ 'lo que no se pidió (filtra en el puerto, no en memoria).');
362
1357
  }
363
- return { purgedRoles: owned.length, truncated, ...(reason ? { reason } : {}) };
364
1358
  }
365
1359
  /**
366
- * El actor de un `scopes.detached` solo tumba roles de rango MENOR que el
367
- * suyo (3E · P3, auditor A3): un admin de unit con rank 5 no puede
368
- * destruir por la vía del árbol el rol de rank 40 que `deleteScopedRole`
369
- * le niega. Se comprueban TODOS antes de purgar ninguno (nada a medias).
1360
+ * La caída del BACKEND no dice nada de la entrada (alpha.5 · R2a): un
1361
+ * `AuthorizationBackendError` (503; el timeout es uno), y en general
1362
+ * cualquier 503 (el freeze, el resolutor caído). Todo lo demás —422, 500,
1363
+ * `E_AUTHZ_SCOPE_TREE_DRIFT`, el padre inexistente, un error sin status de
1364
+ * un driver de terceros— es de la entrada y cuenta hacia el aparcado.
1365
+ */
1366
+ static #isTransientRelayFailure(error) {
1367
+ if (error instanceof AuthorizationBackendError)
1368
+ return true;
1369
+ return typeof error === 'object' && error !== null && error.status === 503;
1370
+ }
1371
+ /**
1372
+ * Las claves de scope de lo APARCADO ⇒ el id aparcado que las nombra
1373
+ * (alpha.5 · R2a). El censo lo lee `#relayInputs`: el `dead` ya leído si
1374
+ * cabe entero en un lote (menos de `batchSize` filas = no hay más); si no,
1375
+ * la cola ENTERA hasta `PARKED_CENSUS_MAX` — nunca acotado por `limit`
1376
+ * (cierre, 🟠 2: con `--limit 50` o `{ only, limit: 1 }` la aparcada 101 no
1377
+ * taponaba y el `attached` colgaba del padre viejo)—, y si la cota se
1378
+ * alcanza la pasada se niega (500): un tapón incompleto no es un tapón.
1379
+ */
1380
+ static #parkedKeys(census) {
1381
+ if (census.length >= PARKED_CENSUS_MAX) {
1382
+ throw new AuthorizationConfigError(`authz:scopes:relay: la outbox tiene ${PARKED_CENSUS_MAX} o más entradas APARCADAS y el censo de sus claves no cabe ` +
1383
+ `(PARKED_CENSUS_MAX): sin el censo entero el tapón por clave sería ciego y un attached podría colgarse del padre ` +
1384
+ `VIEJO. No se ha aplicado nada. Mira dead(), reencola o reconcilia antes de volver a relevar.`);
1385
+ }
1386
+ const keys = new Map();
1387
+ for (const item of census) {
1388
+ // **Solo la clave del HIJO** (2.4.0-alpha.8 · variante B; dueño: sí).
1389
+ // Una aparcada dice que la POSICIÓN DE SU HIJO en el store es
1390
+ // incorrecta; no dice nada de su padre nuevo: un `attached(otraUnit,
1391
+ // orgA)` detrás de un `moved(unit → orgA)` aparcado escribe otra arista
1392
+ // y conmuta (medido, `m7-tapon.ts`: se aplazaba sin motivo y el store
1393
+ // conservaba el padre VIEJO de todo lo que colgara de orgA). Lo que SÍ
1394
+ // nombra al hijo —`attached(child, unit)`, `moved(unit → orgC)`— sigue
1395
+ // taponado. Vive SOLO aquí, en la siembra: al COMPARAR (`#changeKeys`
1396
+ // en `blocked`, el respaldo de la sonda) van siempre las dos claves, o
1397
+ // el caso 1136 se pierde (trampa medida por el juez).
1398
+ const key = scopeKey(item.change.child);
1399
+ if (!keys.has(key))
1400
+ keys.set(key, item.id);
1401
+ }
1402
+ return keys;
1403
+ }
1404
+ /**
1405
+ * **La sonda de dependencia de `only`** (alpha.5 · R1): la pendiente más
1406
+ * antigua ANTERIOR a `item` que comparta clave de scope con ella, o `null`.
1407
+ * Con `earlierPending` es una consulta de existencia (0,69 ms medidos);
1408
+ * sin ella se pagina `pending()` desde el principio hasta `item.id` —
1409
+ * correcto, O(cola)— y si la cola no se deja recorrer en
1410
+ * `RELAY_WINDOW_MAX_PAGES` lotes se para (500 nombrando el método), nunca
1411
+ * se aplica sin saber.
1412
+ */
1413
+ async #dependencyOf(outbox, item, batchSize) {
1414
+ const keys = _a.#changeKeys(item.change);
1415
+ if (typeof outbox.earlierPending === 'function') {
1416
+ // La misma lista que `#changeKeys` (la raíz exenta, alpha.8): el SQL del
1417
+ // puerto sí sabe casar `parent_type = 'app'`; el manager no se lo pide.
1418
+ return outbox.earlierPending(item.id, _a.#changeScopes(item.change));
1419
+ }
1420
+ const mine = String(item.id);
1421
+ let after;
1422
+ for (let page = 0; page < RELAY_WINDOW_MAX_PAGES; page++) {
1423
+ const batch = await outbox.pending(batchSize, after);
1424
+ for (const earlier of batch) {
1425
+ if (String(earlier.id) === mine)
1426
+ return null;
1427
+ if (_a.#changeKeys(earlier.change).some((key) => keys.includes(key)))
1428
+ return earlier;
1429
+ }
1430
+ if (batch.length < batchSize)
1431
+ return null;
1432
+ after = batch[batch.length - 1].id;
1433
+ }
1434
+ throw new AuthorizationConfigError(`authz:scopes:relay: no se pudo sondear si el cambio ${mine} depende de una entrada anterior: la cola tiene más de ` +
1435
+ `${RELAY_WINDOW_MAX_PAGES * batchSize} pendientes por delante y tu ScopeOutbox no implementa earlierPending(). ` +
1436
+ 'Implementa la sonda, o releva sin `only`.');
1437
+ }
1438
+ /**
1439
+ * **El ORIGEN del bloqueo de una entrada NO pedida** (2.4.0-alpha.6): ¿en la
1440
+ * pasada COMPLETA saldría `deferred` por un APARCADO —directo (`parked:`) o
1441
+ * heredado (`depends-on:` de otra que lo está)— y no por un fallo real ni
1442
+ * porque simplemente se aplicaría? `true` = retenida solo por un aparcado.
1443
+ * Es la misma regla que aplica `block`/`defer` a lo que la pasada SÍ procesa,
1444
+ * simulada hacia atrás sobre lo que no procesa:
1445
+ * 1. una clave suya en `blocked` (esta pasada) con origen FALLO ⇒ fallo;
1446
+ * 2. `attached`/`moved` con una clave en `parkedKeys`, o en `blocked` con
1447
+ * origen aparcado ⇒ aparcado;
1448
+ * 3. si no, su propia anterior con clave compartida (`earlierPending`):
1449
+ * ninguna ⇒ se aplicaría ⇒ NO es aparcado (la pedida espera su turno);
1450
+ * alguna ⇒ el origen de ESA (recursivo, ids decrecientes, memoizado,
1451
+ * `RELAY_ORIGIN_MAX_HOPS`; pasada la cota ⇒ fallo, hacia lo seguro).
1452
+ * Un `detached` anterior nunca es «aparcado» (una purga pasa; si sigue
1453
+ * pendiente es porque se aplicará): la pedida espera detrás.
1454
+ */
1455
+ async #blockOriginOf(outbox, entry, blocked, parkedKeys, batchSize, memo, hops = 0) {
1456
+ const id = String(entry.id);
1457
+ const known = memo.get(id);
1458
+ if (known !== undefined)
1459
+ return known;
1460
+ const decide = (origin) => {
1461
+ memo.set(id, origin);
1462
+ return origin;
1463
+ };
1464
+ // Pasada la cota, hacia lo seguro: como un fallo real.
1465
+ if (hops >= RELAY_ORIGIN_MAX_HOPS)
1466
+ return decide('failed');
1467
+ const keys = _a.#changeKeys(entry.change);
1468
+ const hits = keys.map((key) => blocked.get(key)).filter((hit) => hit !== undefined);
1469
+ // Desde 2.4.0-alpha.8 el origen es TRI-estado (`failed > pending > parked`):
1470
+ // la decisión aplicar/aplazar no cambia (solo `parked` exime a la purga);
1471
+ // lo que cambia es que el reporte distingue «espera a una pendiente de
1472
+ // verdad» de «bloqueada por un fallo».
1473
+ if (hits.some((hit) => hit.origin === 'failed'))
1474
+ return decide('failed');
1475
+ if (hits.some((hit) => hit.origin === 'pending'))
1476
+ return decide('pending');
1477
+ if (entry.change.op !== 'detached') {
1478
+ if (hits.length > 0 || keys.some((key) => parkedKeys.has(key)))
1479
+ return decide('parked');
1480
+ }
1481
+ const earlier = await this.#dependencyOf(outbox, entry, batchSize);
1482
+ // Ninguna anterior: se aplicaría en la pasada completa ⇒ la pedida espera a una PENDIENTE de verdad.
1483
+ if (earlier === null)
1484
+ return decide('pending');
1485
+ return decide(await this.#blockOriginOf(outbox, earlier, blocked, parkedKeys, batchSize, memo, hops + 1));
1486
+ }
1487
+ /**
1488
+ * **Devuelve entradas APARCADAS a la cola** (2.4.0-alpha.5 · R2b; panel
1489
+ * relay, cuestión 2). Es lo que hay detrás de `node ace authz:scopes:requeue
1490
+ * --id`. Operación de PLATAFORMA como `relayScopeChanges` y `driver()`: sin
1491
+ * actor, sin `within`, **no se expone por HTTP**.
1492
+ *
1493
+ * **La sonda anti-reversión vive AQUÍ, no en la outbox**: reaplicar una
1494
+ * entrada ANTERIOR a otra ya APLICADA sobre la misma clave de scope
1495
+ * reescribe el árbol del store en un orden que nunca existió — el `moved`
1496
+ * viejo tras el nuevo deja el padre VIEJO con UN solo padre, así que
1497
+ * `assertOneParent` no lo delata y el tenant antiguo recupera el acceso
1498
+ * (auditor 🔴 2, `relay-auditor-5.ts`, medido). Por eso, antes de tocar la
1499
+ * cola, cada aparcada pedida se compara contra las aplicadas posteriores
1500
+ * que compartan clave (`outbox.laterApplied`, espejo de `earlierPending`)
1501
+ * y si hay alguna se NIEGA con 500 `E_AUTHZ_REQUEUE_REFUSED` nombrándola —
1502
+ * todo o nada, cero filas tocadas—. El remedio correcto es
1503
+ * `authz:reconcile --to=<driver>`, que recalcula con el árbol de HOY. La
1504
+ * sonda es del manager porque necesita las claves del cambio
1505
+ * (`#changeKeys`); `outbox.requeue` queda mecánico y una outbox de
1506
+ * terceros la hereda gratis.
1507
+ *
1508
+ * **`force`** salta la sonda y devuelve el diff en `overrides`. Existe para
1509
+ * el comando ace —que lo imprime y exige el flag explícito (decisión del
1510
+ * dueño del 2026-09-09 (2))— y para nada más: llamarlo con `force: true`
1511
+ * desde un endpoint es policy en una API sin policy.
370
1512
  *
371
- * **El rango se mide POR ROL, en la cadena del OWNER de cada uno** — lo
372
- * mismo que `deleteScopedRole`, que es la otra puerta a lo mismo (3G · W1,
373
- * auditor P1). Medirlo en la cadena del scope NOTIFICADO y aplicarlo a
374
- * roles de OTROS owners era un fail-open de manual: con `descendantsOf`
375
- * declarado (S2) y la fila del padre ya borrada (S1), `detached(padre)`
376
- * destruía los roles locales de descendientes VIVOS —de cualquier rango,
377
- * concediendo en ese instante— porque la cadena del padre no resolvía y la
378
- * policy no llegaba a correr. Las dos piezas eran correctas por separado.
1513
+ * `attempts` queda en `maxAttempts − 1` (lo hace la outbox): si la causa
1514
+ * sigue rota la entrada vuelve a aparcarse en UNA pasada y contamina su
1515
+ * clave solo en esa pasada —un requeue automatizado de lo irreparable no
1516
+ * es un grifo de aplazamiento permanente (auditor 🔴 2, segunda mitad)—; si
1517
+ * el operador la arregló, entra a la primera. No hay bucle: lo aparcado no
1518
+ * vuelve solo a `pending()`.
379
1519
  *
380
- * La comprobación se salta SOLO para los roles cuyo PROPIO owner tampoco
381
- * resuelve: esos son los realmente inalcanzables (no conceden, no son
382
- * membresía, no se pueden asignar ni borrar por `deleteScopedRole`) y son
383
- * los que S1 vino a desbloquear. Devuelve `true` si se saltó alguno, para
384
- * que el evento y el `ScopeDetachOutcome` lo digan (`reason`).
1520
+ * Exige `dead`, `requeue` y `laterApplied` en el puerto (500
1521
+ * `E_AUTHZ_UNSUPPORTED` nombrando el que falte): sin censo no se sabe qué
1522
+ * se reencola y sin sonda no hay requeue. Sin `apply` no pasa por la
1523
+ * barrera del freeze: no escribe en el backend; la pasada que aplique la
1524
+ * entrada sí la pasa.
385
1525
  *
386
- * Coste: un `resolveChain` y una lectura de roles del actor por OWNER
387
- * distinto (memoizados por clave), no por rol.
1526
+ * **`apply: true` — reencolar Y relevar en la misma llamada** (2.4.0-alpha.8;
1527
+ * panel relay 2, cuestión 4; decisión del dueño 2026-09-10 (4)). Medido
1528
+ * (tester P14): la reencolada vuelve con su id ORIGINAL (menor) y la
1529
+ * siguiente pasada `only` del tenant salía `deferred · depends-on:` — el
1530
+ * incidente no se cerraba con un comando. Con `apply`, tras reencolar se
1531
+ * releva SOLO eso (`only` = exactamente los ids reencolados: los
1532
+ * dependientes NO se arrastran, quedan `deferred`/pendientes y el reporte lo
1533
+ * dice —«`only` es mi recibo»—) y el reporte del relay viaja en
1534
+ * `report.relay`. Cuatro condiciones, todas fijadas con caso: (1) **un solo
1535
+ * lease** para toda la llamada (tomarlo dos veces era `busy` en verde,
1536
+ * auditor 🟠 5) — se toma PRIMERO, y si está ocupado es `relay.busy` con
1537
+ * CERO filas reencoladas (reencolar y decir `busy` dejaba `attempts = max −
1538
+ * 1` y la entrada se volvía a aparcar contaminando su clave); (2) **pasa la
1539
+ * barrera del freeze** ANTES de tocar nada (con `apply` escribe el árbol);
1540
+ * (3) **excluyente con `force`** (500 juntos: con las dos, revertir el árbol
1541
+ * pasaba de dos llamadas de plataforma a una, auditor 🟠 6); (4) la sonda
1542
+ * anti-reversión corre ANTES de reencolar, como siempre. `waitForLeaseMs`
1543
+ * (solo con `apply`) es la misma espera acotada que en el relay, para que
1544
+ * el comando que cierra el incidente no pierda siempre contra el cron.
388
1545
  */
389
- async #assertAboveOwnedRoles(driver, actor, roles) {
390
- const catalog = await this.#catalogFor(driver).view();
391
- // `null` = el owner de ese rol tampoco está en el árbol: nada que medir.
392
- const rankIn = new Map();
393
- let skipped = false;
394
- for (const role of roles) {
395
- if (!rankIn.has(role.owner)) {
396
- const owner = this.#ownerOf(role);
397
- const ownerChain = await resolveChain(this.#freshResolver(), owner, 'scopes.detached');
398
- rankIn.set(role.owner, ownerChain ? (await this.#rolesAlong(driver, actor, ownerChain, catalog)).rank : null);
1546
+ async requeueScopeChanges(ids, options = {}) {
1547
+ _a.#assertKnownOptions('requeueScopeChanges', options, REQUEUE_OPTIONS);
1548
+ const apply = options.apply === true;
1549
+ if (apply && options.force === true) {
1550
+ throw new AuthorizationConfigError('authz:scopes:requeue: apply y force no se combinan: con las dos, revertir el árbol del store por encima de lo ya ' +
1551
+ 'aplicado sería UNA sola llamada. Reencola con --force y releva después a sabiendas, o usa --apply sin --force. ' +
1552
+ 'No se ha hecho nada.');
1553
+ }
1554
+ if (!apply && options.waitForLeaseMs !== undefined) {
1555
+ throw new AuthorizationConfigError('authz:scopes:requeue: waitForLeaseMs solo tiene sentido con apply (sin apply no se toma ningún lease). No se ha hecho nada.');
1556
+ }
1557
+ const waitForLeaseMs = _a.#waitMs(options.waitForLeaseMs);
1558
+ // Con `apply` se ESCRIBE el árbol: la barrera del freeze va antes de nada.
1559
+ if (apply)
1560
+ await this.#assertNotFrozen('authz:scopes:requeue --apply');
1561
+ const outbox = this.#outbox();
1562
+ if (!outbox) {
1563
+ throw new AuthorizationConfigError("authz:scopes:requeue necesita 'scopes.outbox' en config/authorization.ts: sin cola no hay nada aparcado que reencolar.");
1564
+ }
1565
+ const wanted = _a.#requeueIds(ids);
1566
+ for (const method of ['dead', 'requeue', 'laterApplied']) {
1567
+ if (typeof outbox[method] !== 'function') {
1568
+ throw new UnsupportedOperationError(method, 'authz:scopes:requeue', 'scopes.outbox', method === 'laterApplied'
1569
+ ? 'Sin la sonda anti-reversión (laterApplied: las entradas APLICADAS posteriores que comparten clave) no hay ' +
1570
+ 'requeue: reaplicar una entrada vieja por encima de una nueva revierte el árbol del store en silencio.'
1571
+ : method === 'dead'
1572
+ ? 'Sin dead() no se sabe qué está aparcado, y solo lo aparcado se reencola.'
1573
+ : 'Devuelve la entrada aparcada a pending() con attempts = maxAttempts − 1, todo o nada.');
399
1574
  }
400
- const rank = rankIn.get(role.owner);
401
- if (rank === null) {
402
- skipped = true;
403
- continue;
1575
+ }
1576
+ // Con `apply`, el lease PRIMERO (una vez para toda la llamada): ocupado ⇒
1577
+ // `busy` con cero filas tocadas. Sin `apply` no hay lease que tomar.
1578
+ const lease = apply ? await this.#acquireLease(outbox, waitForLeaseMs) : null;
1579
+ if (apply && outbox.acquire && lease === null) {
1580
+ const inputs = await _a.#relayInputs(outbox, [...wanted], DEFAULT_RELAY_BATCH);
1581
+ return { requeued: [], overrides: [], forced: false, relay: await this.#busyReport(outbox, inputs) };
1582
+ }
1583
+ try {
1584
+ return await this.#requeueHeld(outbox, wanted, options.force === true, apply);
1585
+ }
1586
+ finally {
1587
+ await lease?.release();
1588
+ }
1589
+ }
1590
+ /** `requeueScopeChanges` con el lease (si `apply`) ya tomado: censo, sonda anti-reversión, requeue y —con `apply`— el relay de esos ids. */
1591
+ async #requeueHeld(outbox, wanted, force, apply) {
1592
+ // El censo de lo aparcado (acotado como el del relay): solo eso se reencola.
1593
+ const parked = await outbox.dead(DEFAULT_RELAY_LIMIT);
1594
+ const byId = new Map();
1595
+ for (const item of parked)
1596
+ byId.set(String(item.id), item);
1597
+ const unknown = wanted.filter((id) => !byId.has(String(id)));
1598
+ if (unknown.length > 0) {
1599
+ throw new AuthorizationConfigError(`authz:scopes:requeue: ${unknown.map(String).join(', ')} no está(n) aparcada(s) —no existe, ya se aplicó, o sigue ` +
1600
+ `pendiente con intentos por gastar—: no se ha reencolado NADA (todo o nada). Solo se reencola lo que sale por ` +
1601
+ `dead(); una entrada aplicada no revive jamás.`);
1602
+ }
1603
+ const conflicts = [];
1604
+ for (const id of wanted) {
1605
+ const item = byId.get(String(id));
1606
+ // La misma lista que `#changeKeys` (la raíz exenta, alpha.8): dos altas de
1607
+ // tenant no se revierten entre sí, y el conflicto que salía era falso.
1608
+ const scopes = _a.#changeScopes(item.change);
1609
+ // Se pide UNA de más para saber si el diff que va a leer el operador está cortado (cierre, ⚪ 6).
1610
+ const later = await outbox.laterApplied(item.id, scopes, DEFAULT_RELAY_BATCH + 1);
1611
+ const applied = later.slice(0, DEFAULT_RELAY_BATCH);
1612
+ if (applied.length > 0) {
1613
+ conflicts.push({ parked: item, applied, ...(later.length > DEFAULT_RELAY_BATCH ? { truncated: true } : {}) });
404
1614
  }
405
- this.#assertAboveRole(rank, role);
406
1615
  }
407
- return skipped;
1616
+ if (conflicts.length > 0 && !force) {
1617
+ const detail = conflicts
1618
+ .map((conflict) => {
1619
+ const keys = _a.#changeKeys(conflict.parked.change);
1620
+ const shared = conflict.applied.map((entry) => {
1621
+ const key = _a.#changeKeys(entry.change).find((k) => keys.includes(k)) ?? keys[0];
1622
+ return `#${entry.id} (${entry.change.op}, clave ${key})`;
1623
+ });
1624
+ return `#${conflict.parked.id} (${conflict.parked.change.op}) revertiría ${shared.join(', ')}`;
1625
+ })
1626
+ .join('; ');
1627
+ throw new ScopeRequeueRefusedError(`authz:scopes:requeue: reencolar reaplicaría un cambio ANTERIOR a otro ya APLICADO sobre la misma clave de scope, ` +
1628
+ `y el árbol del store quedaría en un orden que nunca existió: ${detail}. No se ha reencolado nada. ` +
1629
+ `Para recalcular con el árbol de HOY: authz:reconcile --to=<driver>. Si de verdad quieres revertir, el comando ` +
1630
+ `ace admite --force (imprime este diff y entra igual).`, conflicts);
1631
+ }
1632
+ await outbox.requeue([...wanted]);
1633
+ const report = { requeued: wanted.map((id) => byId.get(String(id))), overrides: conflicts, forced: force };
1634
+ if (!apply)
1635
+ return report;
1636
+ // Bajo el MISMO lease: solo los ids reencolados, con lo aparcado releído
1637
+ // (las reencoladas ya no salen por `dead()`); la barrera del freeze se
1638
+ // re-afirma por lote dentro, como en toda pasada.
1639
+ const inputs = await _a.#relayInputs(outbox, [...wanted], DEFAULT_RELAY_BATCH);
1640
+ report.relay = await this.#drainScopeChanges(outbox, { limit: DEFAULT_RELAY_LIMIT, batchSize: DEFAULT_RELAY_BATCH, ...inputs });
1641
+ return report;
1642
+ }
1643
+ /** Los ids de `requeue`: lista NO vacía de números o cadenas, sin repetidos (500 si no). */
1644
+ static #requeueIds(ids) {
1645
+ if (!Array.isArray(ids) || ids.length === 0 || ids.some((id) => typeof id !== 'number' && typeof id !== 'string')) {
1646
+ throw new AuthorizationConfigError(`authz:scopes:requeue: 'ids' debe ser una lista NO vacía de ids (number | string); llegó ${JSON.stringify(ids)}. ` +
1647
+ 'Sin ids no se reencola nada: «nada» no es «todo».');
1648
+ }
1649
+ const seen = new Set();
1650
+ return ids.filter((id) => (seen.has(String(id)) ? false : (seen.add(String(id)), true)));
1651
+ }
1652
+ /**
1653
+ * **`authz:reconcile --to=<driver>`** (3b-3a): la ÚNICA primitiva de
1654
+ * migración y verificación del paquete, y el motivo de la fase entera —
1655
+ * «todo en un driver o todo en otro, con una migración idempotente y
1656
+ * bidireccional». Sustituye a `openfga:import`, que el 2k borró.
1657
+ *
1658
+ * Operación de PLATAFORMA, como `driver()` y `relayScopeChanges`: no lleva
1659
+ * actor, no mide rangos y **no se expone por HTTP**.
1660
+ *
1661
+ * El driver de destino se resuelve **por nombre del registro**
1662
+ * (`config.drivers[to]`), no por `config.default`: la migración de verdad
1663
+ * es «el motor sigue corriendo con `database` mientras se llena el store de
1664
+ * `openfga`», y con el default no habría forma de nombrar al destino. Un
1665
+ * driver que no sabe reconstruirse lo dice (500 `E_AUTHZ_UNSUPPORTED`
1666
+ * nombrando `reconcile`); el driver `database` es ese caso: sus tablas SON
1667
+ * el origen y llenarlas desde un store es la otra dirección (3b-3b).
1668
+ *
1669
+ * Durante la pasada que ESCRIBE, las escrituras del motor están CONGELADAS
1670
+ * (`withFrozenWrites`): un `grant` que aterrizara entre la lectura del
1671
+ * origen y la escritura del destino no llegaría al destino y no aparecería
1672
+ * en ningún contador. Las lecturas siguen. El `finally` descongela pase lo
1673
+ * que pase.
1674
+ *
1675
+ * **`--dry-run` NO congela** (3b-6, panel 3 · juez §3). El verificador es
1676
+ * read-only por contrato: no escribe nada, así que no tiene NADA que
1677
+ * proteger, y congelar ahí sería apagar las escrituras a cambio de cero.
1678
+ * Está publicado para correrlo en CI y en un cron, o sea justo el sitio
1679
+ * desde el que un mecanismo de indisponibilidad se dispara solo — hoy
1680
+ * contra el proceso del job, y contra la flota entera el día que el freeze
1681
+ * sea durable. El único contraargumento posible —que congelar estabiliza
1682
+ * sus números— no vale: los números de un verificador read-only no son una
1683
+ * garantía de nada.
1684
+ *
1685
+ * Lo que añade el manager al reporte del driver es lo único que el driver
1686
+ * no puede ver: **la ventana del relay** —los cambios del árbol encolados y
1687
+ * sin aplicar, que son la deriva que el store todavía no conoce (decisión
1688
+ * del dueño del 2026-08-30, consecuencia 4)— y las entradas APARCADAS, que
1689
+ * no son una ventana sino una divergencia permanente.
1690
+ */
1691
+ async reconcile(options) {
1692
+ const name = options.to;
1693
+ const factory = this.#config.drivers?.[name];
1694
+ if (!factory) {
1695
+ throw new AuthorizationConfigError(`authz:reconcile --to=${name}: ese driver no está registrado en config/authorization.ts ` +
1696
+ `(registrados: ${Object.keys(this.#config.drivers ?? {}).join(', ') || 'ninguno'}). ` +
1697
+ `El destino se nombra por su clave en 'drivers', no por el driver activo: migrar es llenar el ` +
1698
+ `destino mientras el motor sigue corriendo con el otro.`);
1699
+ }
1700
+ let target = await factory();
1701
+ const clock = this.#config.clock;
1702
+ if (clock !== undefined) {
1703
+ if (typeof target.withClock !== 'function') {
1704
+ throw new AuthorizationConfigError(`config.clock está declarado pero el driver '${name}' no implementa withClock(now): la migración ` +
1705
+ `escribiría caducidades decididas con otro reloj que el motor.`);
1706
+ }
1707
+ target = target.withClock(clock);
1708
+ }
1709
+ if (typeof target.reconcile !== 'function') {
1710
+ throw new UnsupportedOperationError('reconcile', `authz:reconcile --to=${name}`, name, `El driver '${name}' no sabe reconstruirse desde 'authz_*' + el árbol del consumidor. ` +
1711
+ `El driver 'database' es ese caso a propósito: sus tablas son el ORIGEN.`);
1712
+ }
1713
+ // **La dirección RANCIA** (2.4.0-alpha.10; 🔴 4 del sexto informe de
1714
+ // COGNITIV y cuarta iteración del LINAJE «origen rancio del reconcile»:
1715
+ // 3b-5 cerró la pasada por defecto, el primer guard de alpha.10 cerró
1716
+ // `--from`, y quedaba abierto el origen elegido POR DESCARTE). Regla del
1717
+ // dueño: el tercer defecto de un linaje se arregla estructuralmente, así
1718
+ // que el guard NO mira la bandera, mira el ORIGEN RESUELTO — con la MISMA
1719
+ // decisión que `#factsOrigin` (`#isMaintenancePass`), no con una copia:
1720
+ // si `to` es el driver ACTIVO, el único origen legítimo es `to` mismo (la
1721
+ // pasada de MANTENIMIENTO); cualquier otro —`--from`, el driver elegido por
1722
+ // descarte o las tablas `authz_*`— dejó de ver los hechos en el cutover y la
1723
+ // pasada re-escribiría lo revocado después (con o sin --prune; el seguro de
1724
+ // borrado masivo no salta: el origen no está vacío). Se decide por `to`,
1725
+ // nunca por `from`: el cutover inicial y la vuelta atrás ANTES de cambiar
1726
+ // `default` tienen `to` ≠ activo y pasan. Antes de resolver el origen y de
1727
+ // congelar. `--dry-run` no lanza (es el verificador): lo marca y
1728
+ // `reconcileLines` lo hace deriva. `from === to` sigue siendo el 500
1729
+ // E_AUTHZ_CONFIG de 3b-3b.
1730
+ const stale = this.#sameDriver(name, this.#config.default) &&
1731
+ (options.from === undefined || !this.#sameDriver(options.from, name)) &&
1732
+ !this.#isMaintenancePass(name, target, options.from);
1733
+ const allowStale = options.allowStaleSource === true;
1734
+ if (stale && !allowStale && options.dryRun !== true) {
1735
+ const how = options.from !== undefined ? `--from=${options.from}` : 'sin --from';
1736
+ // Cierre del auditor (⚪ 8): con UN solo driver registrado no hay descarte que
1737
+ // hacer; la letra no puede hablar de un origen que no existe.
1738
+ const others = Object.keys(this.#config.drivers ?? {}).filter((key) => !this.#sameDriver(key, name));
1739
+ const originLabel = options.from !== undefined
1740
+ ? `el origen '${options.from}' no lo es`
1741
+ : others.length === 0
1742
+ ? `no hay ningún otro driver registrado ni pasada de mantenimiento para él (no guarda sus hechos en su ` +
1743
+ `backend: no declara hierarchyFacts), así que no hay nada de dónde reconciliar hacia '${name}'`
1744
+ : `sin --from el origen se elegiría POR DESCARTE (las tablas authz_* u otro driver registrado: ` +
1745
+ `${others.join(', ')}), y no es '${name}'`;
1746
+ throw new StaleSourceRefusedError(`authz:reconcile --to=${name} ${how}: '${name}' es el driver ACTIVO (config.default` +
1747
+ `${name === this.#config.default ? '' : `: la clave resuelve la MISMA factory que '${this.#config.default}'`}) y ${originLabel}. ` +
1748
+ `Esa dirección re-escribiría en el driver activo hechos que el origen ya no refleja: resucitaría las ` +
1749
+ `revocaciones hechas desde el cutover (y con --prune borraría lo concedido después). No se ha leído, ` +
1750
+ `congelado ni escrito nada. Hacia el driver activo el único origen es él mismo: la pasada de mantenimiento ` +
1751
+ `(authz:reconcile --to=${name} [--dry-run], sin --from) si su driver guarda sus hechos; y si lo que quieres ` +
1752
+ `es volver atrás, migra en la otra dirección ANTES de cambiar config.default. Si de verdad el origen sigue ` +
1753
+ `siendo la fuente de verdad (el motor aún no ha escrito desde '${name}'), la decisión humana es ` +
1754
+ `--allow-stale-source (allowStaleSource: true).`);
1755
+ }
1756
+ // **De dónde salen los HECHOS** (3b-5): la decisión que faltaba, y la que
1757
+ // el destino no puede tomar por su cuenta. Ver `#factsOrigin`.
1758
+ const origin = await this.#factsOrigin(name, target, options.from);
1759
+ const source = {
1760
+ enumerateEdges: this.#edgesEnumerator(),
1761
+ resolveChain: this.#freshResolver(),
1762
+ // Los hechos del ORIGEN, **perezosos** (3b-3b): la dirección que lee
1763
+ // `authz_*` no construye ningún driver de más. Y el origen se resuelve
1764
+ // UNA vez.
1765
+ facts: origin.enumerate,
1766
+ factsOrigin: { name: origin.name, authzTables: origin.authzTables },
1767
+ // El techo de profundidad del consumidor, si lo declaró (alpha.5 · R0):
1768
+ // el mismo mando que el gate de `scopes.*`.
1769
+ ...(this.#config.scopes?.maxDepth === undefined ? {} : { maxDepth: this.#config.scopes.maxDepth }),
1770
+ // **Los tipos del OTRO subsistema del mismo backend** (alpha.9 · B · S4),
1771
+ // si el consumidor declaró relaciones. Es SOLO para el reporte: parte lo
1772
+ // ajeno en «declarado» y «no lo reclama nadie». La decisión de BORRAR no
1773
+ // lo mira —la toma la lista blanca congelada del destino—, porque el
1774
+ // proceso que corre un cutover puede no tener esta config y aun así
1775
+ // tiene que respetar las tuplas de quien sí la tiene.
1776
+ ...(this.#config.relations?.config === undefined
1777
+ ? {}
1778
+ : { relationTypes: this.#config.relations.config.objectTypes.map((type) => type.type) }),
1779
+ };
1780
+ const pass = async () => {
1781
+ const report = await target.reconcile(source, options);
1782
+ const { pending, dead } = await this.#relayWindow();
1783
+ report.drift.pendingRelay = pending;
1784
+ report.drift.deadRelay = dead;
1785
+ // Quién fue el origen se DICE, siempre: es la diferencia entre una
1786
+ // migración y una pasada de mantenimiento contra el driver activo.
1787
+ report.factsFrom = origin.resolved();
1788
+ if (stale)
1789
+ report.staleSource = { to: name, from: origin.resolved(), allowed: allowStale };
1790
+ return report;
1791
+ };
1792
+ // La pasada que escribe congela; el verificador NO (ver el docblock).
1793
+ if (options.dryRun === true)
1794
+ return pass();
1795
+ // El freeze de la pasada es DURABLE y con dueño (3b-7): si este manager
1796
+ // ya sostiene uno, la pasada corre DENTRO; si hay una ventana de
1797
+ // OPERADOR viva (`authz:freeze`, el cutover), la pasada la reconoce como
1798
+ // contexto propio —no la toma, no la renueva, no la levanta—; si el
1799
+ // freeze vivo es de otro `reconcile`, 423: dos pasadas no se pisan. Y el
1800
+ // reporte publica la garantía en vez de suponerla: `frozen.lapsed=true`
1801
+ // significa que el lease se perdió a mitad y la pasada NO se certifica
1802
+ // (el comando sale distinto de cero).
1803
+ const window = await this.#durableFreezeContext(`authz:reconcile --to=${name}`, 'reconcile', {
1804
+ operatorAsContext: true,
1805
+ });
1806
+ try {
1807
+ const report = await pass();
1808
+ report.frozen = {
1809
+ durable: true,
1810
+ lapsed: await window.lapsed(),
1811
+ leaseMs: window.leaseMs,
1812
+ fence: window.fence,
1813
+ ...(window.untilMs === undefined ? {} : { untilMs: window.untilMs }),
1814
+ ...(window.window === undefined ? {} : { window: window.window }),
1815
+ };
1816
+ return report;
1817
+ }
1818
+ finally {
1819
+ await window.release();
1820
+ }
1821
+ }
1822
+ /**
1823
+ * **Quién es la FUENTE DE VERDAD de los hechos de esta pasada** (3b-5, los
1824
+ * dos 🔴 del auditor final de la Fase 3b). Es la pregunta que
1825
+ * `authz:reconcile --to=openfga` no se hacía: leía `authz_assignments`/
1826
+ * `authz_denies` SIEMPRE, y en un despliegue `hierarchy: 'facts'` esas
1827
+ * tablas no son la fuente de verdad de los hechos —lo son las tuplas del
1828
+ * store—, así que la pasada resucitaba lo revocado después del cutover,
1829
+ * `--prune` borraba los denies vivos y el barrido de visibilidad del
1830
+ * invariante 18 no se aplicaba nunca (`forbidden` salía vacío porque
1831
+ * `wanted.facts` salía vacío).
1832
+ *
1833
+ * Las tres respuestas, en este orden:
1834
+ *
1835
+ * 1. **El destino es el driver ACTIVO y sus hechos son SUYOS**
1836
+ * (`to === config.default` y `capabilities.hierarchyFacts`): entonces
1837
+ * `authz_*` no puede ser su origen —el motor lleva desde el cutover
1838
+ * escribiendo los hechos en el destino— y la pasada es de
1839
+ * MANTENIMIENTO: los hechos se leen del propio destino por el puerto
1840
+ * (`enumerateFacts`), se rehace lo DERIVADO (marcador, catálogo, árbol)
1841
+ * y se aplica el barrido de visibilidad del invariante 18 con el árbol
1842
+ * y el catálogo de HOY. No se inventa ningún hecho; con `--prune` se
1843
+ * borran los que ya no respaldan nada (scopes que ya no resuelven,
1844
+ * caducados, roles/permisos retirados, y los que sobran). Un
1845
+ * destino activo con `hierarchyFacts` que no sepa enumerar sus hechos
1846
+ * es 500 `E_AUTHZ_UNSUPPORTED`: leerle `authz_*` sería justo el defecto.
1847
+ * 2. **`--from=<nombre>` manda**, y por eso se resuelve YA: de la
1848
+ * naturaleza de ese driver depende de dónde salen los hechos (si sabe
1849
+ * `enumerateFacts`, del puerto; si no, es un driver cuyos hechos son
1850
+ * `authz_*` —el `database` del paquete— y los lee el destino).
1851
+ * 3. **Sin `--from` y sin ser el activo**: la MIGRACIÓN de siempre. Los
1852
+ * hechos son `authz_*`, el esquema PUBLICADO del paquete, y el destino
1853
+ * los lee él mismo; si el destino los pide por el puerto (`--to=database`)
1854
+ * el origen se resuelve entonces, perezosamente y con la regla ruidosa
1855
+ * de 3b-3b (`#factsEnumerator`).
1856
+ */
1857
+ async #factsOrigin(to, target, from) {
1858
+ if (this.#isMaintenancePass(to, target, from)) {
1859
+ if (typeof target.enumerateFacts !== 'function') {
1860
+ throw new UnsupportedOperationError('enumerateFacts', `authz:reconcile --to=${to}`, to, `El motor SIRVE desde '${to}' y ese driver declara que el árbol y los hechos viven en su backend ` +
1861
+ `(hierarchyFacts), así que 'authz_assignments'/'authz_denies' NO son la fuente de verdad de sus ` +
1862
+ `hechos: reconstruirlo desde ellas reescribiría lo que hayas revocado desde el cutover. Para poder ` +
1863
+ `verificarlo y repararlo hace falta que sepa entregar sus hechos (enumerateFacts).`);
1864
+ }
1865
+ return {
1866
+ name: to,
1867
+ authzTables: false,
1868
+ enumerate: (page) => target.enumerateFacts(page),
1869
+ resolved: () => to,
1870
+ };
1871
+ }
1872
+ if (from !== undefined) {
1873
+ const driver = await this.#originDriver(from, to);
1874
+ return {
1875
+ name: from,
1876
+ authzTables: typeof driver.enumerateFacts !== 'function',
1877
+ enumerate: async (page) => {
1878
+ if (typeof driver.enumerateFacts !== 'function') {
1879
+ throw new UnsupportedOperationError('enumerateFacts', `authz:reconcile --from=${from}`, from, `El driver '${from}' no sabe entregar sus hechos. El driver 'database' es ese caso a propósito: ` +
1880
+ `sus hechos son 'authz_assignments'/'authz_denies' y el destino los lee de ahí.`);
1881
+ }
1882
+ return driver.enumerateFacts(page);
1883
+ },
1884
+ resolved: () => from,
1885
+ };
1886
+ }
1887
+ let resolvedName = AUTHZ_TABLES_ORIGIN;
1888
+ const enumerate = await this.#factsEnumerator(to, (name) => {
1889
+ resolvedName = name;
1890
+ });
1891
+ return { name: AUTHZ_TABLES_ORIGIN, authzTables: true, enumerate, resolved: () => resolvedName };
1892
+ }
1893
+ /**
1894
+ * **¿Es la pasada de MANTENIMIENTO?** (3b-5; punto ÚNICO desde alpha.10): el
1895
+ * destino es el driver ACTIVO, sus hechos viven en su backend
1896
+ * (`hierarchyFacts`) y nadie nombró otro origen. Es la ÚNICA forma en la que
1897
+ * el origen resuelto de una pasada hacia el driver activo es el propio
1898
+ * destino, y la usan los dos que la necesitan: `#factsOrigin` (de dónde se
1899
+ * leen los hechos) y el guard de la dirección rancia de `reconcile` (si ese
1900
+ * origen es legítimo). Dos copias de esta condición eran dos respuestas.
1901
+ */
1902
+ #isMaintenancePass(to, target, from) {
1903
+ return from === undefined && this.#sameDriver(to, this.#config.default) && target.capabilities?.hierarchyFacts === true;
1904
+ }
1905
+ /**
1906
+ * **¿Dos claves de `drivers` son el MISMO driver?** (cierre del auditor de
1907
+ * alpha.10 · R4, gemelo de roles): misma clave o la MISMA factory registrada
1908
+ * bajo dos claves. El guard de la dirección rancia, el mantenimiento, el
1909
+ * `from === to` y el descarte juzgan la identidad RESUELTA, no la etiqueta:
1910
+ * con `drivers: { openfga: f, fga: f }` y `default: 'openfga'`, `--to=fga
1911
+ * --from=database` es la misma dirección rancia que `--to=openfga`.
1912
+ *
1913
+ * **Límite DECLARADO (cierre-2 · C1)**: la identidad es la REFERENCIA de la
1914
+ * factory, no el backend. Dos factories distintas que construyen el mismo
1915
+ * store (`openfga: () => new OpenFga…(…)`, `fga: () => new OpenFga…(…)`) NO son
1916
+ * el mismo driver para este predicado y el alias pasa el guard. Identidad de
1917
+ * backend declarada por el driver ⇒ roadmap 2.5.
1918
+ */
1919
+ #sameDriver(a, b) {
1920
+ if (a === b)
1921
+ return true;
1922
+ const drivers = this.#config.drivers ?? {};
1923
+ const own = (key) => (Object.prototype.hasOwnProperty.call(drivers, key) ? drivers[key] : undefined);
1924
+ const fa = own(a);
1925
+ return fa !== undefined && fa === own(b);
1926
+ }
1927
+ /** El driver que `--from` nombra, con los dos errores de 3b-3b intactos. */
1928
+ async #originDriver(from, to) {
1929
+ const registered = Object.keys(this.#config.drivers ?? {});
1930
+ if (this.#sameDriver(from, to)) {
1931
+ throw new AuthorizationConfigError(`authz:reconcile --from=${from} --to=${to}: el origen y el destino son el mismo driver. ` +
1932
+ `Si lo que quieres es VERIFICAR y reparar lo derivado del driver activo, no lo digas con --from: ` +
1933
+ `la pasada ya lee sus hechos de él cuando es el driver por defecto.`);
1934
+ }
1935
+ const factory = this.#config.drivers?.[from];
1936
+ if (!factory) {
1937
+ throw new AuthorizationConfigError(`authz:reconcile --from=${from}: ese driver no está registrado en config/authorization.ts ` +
1938
+ `(registrados: ${registered.join(', ') || 'ninguno'}).`);
1939
+ }
1940
+ return factory();
1941
+ }
1942
+ /**
1943
+ * **Quién es el ORIGEN de `authz:reconcile --to=<destino>`** (3b-3b), y su
1944
+ * enumerador de hechos — perezoso: se resuelve la PRIMERA vez que el
1945
+ * destino lo pide, así que la dirección que lee `authz_*` (`--to=openfga`)
1946
+ * no construye ningún driver de más.
1947
+ *
1948
+ * La regla es determinista y RUIDOSA, nunca «el que haya» (`--from` lo
1949
+ * resuelve antes `#factsOrigin`, 3b-5):
1950
+ * - se busca entre los drivers registrados distintos del
1951
+ * destino los que sepan ser origen (`capabilities.enumerateFacts` o el
1952
+ * método): **exactamente uno** ⇒ ése; **ninguno** ⇒ 500
1953
+ * `E_AUTHZ_UNSUPPORTED` nombrando `enumerateFacts`; **más de uno** ⇒ 500
1954
+ * pidiendo `--from`, porque elegir por ti es elegir de dónde sale lo que
1955
+ * va a quedar escrito.
1956
+ *
1957
+ * Nunca «cero hechos» en silencio: un origen que no responde y un `--prune`
1958
+ * detrás vacían el destino, y eso no puede depender de adivinar.
1959
+ */
1960
+ async #factsEnumerator(to, onResolved) {
1961
+ let resolved = null;
1962
+ const build = async () => {
1963
+ const registered = Object.keys(this.#config.drivers ?? {});
1964
+ const candidates = [];
1965
+ for (const candidate of registered) {
1966
+ if (this.#sameDriver(candidate, to))
1967
+ continue;
1968
+ const driver = await this.#config.drivers[candidate]();
1969
+ if (typeof driver.enumerateFacts === 'function')
1970
+ candidates.push({ name: candidate, driver });
1971
+ }
1972
+ if (candidates.length === 1) {
1973
+ onResolved?.(candidates[0].name);
1974
+ return candidates[0].driver;
1975
+ }
1976
+ if (candidates.length === 0) {
1977
+ throw new UnsupportedOperationError('enumerateFacts', `authz:reconcile --to=${to}`, to, `Ningún driver registrado (${registered.join(', ') || 'ninguno'}) sabe ser el ORIGEN de esta ` +
1978
+ `migración. Sin hechos que leer, la pasada escribiría cero y con --prune vaciaría el destino.`);
1979
+ }
1980
+ throw new AuthorizationConfigError(`authz:reconcile --to=${to}: hay más de un origen posible ` +
1981
+ `(${candidates.map((c) => c.name).join(', ')}). Dilo con --from=<driver>: de dónde salen los ` +
1982
+ `hechos decide lo que va a quedar escrito, y eso no se adivina.`);
1983
+ };
1984
+ return async (page) => {
1985
+ resolved ??= await build();
1986
+ return resolved.enumerateFacts(page);
1987
+ };
1988
+ }
1989
+ /**
1990
+ * `scopes.enumerateEdges` o 500: sin el árbol del consumidor no se puede
1991
+ * reconstruir el del backend, y suponerlo plano sería inventar una
1992
+ * jerarquía (y con ella una concesión).
1993
+ */
1994
+ #edgesEnumerator() {
1995
+ const enumerate = this.#config.scopes?.enumerateEdges;
1996
+ if (typeof enumerate !== 'function') {
1997
+ throw new AuthorizationConfigError("authz:reconcile necesita 'scopes.enumerateEdges' en config/authorization.ts: es el árbol ENTERO, " +
1998
+ 'paginado, y es lo que se migra (y lo que dice qué aristas del backend ya no respalda nadie). ' +
1999
+ 'sqlScopeEdges(...) lo implementa sobre una tabla con columna padre.');
2000
+ }
2001
+ return enumerate;
2002
+ }
2003
+ /**
2004
+ * **La ventana del relay, medida** (decisión del dueño del 2026-08-30,
2005
+ * consecuencia 4): cuántos cambios del árbol están encolados sin aplicar
2006
+ * —el backend decide con el árbol viejo mientras tanto— y cuántos están
2007
+ * APARCADOS, que ya no es una ventana sino una divergencia permanente.
2008
+ *
2009
+ * Se mide con las escrituras congeladas, así que la cola no crece durante
2010
+ * la cuenta. Sin outbox no hay ventana (el manager escribe en línea) y los
2011
+ * dos números son cero.
2012
+ */
2013
+ async #relayWindow() {
2014
+ const outbox = this.#outbox();
2015
+ if (!outbox)
2016
+ return { pending: 0, dead: 0 };
2017
+ let pending = 0;
2018
+ let after;
2019
+ for (let page = 0; page < RELAY_WINDOW_MAX_PAGES; page++) {
2020
+ const batch = await outbox.pending(DEFAULT_RELAY_BATCH, after);
2021
+ pending += batch.length;
2022
+ if (batch.length < DEFAULT_RELAY_BATCH)
2023
+ break;
2024
+ after = batch[batch.length - 1].id;
2025
+ }
2026
+ const dead = typeof outbox.dead === 'function' ? (await outbox.dead(DEFAULT_RELAY_BATCH)).length : 0;
2027
+ return { pending, dead };
2028
+ }
2029
+ /**
2030
+ * Los scopes que un cambio del árbol NOMBRA: son las claves con las que se
2031
+ * decide si otro cambio depende de él (3b-2h · 🔴 2). Dos cambios que no
2032
+ * comparten ninguna no pueden interactuar en el árbol —toda dependencia
2033
+ * (recolgar, cerrar un ciclo, purgar) viaja por un nodo nombrado—, así que
2034
+ * el orden RELATIVO que hay que conservar es exactamente este.
2035
+ *
2036
+ * **La raíz `app` NO es clave** (2.4.0-alpha.8; panel relay 2, cuestión 1;
2037
+ * decisión del dueño 2026-09-10 (4)). La raíz no se mueve, no se purga y
2038
+ * nunca es hijo (los tres 422 de `#assertEdge`/`scopes.detached`), así que
2039
+ * dos cambios cuyo ÚNICO cruce sea `app` conmutan: ningún orden que
2040
+ * conservar (el auditor lo buscó y no lo encontró; `m2-orden.ts`). Con
2041
+ * `[hijo, app]` toda alta de tenant compartía clave con todas las demás:
2042
+ * una aparcada bajo la raíz congelaba el registro entero (5 pasadas con
2043
+ * `applied = []`, medido en un consumidor), dos altas concurrentes con la
2044
+ * receta inline dejaban la segunda `deferred` SIN nada aparcado, y
2045
+ * `requeue` se negaba con un conflicto falso. Se decide por REF
2046
+ * (`#isRootRef`), nunca por la cadena `'app'`, y en los TRES sitios que
2047
+ * construyen la lista: aquí, `#dependencyOf` y `requeueScopeChanges` (los
2048
+ * dos por `#changeScopes`); también para el bloqueo por FALLO (una sola
2049
+ * regla). La lista nunca queda vacía: el hijo siempre va.
2050
+ */
2051
+ static #changeKeys(change) {
2052
+ return _a.#changeScopes(change).map((scope) => scopeKey(scope));
2053
+ }
2054
+ /**
2055
+ * Los scopes de `#changeKeys` como REFERENCIAS: la lista que va al puerto
2056
+ * (`earlierPending`/`laterApplied`). Es la misma decisión que
2057
+ * `#changeKeys` y vive en un solo sitio a propósito (auditor 🟠 3 del panel
2058
+ * relay 2: exentar la raíz solo en las claves dejaba el mutex vivo bajo
2059
+ * `only`, porque el SQL de la sonda sí casa `parent_type = 'app'`).
2060
+ */
2061
+ static #changeScopes(change) {
2062
+ if (change.op === 'detached')
2063
+ return [change.child];
2064
+ if (_a.#isRootRef(change.parent))
2065
+ return [change.child];
2066
+ return [change.child, change.parent];
2067
+ }
2068
+ /** ¿Es la raíz? Por REF (`type === 'app' && uuid === null`), jamás comparando la cadena `'app'`. */
2069
+ static #isRootRef(scope) {
2070
+ return scope.type === APP_SCOPE_TYPE && (scope.uuid ?? null) === null;
2071
+ }
2072
+ /** Lo aparcado por la outbox (si sabe aparcar), listo para el reporte. */
2073
+ static async #deadLetters(outbox, limit) {
2074
+ if (typeof outbox.dead !== 'function')
2075
+ return [];
2076
+ const rows = await outbox.dead(limit);
2077
+ return rows.map((item) => ({
2078
+ id: item.id,
2079
+ change: item.change,
2080
+ attempts: item.attempts,
2081
+ ...(item.lastError === undefined ? {} : { error: item.lastError }),
2082
+ }));
2083
+ }
2084
+ /**
2085
+ * Aplica UN cambio del árbol al driver. Es el mismo camino que
2086
+ * `scopes.*` sin outbox, incluido el orden de `detached`: **hechos primero
2087
+ * —el driver demuestra cero o lanza—, arista al final** (S6). Al revés,
2088
+ * una purga muerta a medias dejaría grants vivos en un scope sin ancestro,
2089
+ * los denies heredados dejarían de aplicar y esos permisos serían
2090
+ * INDENEGABLES (invariante 2).
2091
+ */
2092
+ async #applyScopeChange(driver, item) {
2093
+ const change = item.change;
2094
+ if (change.op === 'attached') {
2095
+ await driver.onScopeAttached?.(change.child, change.parent);
2096
+ return;
2097
+ }
2098
+ if (change.op === 'moved') {
2099
+ await driver.onScopeMoved?.(change.child, change.parent);
2100
+ return;
2101
+ }
2102
+ // La auditoría no pierde al autor por haber pasado por una cola.
2103
+ const event = {
2104
+ action: 'scope_purged',
2105
+ scope: change.child,
2106
+ ...(item.actor ? { actor: item.actor } : {}),
2107
+ };
2108
+ await this.#write(event, () => driver.purgeScope(change.child));
2109
+ await driver.onScopeDetached?.(change.child);
2110
+ await this.#notify(event);
2111
+ }
2112
+ /** Cota entera positiva de las opciones del relay (500 si llega otra cosa). */
2113
+ /**
2114
+ * **Una opción desconocida es 500, nunca silencio** (2.4.0-alpha.8 · X-1;
2115
+ * tester del panel relay 2, §10-d). Medido en alpha.7: `requeueScopeChanges(ids,
2116
+ * { apply: true })` y `relayScopeChanges({ waitForLeaseMs })` se IGNORABAN
2117
+ * —el objeto de opciones no se validaba— y un consumidor en JS (o con
2118
+ * `as any`) creía que aplicaba o esperaba cuando no; el síntoma era un
2119
+ * `busy` o un `deferred` achacado a otra cosa. Se comprueban las CLAVES
2120
+ * (un valor `undefined` en una clave conocida vale: es lo que pasan los
2121
+ * comandos ace), y el objeto tiene que serlo.
2122
+ */
2123
+ static #assertKnownOptions(operation, options, known) {
2124
+ if (options === undefined)
2125
+ return;
2126
+ if (options === null || typeof options !== 'object' || Array.isArray(options)) {
2127
+ throw new AuthorizationConfigError(`${operation}: las opciones deben ser un objeto { ${known.join(', ')} }; llegó ${Array.isArray(options) ? 'una lista' : String(options)}.`);
2128
+ }
2129
+ const unknown = Object.keys(options).filter((key) => !known.includes(key));
2130
+ if (unknown.length > 0) {
2131
+ throw new AuthorizationConfigError(`${operation}: opción(es) desconocida(s): ${unknown.join(', ')}. Las conocidas son ${known.join(', ')}. ` +
2132
+ 'Una opción que no existe no se ignora: en JS creerías que aplica (o espera) cuando no. No se ha hecho nada.');
2133
+ }
2134
+ }
2135
+ static #positive(value, fallback, name) {
2136
+ if (value === undefined)
2137
+ return fallback;
2138
+ if (!Number.isInteger(value) || value < 1) {
2139
+ throw new AuthorizationConfigError(`authz:scopes:relay: ${name} debe ser un entero >= 1 (llegó ${String(value)})`);
2140
+ }
2141
+ return value;
408
2142
  }
409
2143
  /**
410
2144
  * Valida las opciones comunes de una escritura (B7) ANTES de identidad,
@@ -412,7 +2146,11 @@ export class AuthorizationManager {
412
2146
  * `requireActor`. Devuelve `{ actor }` listo para fundir en el evento (o
413
2147
  * `{}` si no hay actor: el evento no inventa autores).
414
2148
  */
415
- #writeOptions(options, operation) {
2149
+ async #writeOptions(options, operation) {
2150
+ // Sin cast sobre `transaction` (L-1 · 🟠 8): la barrera no lee nada del
2151
+ // llamante, y `transaction` solo lo consumen los tipos que lo declaran
2152
+ // (`ScopeTreeWriteOptions`, para ENCOLAR — nunca para decidir).
2153
+ await this.#assertNotFrozen(operation);
416
2154
  if (options?.actor !== undefined)
417
2155
  assertSubject(options.actor);
418
2156
  if (this.#config.requireActor === true && !options?.actor) {
@@ -535,6 +2273,26 @@ export class AuthorizationManager {
535
2273
  }
536
2274
  return within;
537
2275
  }
2276
+ /**
2277
+ * La outbox del árbol, si el consumidor la declaró (3b-2d). Con ella,
2278
+ * `scopes.attached/moved/detached` NO tocan el driver: encolan el cambio
2279
+ * en la transacción del consumidor y lo aplica `authz:scopes:relay`.
2280
+ *
2281
+ * Por qué no es una recomendación sino un mecanismo (panel 2, cruce 4 ·
2282
+ * S5): sin outbox, el paquete escribe en el backend DENTRO de la
2283
+ * transacción del consumidor y un `rollback` posterior no lo deshace. El
2284
+ * árbol del backend queda adelantado al de la base del consumidor y en
2285
+ * modo `facts` eso es una escalada persistente e invisible —el backend es
2286
+ * el PDP, y la aplicación lista y audita contra su propia base—.
2287
+ *
2288
+ * Lo que la outbox NO arregla: el lag del relay. Durante esos segundos el
2289
+ * backend decide con el árbol VIEJO, y eso es un **fail-open temporal**
2290
+ * (el tenant antiguo conserva acceso tras un `moved`; los denies heredados
2291
+ * no aplican tras un `attached`). No hay 2PC.
2292
+ */
2293
+ #outbox() {
2294
+ return this.#config.scopes?.outbox;
2295
+ }
538
2296
  #resolver(operation) {
539
2297
  const resolver = this.#config.scopes?.resolveChain;
540
2298
  if (!resolver) {
@@ -543,7 +2301,13 @@ export class AuthorizationManager {
543
2301
  }
544
2302
  return resolver;
545
2303
  }
546
- /** Valida la arista y devuelve la cadena (fresca) del padre. */
2304
+ /**
2305
+ * Valida la arista y devuelve la cadena (fresca) del padre y el HIJO
2306
+ * CANÓNICO (invariante 17). El hijo canónico se devuelve desde 3b-2d
2307
+ * porque la outbox lo encola: lo que se guarda en la cola es la fila del
2308
+ * árbol, no lo que escribió el llamante — si no, el relay abriría días
2309
+ * después una rama nueva en el store por un alias del uuid.
2310
+ */
547
2311
  async #assertEdge(child, parent, operation) {
548
2312
  const resolver = this.#resolver(operation);
549
2313
  assertScope(child);
@@ -556,12 +2320,41 @@ export class AuthorizationManager {
556
2320
  // El hijo, si el árbol ya lo conoce, con su identidad canónica (K1): un
557
2321
  // alias del uuid no puede colarse por debajo de la comprobación de ciclo.
558
2322
  const known = await resolveChain(resolver, child, operation);
559
- const childKey = _a.#scopeKey(known ? known[0] : child);
2323
+ const canonicalChild = known ? known[0] : child;
2324
+ const childKey = _a.#scopeKey(canonicalChild);
560
2325
  if (chain.some((s) => _a.#scopeKey(s) === childKey)) {
561
2326
  throw new ScopeCycleError(`${operation}: ${parent.type}:${parent.uuid} desciende de ${childKey.replace('\u001f', ':')} (o es él mismo); ` +
562
2327
  `colgarlo cerraría un ciclo y la herencia dejaría de ser solo hacia abajo.`);
563
2328
  }
564
- return chain;
2329
+ this.#assertDepth(chain, canonicalChild, parent, operation);
2330
+ return { chain, child: canonicalChild };
2331
+ }
2332
+ /**
2333
+ * **El gate de profundidad, opt-in** (2.4.0-alpha.5 · R0; `scopes.maxDepth`).
2334
+ * La cadena del padre incluye `app`, así que su longitud ES la profundidad
2335
+ * a la que quedaría el hijo (saltos `parent` hasta la raíz): cero I/O de
2336
+ * más. Se juzga ANTES de escribir o encolar. PARCIAL a propósito: no ve la
2337
+ * altura del subárbol que un `moved` arrastra (ver `define_config.ts`).
2338
+ */
2339
+ #assertDepth(chain, child, parent, operation) {
2340
+ const maxDepth = this.#config.scopes?.maxDepth;
2341
+ if (maxDepth === undefined)
2342
+ return;
2343
+ if (typeof maxDepth !== 'number' || !Number.isInteger(maxDepth) || maxDepth < 1) {
2344
+ throw new AuthorizationConfigError(`${operation}: scopes.maxDepth debe ser un entero >= 1 (llegó ${String(maxDepth)}); quítalo si no quieres techo.`);
2345
+ }
2346
+ if (chain.length > maxDepth) {
2347
+ // El `message` es para el TENANT (2.4.0-alpha.8, cuestión 7.2): sin el
2348
+ // nombre del backend ni sus constantes; eso va en `details`, para el log.
2349
+ throw new ScopeDepthLimitError(`${operation}: colgar ${child.type}:${child.uuid} de ${parent.type}:${parent.uuid ?? ''} lo dejaría a ${chain.length} ` +
2350
+ `saltos de la raíz y el techo es scopes.maxDepth=${maxDepth}. No se ha escrito ni encolado nada. Aplana el árbol ` +
2351
+ `(o pide al operador que suba el techo).`, {
2352
+ depth: chain.length,
2353
+ maxDepth,
2354
+ why: 'el modelo facts del driver openfga no resuelve de forma fiable más allá de FACTS_MAX_RESOLVE_DEPTH: pasado él, authorize es 503 para todo ese subárbol',
2355
+ server: 'para subir el techo hay que subir también OPENFGA_RESOLVE_NODE_LIMIT en el servidor',
2356
+ });
2357
+ }
565
2358
  }
566
2359
  // La identidad se valida AQUÍ, antes de resolver siquiera el driver: una
567
2360
  // pregunta mal formada (uuid ausente, `{app, uuid}`, slug con `~`) es 422
@@ -816,7 +2609,8 @@ export class AuthorizationManager {
816
2609
  * Devuelve el rol y notifica `role_defined`.
817
2610
  */
818
2611
  async defineScopedRole(actor, ownerScope, spec, options) {
819
- const who = this.#requireActor(actor, 'defineScopedRole');
2612
+ this.#assertNoTransaction(options, 'defineScopedRole');
2613
+ const who = await this.#requireActor(actor, 'defineScopedRole');
820
2614
  this.#assertOwnerScope(ownerScope, 'defineScopedRole');
821
2615
  const parsed = this.#parseScopedRoleSpec(spec);
822
2616
  const driver = await this.driver();
@@ -890,6 +2684,11 @@ export class AuthorizationManager {
890
2684
  }
891
2685
  });
892
2686
  const role = Object.freeze({ uuid, slug: parsed.slug, scopeType: parsed.scopeType, owner: ownerKey, rank: parsed.rank });
2687
+ // La proyección derivada del driver, si la tiene (3b-2e · E4): en el modo
2688
+ // `facts` lo que un rol concede son TUPLAS, así que un rol definido sin
2689
+ // proyectar no concedería nada — un no-op silencioso. Va después del
2690
+ // commit del catálogo y antes de notificar.
2691
+ await driver.projectCatalogRole?.(uuid);
893
2692
  await this.#notifyCatalog({
894
2693
  action: 'role_defined',
895
2694
  actor: who,
@@ -910,7 +2709,8 @@ export class AuthorizationManager {
910
2709
  * escribe ni notifica (idempotente). Notifica `role_updated`.
911
2710
  */
912
2711
  async updateScopedRole(actor, roleUuid, changes, options) {
913
- const who = this.#requireActor(actor, 'updateScopedRole');
2712
+ this.#assertNoTransaction(options, 'updateScopedRole');
2713
+ const who = await this.#requireActor(actor, 'updateScopedRole');
914
2714
  assertCatalogUuid('rol', roleUuid);
915
2715
  const parsed = this.#parseScopedRoleChanges(changes);
916
2716
  const driver = await this.driver();
@@ -985,6 +2785,12 @@ export class AuthorizationManager {
985
2785
  return touched;
986
2786
  }, { skipIfNoop: true });
987
2787
  const updated = Object.freeze({ ...role, rank: next.rank });
2788
+ // 3b-2e · E4: quitarle un permiso a un rol tiene que dejar de conceder
2789
+ // también en el driver que proyecta el catálogo como tuplas. Sin esto la
2790
+ // tupla `permits_<P>` sobrevive al vínculo y el rol sigue concediendo lo
2791
+ // que ya no vincula: fail-open.
2792
+ if (changed && permissionsChanged)
2793
+ await driver.projectCatalogRole?.(role.uuid);
988
2794
  if (changed)
989
2795
  await this.#notifyCatalog({ action: 'role_updated', actor: who, role: updated, owner, permissions: nextPermissions });
990
2796
  return updated;
@@ -997,7 +2803,8 @@ export class AuthorizationManager {
997
2803
  * su owner. Notifica `role_purged`. No necesita `listDenies`.
998
2804
  */
999
2805
  async deleteScopedRole(actor, roleUuid, options) {
1000
- const who = this.#requireActor(actor, 'deleteScopedRole');
2806
+ this.#assertNoTransaction(options, 'deleteScopedRole');
2807
+ const who = await this.#requireActor(actor, 'deleteScopedRole');
1001
2808
  assertCatalogUuid('rol', roleUuid);
1002
2809
  const driver = await this.driver();
1003
2810
  const purgeRole = this.#optional(driver, 'purgeRole', 'deleteScopedRole');
@@ -1017,8 +2824,194 @@ export class AuthorizationManager {
1017
2824
  }
1018
2825
  await this.#notifyCatalog({ action: 'role_purged', actor: who, role, owner, permissions });
1019
2826
  }
2827
+ /**
2828
+ * Los roles LOCALES cuyo owner el árbol YA NO conoce, y —con `force`— su
2829
+ * purga. Es el motor de `authz:catalog:prune-orphans` (3b-0 · Z2).
2830
+ *
2831
+ * Un rol así está DORMIDO, y «dormido» significa **exactamente** esto
2832
+ * (3b-0b · AA1, auditor 3b-0): no es visible desde ningún scope vivo cuya
2833
+ * cadena NO pase por su owner. No significa que no conceda. La regla única
2834
+ * de visibilidad (invariante 18) pide que el owner esté en la cadena del
2835
+ * scope preguntado, y **un descendiente vivo cuya ruta materializada sigue
2836
+ * pasando por el owner la cumple**: ahí el rol concede, es membresía por
2837
+ * los seis caminos de lectura y se puede ASIGNAR, por slug y por uuid.
2838
+ * Ocurre en cuanto el consumidor borra la fila del owner sin borrar (o sin
2839
+ * notificar) la de sus descendientes — el borrado en dos pasos y las rutas
2840
+ * materializadas son lo normal. Por eso este barrido es destructivo de
2841
+ * verdad y por eso `--dry-run` es el default: puede estar revocando
2842
+ * permisos VIVOS, no recogiendo basura inerte. Lo que sí es seguro decir:
2843
+ * un rol huérfano SIN asignaciones vigentes no concede nada, y ninguno
2844
+ * concede en un scope cuya cadena no pase por el owner.
2845
+ *
2846
+ * Cada huérfano viene con `assignments` (hechos vigentes) y
2847
+ * `stillGranting` (`assignments > 0`), que es la marca CONSERVADORA de
2848
+ * «esto no es basura inerte»: cuenta hechos, no comprueba si el scope de
2849
+ * cada uno sigue resolviendo. Falso ⇒ no concede seguro; verdadero ⇒
2850
+ * míralo antes de `--force`.
2851
+ *
2852
+ * **Y esos hechos se los cuenta el DRIVER** (3b-2j, decisión del dueño del
2853
+ * 2026-08-31 (3)), con `countRoleAssignments` del puerto. Hasta aquí los
2854
+ * contaba el propio barrido en `authz_assignments` —la tabla del driver
2855
+ * `database`—, así que con `openfga` en modo `facts`, donde los hechos
2856
+ * viven en el store, `stillGranting` era SIEMPRE `false`: el barrido
2857
+ * declaraba basura inerte, justo antes de un borrado destructivo, un rol
2858
+ * que estaba concediendo (medido en el lote 2i). Un driver que no traiga
2859
+ * el método deja los DOS campos en **`undefined`**, nunca en `false`: «no
2860
+ * lo sé» no puede degradar a «no concede», que es exactamente el bug. Con
2861
+ * `undefined` el rol no es demostrablemente inerte y el comando lo lista
2862
+ * APARTE, igual que a los que sí conceden.
2863
+ *
2864
+ * Lo que el rol dormido sí hace en todo caso es ocupar su `(slug, nivel)`
2865
+ * dentro del subárbol donde todavía se le vea, y `deleteScopedRole` no lo
2866
+ * alcanza (resuelve el owner en fresco y responde 422
2867
+ * `E_AUTHZ_UNKNOWN_SCOPE`). Hasta 3G esa limpieza la arrastraba
2868
+ * `scopes.detached`, y ahí es donde nacieron tres de las cuatro
2869
+ * regresiones de la Fase 3: la operación la dispara un TENANT, sobre un
2870
+ * scope que ya no resuelve, así que hubo que inventarle una policy de
2871
+ * rango sin cadena donde medirla, una enumeración del subárbol y una
2872
+ * degradación — tres piezas que compuestas destruían roles de
2873
+ * descendientes VIVOS. Aquí no hay nada de eso: es una operación de
2874
+ * PLATAFORMA (una tarea de mantenimiento con acceso al catálogo, como
2875
+ * `authz:catalog:sync`), no lleva actor y no mide rangos, exactamente como
2876
+ * el `purgeRole` de último recurso que el README ya prometía. Es, junto a
2877
+ * `driver()`, **API de plataforma**: se salta `requireActor` y
2878
+ * `requireWithin` a propósito, así que no se expone a un controlador.
2879
+ *
2880
+ * `force: false` (el default, y el del comando: `--dry-run`) NO escribe:
2881
+ * devuelve la lista para que un humano la mire. Con `force: true` cada rol
2882
+ * se purga con `purgeRole` —atómico: asignaciones + vínculos + fila +
2883
+ * versión del catálogo— y se notifica `role_purged` (sin `actor`). El
2884
+ * conjunto no es atómico, y por eso el reporte dice QUÉ se purgó
2885
+ * (`purged: CatalogRoleRef[]`, 3b-0b · AB3) y no cuántos: si un
2886
+ * `purgeRole` falla a mitad, lo anterior ya está borrado —con el hallazgo
2887
+ * de AA1 eso puede ser revocación parcial de permisos vivos— y quien
2888
+ * recoge el 503 necesita la lista, no un contador. Una pasada
2889
+ * interrumpida la recoge la siguiente (el orden es estable por uuid).
2890
+ *
2891
+ * **Dos seguros contra el barrido a ciegas**, que es el riesgo real
2892
+ * (auditor 3b-0):
2893
+ *
2894
+ * - **Cota de purga masiva** (AA2): si TODOS los owners distintos
2895
+ * resultan huérfanos, o si los huérfanos superan el 50 % de los roles
2896
+ * locales, `force` es 500 `E_AUTHZ_MASS_PURGE_REFUSED` **antes de
2897
+ * borrar nada**. Esa es la firma de un `resolveChain` filtrado por el
2898
+ * tenant de la petición o corriendo sin contexto (comando, réplica
2899
+ * atrasada): devuelve `null` para todo y la pasada se lleva el catálogo
2900
+ * local de TODOS los tenants (medido: 2 de 2 roles vivos). Una poda
2901
+ * grande de verdad pasa con `allowMassPurge: true`
2902
+ * (`--allow-mass-purge`), que es una decisión humana. El `--dry-run` no
2903
+ * lanza —es justo el diagnóstico que hay que poder mirar— pero lo
2904
+ * marca en `massPurge`.
2905
+ * - **Re-resolución justo antes de cada purga** (AA3): entre la lectura y
2906
+ * el borrado cabe un `scopes.attached`/restore concurrente, y la
2907
+ * ventana es TODA la pasada (N roles + N `resolveChain`), no un
2908
+ * instante. Cada owner se vuelve a resolver en FRESCO inmediatamente
2909
+ * antes de su `purgeRole`; si ha vuelto, el rol se salta y se cuenta en
2910
+ * `skipped` con `reason: 'owner-came-back'`.
2911
+ *
2912
+ * Coste: una lectura del catálogo local + un `resolveChain` por OWNER
2913
+ * DISTINTO (memoizado) + UNA llamada a `countRoleAssignments` con los
2914
+ * uuids de los huérfanos (ninguna si no hay) + un `resolveChain` más por
2915
+ * rol purgado (el de AA3). Es O(owners con roles locales) para mirar y
2916
+ * O(roles purgados) para borrar, y corre en un comando, no en el camino de
2917
+ * una petición.
2918
+ */
2919
+ async pruneOrphanRoles(options = {}) {
2920
+ const force = options.force === true;
2921
+ this.#resolver('authz:catalog:prune-orphans');
2922
+ const driver = await this.driver();
2923
+ // Antes de leer nada: un driver que no sabe purgar lo dice, no se
2924
+ // descubre a mitad de la pasada (3E · P4). Y antes que la barrera del
2925
+ // freeze (3b-7): «no sé purgar» es permanente y se dice sin consultar
2926
+ // NADA (la promesa medida en 3b-1); «estás congelado» es transitorio.
2927
+ const purgeRole = this.#optional(driver, 'purgeRole', 'pruneOrphanRoles');
2928
+ if (force)
2929
+ await this.#assertNotFrozen('authz:catalog:prune-orphans');
2930
+ const resolver = this.#freshResolver();
2931
+ const locals = await readLocalRoles({ driver: this.#config.default });
2932
+ const resolved = new Map();
2933
+ const orphans = [];
2934
+ for (const { role, permissions } of locals) {
2935
+ const owner = this.#ownerOf(role);
2936
+ if (!resolved.has(role.owner)) {
2937
+ resolved.set(role.owner, (await resolveChain(resolver, owner, 'pruneOrphanRoles')) !== null);
2938
+ }
2939
+ if (resolved.get(role.owner))
2940
+ continue;
2941
+ orphans.push({ role, owner, permissions, assignments: undefined, stillGranting: undefined });
2942
+ }
2943
+ // Los hechos son del DRIVER, no de una tabla (3b-2j). Sin el método del
2944
+ // puerto los dos campos se quedan en `undefined`: el barrido no lo sabe y
2945
+ // lo dice, en vez de degradar a «no concede».
2946
+ if (orphans.length > 0 && typeof driver.countRoleAssignments === 'function') {
2947
+ const counts = await driver.countRoleAssignments(orphans.map(({ role }) => role.uuid));
2948
+ if (!Array.isArray(counts) || counts.length !== orphans.length) {
2949
+ throw new AuthorizationInternalError(`countRoleAssignments: el driver '${this.#config.default}' respondió ${Array.isArray(counts) ? counts.length : typeof counts} ` +
2950
+ `valor(es) para ${orphans.length} rol(es). La respuesta es POR POSICIÓN y esto se lee antes de borrar: no se ` +
2951
+ `adivina cuál era de quién.`);
2952
+ }
2953
+ counts.forEach((total, i) => {
2954
+ if (!Number.isInteger(total) || total < 0) {
2955
+ throw new AuthorizationInternalError(`countRoleAssignments: el driver '${this.#config.default}' respondió '${total}' para el rol ` +
2956
+ `'${orphans[i].role.slug}' (${orphans[i].role.uuid}); se espera un entero ≥ 0.`);
2957
+ }
2958
+ orphans[i].assignments = total;
2959
+ orphans[i].stillGranting = total > 0;
2960
+ });
2961
+ }
2962
+ const owners = new Set(locals.map(({ role }) => role.owner));
2963
+ const orphanOwners = new Set(orphans.map(({ role }) => role.owner));
2964
+ const massPurge = orphans.length > 0 && (orphanOwners.size === owners.size || orphans.length * 2 > locals.length);
2965
+ if (!force)
2966
+ return { orphans, purged: [], skipped: [], massPurge, dryRun: true };
2967
+ if (massPurge && options.allowMassPurge !== true) {
2968
+ throw new MassPurgeRefusedError(`pruneOrphanRoles: ${orphans.length} de ${locals.length} roles locales (${orphanOwners.size} de ${owners.size} ` +
2969
+ `owners distintos) tienen el owner fuera del árbol. Esa es la firma de un 'scopes.resolveChain' ciego —filtrado ` +
2970
+ `por el tenant de la petición, o sin contexto— que devuelve null para todo: una pasada así borra el catálogo ` +
2971
+ `local de todos los tenants. No se ha borrado nada. Comprueba el resolutor y, si la poda es real, repite con ` +
2972
+ `allowMassPurge: true (--allow-mass-purge).`);
2973
+ }
2974
+ const purged = [];
2975
+ const skipped = [];
2976
+ for (const { role, owner, permissions } of orphans) {
2977
+ // AA3: la ventana entre leer y borrar es toda la pasada. El owner se
2978
+ // vuelve a resolver EN FRESCO aquí mismo; si ha vuelto (un
2979
+ // `scopes.attached`, un restore, una réplica que se pone al día) este
2980
+ // rol ya no es huérfano y no se toca.
2981
+ if ((await resolveChain(this.#freshResolver(), owner, 'pruneOrphanRoles')) !== null) {
2982
+ skipped.push({ role, reason: 'owner-came-back' });
2983
+ continue;
2984
+ }
2985
+ try {
2986
+ // 3b-8 · B3 (mismo patrón que el relay): la ventana de la pasada es
2987
+ // larga (N roles × resolveChain) y la mirada única de la entrada
2988
+ // dejaba purgas destructivas DESPUÉS de un freeze adquirido a mitad.
2989
+ // Se re-afirma por rol, ANTES de cada borrado; el 503 sale envuelto
2990
+ // en PruneInterruptedError para que viaje la lista de lo YA purgado.
2991
+ await this.#assertNotFrozen('authz:catalog:prune-orphans');
2992
+ await purgeRole(role.uuid);
2993
+ }
2994
+ catch (error) {
2995
+ // La purga no es transaccional ENTRE roles: lo ya borrado está
2996
+ // borrado. El valor de retorno no llega a producirse, así que la
2997
+ // lista viaja en el error (tester 3b-1 §6.2) y el del driver va como
2998
+ // `cause`: la abstracción no filtra.
2999
+ throw new PruneInterruptedError(`pruneOrphanRoles: '${role.slug}' (nivel '${role.scopeType}') no se pudo purgar. ` +
3000
+ `Los ${purged.length} rol(es) anteriores YA están borrados y no se deshacen; el resto sigue vivo. ` +
3001
+ `La lista de lo purgado va en 'error.purged' y también en los eventos 'role_purged' ya emitidos; ` +
3002
+ `la siguiente pasada recoge lo que queda.`, purged, skipped, { cause: error });
3003
+ }
3004
+ finally {
3005
+ invalidateAuthzCatalog();
3006
+ }
3007
+ purged.push(role);
3008
+ await this.#notifyCatalog({ action: 'role_purged', role, owner, permissions });
3009
+ }
3010
+ return { orphans, purged, skipped, massPurge, dryRun: false };
3011
+ }
1020
3012
  /** El actor de la API de delegación: obligatorio SIEMPRE (sin él no hay policy que evaluar) y bien formado. */
1021
- #requireActor(actor, operation) {
3013
+ async #requireActor(actor, operation) {
3014
+ await this.#assertNotFrozen(operation);
1022
3015
  if (actor === undefined || actor === null) {
1023
3016
  throw new ActorRequiredError(`${operation}: el actor es obligatorio (es quien delega; sin él no hay policy que evaluar).`);
1024
3017
  }
@@ -1170,10 +3163,18 @@ export class AuthorizationManager {
1170
3163
  * es una función normal del producto. Es un control que el vigilado apaga.
1171
3164
  * Se acepta a sabiendas: la regla mínima no concede NADA (es la que corre
1172
3165
  * en todo consumidor con el stub publicado), y el daño residual —ocupar un
1173
- * `(slug, nivel)`— es reparable por AUTORIDAD + RANGO: un ancestro con
1174
- * rango por encima define el suyo y lo ensombrece (3F · S3 + 3G · W3), y
1175
- * la plataforma siempre puede `purgeRole`. Quien no acepte ese trato deja
1176
- * `maxDescendants` por encima de su subárbol mayor y vigila `truncated`.
3166
+ * `(slug, nivel)`— es reparable por AUTORIDAD + RANGO: un ancestro define
3167
+ * el suyo y lo ensombrece (3F · S3 + 3G · W3) **si supera en rango al
3168
+ * squatter** — `rank` es metadata del consumidor (invariante 8) y nada
3169
+ * obliga a que decrezca con la profundidad, así que con un reparto no
3170
+ * monótono (rank 60 en una unit bajo el org-admin rank 50 que es dueño de
3171
+ * ese árbol) el dueño se lleva 422 por las dos puertas y el recurso es la
3172
+ * PLATAFORMA (3b-1 · D1): el techo global acota todo rank local, y
3173
+ * `purgeRole` no mide rango. Quien no acepte ese trato deja
3174
+ * `maxDescendants` por encima de su subárbol mayor (3b-0b · AB1: la
3175
+ * degradación ya no se anuncia en ningún retorno —`truncated` se borró con
3176
+ * `ScopeDetachOutcome` en 3b-0 · Z1—, así que la cota es lo único que hay
3177
+ * que vigilar; `authz:catalog:diff --fail-on-shadows` es el gate de CI).
1177
3178
  *
1178
3179
  * (Un `scopeType` de nivel `app` muere antes, en `#parseScopedRoleSpec`:
1179
3180
  * la raíz no cuelga de ningún owner. Si llegara aquí sería un ancestro.)
@@ -1297,10 +3298,28 @@ export class AuthorizationManager {
1297
3298
  /**
1298
3299
  * Los homónimos LOCALES a un DESCENDIENTE del owner: los que una
1299
3300
  * definición en `ownerKey` ENSOMBRECE (3F · S3). Los owners se resuelven
1300
- * en fresco; uno que el árbol ya no conoce no ensombrece a nadie (no es
1301
- * visible en ninguna parte). Lo usan `defineScopedRole` (la colisión) y
1302
- * `updateScopedRole` (que no crea sombras nuevas, pero tampoco deja tocar
1303
- * un rol que ya ensombrece a otro de más rango — 3G · W3).
3301
+ * en fresco; uno que el árbol ya no conoce no ensombrece a nadie. Lo usan
3302
+ * `defineScopedRole` (la colisión) y `updateScopedRole` (que no crea
3303
+ * sombras nuevas, pero tampoco deja tocar un rol que ya ensombrece a otro
3304
+ * de más rango — 3G · W3).
3305
+ *
3306
+ * **La VENTANA, dicha** (3b-1 · D2, auditor 3G): `chain === null` es «no
3307
+ * demostrable», y aquí se trata como «no hay sombra». Mientras el árbol no
3308
+ * responda por el owner de la víctima —soft-delete, réplica atrasada, un
3309
+ * scope en «pending»: los mismos estados que el resto del paquete admite
3310
+ * como normales— un actor de rank bajo en un ancestro crea el homónimo sin
3311
+ * pasar por `#assertAboveShadowed`, y al volver el árbol la sombra es real
3312
+ * y permanente. **No se rechaza, a propósito**: desde 3b-0 · Z1 un rol cuyo
3313
+ * owner no resuelve está DORMIDO y la salida es `prune-orphans`, así que
3314
+ * rechazar aquí convertiría un rol dormido en un BLOQUEO de `(slug, nivel)`
3315
+ * —exactamente la mina que Z1 quitó— y lo haría por una condición que el
3316
+ * llamante no puede ni ver ni corregir. Lo que acota el daño: (a) el mismo
3317
+ * atacante consigue la misma denegación **yendo primero**, sin trampa
3318
+ * ninguna (W3 solo protege a los roles que YA existen; ocupar el nombre
3319
+ * antes siempre fue gratis); (b) nadie pierde permisos —`authorize` no
3320
+ * direcciona por slug— y la sombra sale en `authz:catalog:diff` como
3321
+ * `shadowedByAncestor`; (c) el dueño del árbol con rango la borra, y la
3322
+ * plataforma siempre (3b-1 · D1).
1304
3323
  */
1305
3324
  async #shadowedBelow(ownerKey, ancestors, others, operation) {
1306
3325
  const shadowed = [];
@@ -1318,7 +3337,13 @@ export class AuthorizationManager {
1318
3337
  }
1319
3338
  /**
1320
3339
  * Sobre un rol solo actúa quien lo SUPERA EN RANGO — también para
1321
- * ensombrecerlo (3G · W3, auditor P3′). Ensombrecer es tan destructivo
3340
+ * ensombrecerlo (3G · W3, auditor P3′). **Es una comprobación de
3341
+ * ESCRITURA, no un invariante** (3b-1 · D3): quién ensombrece a quién es
3342
+ * función del árbol de HOY y el árbol se mueve sin preguntar aquí
3343
+ * (`scopes.moved` crea sombras sin juzgar ningún rango), y el propio
3344
+ * chequeo tiene su ventana (`#shadowedBelow` con `chain === null`, D2) y
3345
+ * su límite honesto: solo protege a los roles que YA existen —ocupar el
3346
+ * nombre primero siempre fue gratis—. Ensombrecer es tan destructivo
1322
3347
  * como borrar: dentro del subárbol del ensombrecido toda ruta por slug
1323
3348
  * pasa a 422 `E_AUTHZ_AMBIGUOUS_ROLE` para TODOS, y la víctima no puede
1324
3349
  * repararlo (su rango se mide en la cadena del owner del rol que
@@ -1390,13 +3415,15 @@ export class AuthorizationManager {
1390
3415
  * `E_AUTHZ_AMBIGUOUS_ROLE`, nunca «el más cercano gana»).
1391
3416
  */
1392
3417
  async grant(subject, role, scope, options) {
1393
- const actor = this.#writeOptions(options, 'grant');
3418
+ await this.#assertTransactionalWrite(options, 'grant');
3419
+ const actor = await this.#writeOptions(options, 'grant');
1394
3420
  assertIdentity({ subject, role, scope, expiresAt: options?.expiresAt });
1395
3421
  await this.#assertWithin(scope, options, 'grant');
1396
3422
  // 3E · Q7: el evento lleva el rol RESUELTO (uuid + slug + nivel + owner),
1397
3423
  // no la pregunta cruda. Solo se resuelve si hay hook que lo vaya a leer.
1398
3424
  const roles = await this.#resolvedRoles(role, scope, 'grant');
1399
- const outcome = (await this.#write({ action: 'granted', subject, scope, roles, expiresAt: options?.expiresAt ?? null, ...actor }, async () => (await this.driver()).grant(subject, role, scope, options))) ??
3425
+ const transactional = this.#transactional(options);
3426
+ const outcome = (await this.#write({ action: 'granted', subject, scope, roles, expiresAt: options?.expiresAt ?? null, ...actor, ...transactional }, async () => (await this.driver()).grant(subject, role, scope, options))) ??
1400
3427
  // Un driver de terceros que aún devuelva `void`: la firma promete un
1401
3428
  // `GrantOutcome` y no miente (E1). Sin lectura previa no hay caducidad
1402
3429
  // anterior que contar: es lo que pidió el llamante, y `existed: false`.
@@ -1412,6 +3439,7 @@ export class AuthorizationManager {
1412
3439
  expiresAt: outcome.expiresAt,
1413
3440
  previousExpiresAt: outcome.previousExpiresAt,
1414
3441
  ...actor,
3442
+ ...transactional,
1415
3443
  });
1416
3444
  }
1417
3445
  else {
@@ -1422,6 +3450,7 @@ export class AuthorizationManager {
1422
3450
  roles,
1423
3451
  expiresAt: outcome.expiresAt,
1424
3452
  ...actor,
3453
+ ...transactional,
1425
3454
  });
1426
3455
  }
1427
3456
  return outcome;
@@ -1433,27 +3462,38 @@ export class AuthorizationManager {
1433
3462
  * rol.
1434
3463
  */
1435
3464
  async revoke(subject, role, scope, options) {
1436
- const actor = this.#writeOptions(options, 'revoke');
3465
+ await this.#assertTransactionalWrite(options, 'revoke');
3466
+ const actor = await this.#writeOptions(options, 'revoke');
1437
3467
  assertIdentity({ subject, role, scope });
1438
3468
  await this.#assertWithin(scope, options, 'revoke');
1439
- const event = { action: 'revoked', subject, scope, roles: await this.#resolvedRoles(role, scope, 'revoke'), ...actor };
1440
- await this.#write(event, async () => (await this.driver()).revoke(subject, role, scope));
3469
+ const event = {
3470
+ action: 'revoked',
3471
+ subject,
3472
+ scope,
3473
+ roles: await this.#resolvedRoles(role, scope, 'revoke'),
3474
+ ...actor,
3475
+ ...this.#transactional(options),
3476
+ };
3477
+ // L-2: las opciones viajan al driver (`transaction`, si la puerta la dejó pasar) — como en `grant`.
3478
+ await this.#write(event, async () => (await this.driver()).revoke(subject, role, scope, options));
1441
3479
  await this.#notify(event);
1442
3480
  }
1443
3481
  async deny(subject, permission, scope, options) {
1444
- const actor = this.#writeOptions(options, 'deny');
3482
+ await this.#assertTransactionalWrite(options, 'deny');
3483
+ const actor = await this.#writeOptions(options, 'deny');
1445
3484
  assertIdentity({ subject, permission, scope });
1446
3485
  await this.#assertWithin(scope, options, 'deny');
1447
- const event = { action: 'denied', subject, scope, permission, ...actor };
1448
- await this.#write(event, async () => (await this.driver()).deny(subject, permission, scope));
3486
+ const event = { action: 'denied', subject, scope, permission, ...actor, ...this.#transactional(options) };
3487
+ await this.#write(event, async () => (await this.driver()).deny(subject, permission, scope, options));
1449
3488
  await this.#notify(event);
1450
3489
  }
1451
3490
  async removeDeny(subject, permission, scope, options) {
1452
- const actor = this.#writeOptions(options, 'removeDeny');
3491
+ await this.#assertTransactionalWrite(options, 'removeDeny');
3492
+ const actor = await this.#writeOptions(options, 'removeDeny');
1453
3493
  assertIdentity({ subject, permission, scope });
1454
3494
  await this.#assertWithin(scope, options, 'removeDeny');
1455
- const event = { action: 'deny_removed', subject, scope, permission, ...actor };
1456
- await this.#write(event, async () => (await this.driver()).removeDeny(subject, permission, scope));
3495
+ const event = { action: 'deny_removed', subject, scope, permission, ...actor, ...this.#transactional(options) };
3496
+ await this.#write(event, async () => (await this.driver()).removeDeny(subject, permission, scope, options));
1457
3497
  await this.#notify(event);
1458
3498
  }
1459
3499
  /**
@@ -1614,29 +3654,28 @@ export class AuthorizationManager {
1614
3654
  return { maxScopes: Math.min(options.maxScopes ?? configured, configured), maxNodes };
1615
3655
  }
1616
3656
  /**
1617
- * El subárbol del consumidor para las dos piezas que lo caminan por
1618
- * SEGURIDAD y no por enumeración —`scopes.detached` y la regla de nivel de
1619
- * `defineScopedRole`/`updateScopedRole`—, DEGRADANDO en vez de tumbar la
1620
- * operación (3F · S2, auditor N3).
3657
+ * El subárbol del consumidor para la pieza que lo camina por SEGURIDAD y
3658
+ * no por enumeración —la regla de nivel de `defineScopedRole`/
3659
+ * `updateScopedRole`—, DEGRADANDO en vez de tumbar la operación (3F · S2,
3660
+ * auditor N3).
1621
3661
  *
1622
3662
  * Regla: *declarar `scopes.descendantsOf` nunca puede dejarte peor que no
1623
3663
  * declararlo*. Hasta 3E, una org con más units que `maxDescendants` —la
1624
- * cota sale del config y una llamada no la puede subir (F8)— dejaba el
1625
- * `detached` entero en 503 **sin purgar ni los roles ni los hechos** y al
3664
+ * cota sale del config y una llamada no la puede subir (F8)— dejaba al
1626
3665
  * tenant grande sin poder delegar hacia abajo: la configuración que el
1627
3666
  * invariante 18 recomienda EMPEORABA el caso grande. Ahora, si el subárbol
1628
3667
  * no se puede enumerar (más nodos que la cota, o un `descendantsOf` que
1629
- * falla), se sigue con `enumerated: false`: la purga se acota al scope
1630
- * exacto —lo mismo que sin declararlo, y el resultado lo dice con
1631
- * `truncated`— y la regla de nivel cae a la MÍNIMA (rechazar solo los
1632
- * tipos de un ancestro). Ninguna de las dos degradaciones concede nada:
1633
- * purgar menos deja roles que ya no son visibles en ninguna parte, y la
1634
- * regla mínima es la que corre en todo consumidor con el stub publicado.
3668
+ * falla), se sigue con `enumerated: false` y la regla de nivel cae a la
3669
+ * MÍNIMA (rechazar solo los tipos de un ancestro), que es la que corre en
3670
+ * todo consumidor con el stub publicado y no concede nada.
1635
3671
  * Pero no es gratis y está escrito donde toca (3G · X1, auditor P4): es un
1636
3672
  * control que el propio vigilado puede apagar creando hijos. Lo que NO
1637
- * degrada nunca es la policy de RANGO: con `below = []` sigue corriendo
1638
- * sobre los roles del scope exacto (3G · X2), y ensombrecer sigue pidiendo
1639
- * rango aunque la regla de nivel haya caído a la mínima (3G · W3).
3673
+ * degrada es ensombrecer, que sigue pidiendo rango aunque la regla de
3674
+ * nivel haya caído a la mínima (3G · W3).
3675
+ *
3676
+ * (Desde 3b-0 · Z1 `scopes.detached` ya no llama aquí: purga los hechos
3677
+ * del scope EXACTO y no toca el catálogo, así que no tiene subárbol que
3678
+ * enumerar ni degradación que declarar.)
1640
3679
  *
1641
3680
  * Lo que NO se degrada es un error de CONFIG (`maxDescendants` fuera de
1642
3681
  * rango): eso es un bug del consumidor y sigue siendo 500.
@@ -1644,14 +3683,14 @@ export class AuthorizationManager {
1644
3683
  async #descendantsOrDegrade(scope, operation) {
1645
3684
  const descendantsOf = this.#config.scopes?.descendantsOf;
1646
3685
  if (!descendantsOf)
1647
- return { below: [], declared: false, enumerated: false };
3686
+ return { below: [], enumerated: false };
1648
3687
  const { maxNodes } = this.#scopeBounds(operation, {});
1649
3688
  try {
1650
- return { below: await this.#descendants(descendantsOf, scope, maxNodes), declared: true, enumerated: true };
3689
+ return { below: await this.#descendants(descendantsOf, scope, maxNodes), enumerated: true };
1651
3690
  }
1652
3691
  catch (error) {
1653
3692
  if (error instanceof TooManyScopesError || error instanceof ScopeResolverError) {
1654
- return { below: [], declared: true, enumerated: false };
3693
+ return { below: [], enumerated: false };
1655
3694
  }
1656
3695
  throw error;
1657
3696
  }
@@ -1705,6 +3744,17 @@ export class AuthorizationManager {
1705
3744
  * `indeterminate: true`, para que quien audita registre "puede haber
1706
3745
  * ocurrido" en vez de nada. Cualquier otro fallo (422, conexión rechazada)
1707
3746
  * significa que la escritura no ocurrió y se propaga sin evento.
3747
+ *
3748
+ * **Dentro de la transacción del llamante (L-3) el deadline sigue siendo
3749
+ * `indeterminate: true`**, y el evento lleva además `transactional: true`.
3750
+ * El auditor del panel `{trx}` (🟡 12) objetó que ahí «el rollback la
3751
+ * determina»; pero lo que determina el rollback lo determina el LLAMANTE
3752
+ * y el paquete no lo ve: en el instante del 503 la sentencia puede haber
3753
+ * aterrizado en la transacción (SQLite no cancela; en PG la transacción
3754
+ * queda abortada y ya no puede confirmar; en MySQL la sentencia se mata y
3755
+ * la transacción sigue viva) y el llamante puede confirmar o no. Lo
3756
+ * honesto es el mismo «no lo sé» de siempre más la marca de que la última
3757
+ * palabra es su commit. Medido por motor en `database_transaction.spec.ts`.
1708
3758
  */
1709
3759
  async #write(event, fn) {
1710
3760
  try {