@jantstack/adonis-authz 2.5.0-alpha.3 → 2.5.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (277) hide show
  1. package/README.md +437 -48
  2. package/build/commands/authz_catalog_diff.d.ts +0 -1
  3. package/build/commands/authz_catalog_diff.js +0 -1
  4. package/build/commands/authz_catalog_prune_orphans.d.ts +17 -3
  5. package/build/commands/authz_catalog_prune_orphans.js +28 -6
  6. package/build/commands/authz_catalog_sync.d.ts +0 -1
  7. package/build/commands/authz_catalog_sync.js +0 -1
  8. package/build/commands/authz_freeze.d.ts +0 -1
  9. package/build/commands/authz_freeze.js +0 -1
  10. package/build/commands/authz_reconcile.d.ts +0 -1
  11. package/build/commands/authz_reconcile.js +0 -1
  12. package/build/commands/authz_relations_reconcile.d.ts +0 -1
  13. package/build/commands/authz_relations_reconcile.js +56 -8
  14. package/build/commands/authz_scopes_relay.d.ts +0 -1
  15. package/build/commands/authz_scopes_relay.js +54 -6
  16. package/build/commands/authz_scopes_requeue.d.ts +0 -1
  17. package/build/commands/authz_scopes_requeue.js +16 -3
  18. package/build/commands/authz_unfreeze.d.ts +0 -1
  19. package/build/commands/authz_unfreeze.js +0 -1
  20. package/build/commands/main.d.ts +0 -1
  21. package/build/commands/main.js +0 -1
  22. package/build/commands/openfga_provision.d.ts +10 -1
  23. package/build/commands/openfga_provision.js +43 -3
  24. package/build/configure.d.ts +0 -1
  25. package/build/configure.js +0 -1
  26. package/build/index.d.ts +9 -7
  27. package/build/index.js +10 -3
  28. package/build/providers/authz_provider.d.ts +0 -1
  29. package/build/providers/authz_provider.js +0 -1
  30. package/build/services/main.d.ts +0 -1
  31. package/build/services/main.js +0 -1
  32. package/build/services/relations.d.ts +0 -1
  33. package/build/services/relations.js +0 -1
  34. package/build/src/catalog/catalog.d.ts +0 -1
  35. package/build/src/catalog/catalog.js +0 -1
  36. package/build/src/catalog/catalog_cache.d.ts +13 -1
  37. package/build/src/catalog/catalog_cache.js +27 -2
  38. package/build/src/clock.d.ts +0 -1
  39. package/build/src/clock.js +0 -1
  40. package/build/src/define_config.d.ts +0 -1
  41. package/build/src/define_config.js +24 -1
  42. package/build/src/drivers/database_driver.d.ts +60 -3
  43. package/build/src/drivers/database_driver.js +160 -25
  44. package/build/src/drivers/database_relations_driver.d.ts +11 -5
  45. package/build/src/drivers/database_relations_driver.js +115 -33
  46. package/build/src/drivers/openfga_driver.d.ts +159 -26
  47. package/build/src/drivers/openfga_driver.js +882 -199
  48. package/build/src/drivers/openfga_facts.d.ts +91 -1
  49. package/build/src/drivers/openfga_facts.js +277 -3
  50. package/build/src/drivers/openfga_relations_driver.d.ts +41 -13
  51. package/build/src/drivers/openfga_relations_driver.js +422 -228
  52. package/build/src/errors.d.ts +160 -1
  53. package/build/src/errors.js +161 -1
  54. package/build/src/expiry.d.ts +51 -2
  55. package/build/src/expiry.js +96 -2
  56. package/build/src/freeze.d.ts +0 -1
  57. package/build/src/freeze.js +0 -1
  58. package/build/src/hierarchical_resolver.d.ts +0 -1
  59. package/build/src/hierarchical_resolver.js +0 -1
  60. package/build/src/http/app_access_middleware.d.ts +0 -1
  61. package/build/src/http/app_access_middleware.js +0 -1
  62. package/build/src/http/resource_access_middleware.d.ts +0 -1
  63. package/build/src/http/resource_access_middleware.js +0 -1
  64. package/build/src/identity.d.ts +9 -2
  65. package/build/src/identity.js +33 -1
  66. package/build/src/manager.d.ts +24 -1
  67. package/build/src/manager.js +381 -85
  68. package/build/src/memoize_ancestors.d.ts +0 -1
  69. package/build/src/memoize_ancestors.js +0 -1
  70. package/build/src/models/authz_assignment.d.ts +5 -6
  71. package/build/src/models/authz_assignment.js +0 -1
  72. package/build/src/models/authz_deny.d.ts +5 -6
  73. package/build/src/models/authz_deny.js +0 -1
  74. package/build/src/models/authz_permission.d.ts +5 -6
  75. package/build/src/models/authz_permission.js +0 -1
  76. package/build/src/models/authz_role.d.ts +5 -6
  77. package/build/src/models/authz_role.js +0 -1
  78. package/build/src/models/authz_role_permission.d.ts +5 -6
  79. package/build/src/models/authz_role_permission.js +0 -1
  80. package/build/src/openfga.d.ts +2 -3
  81. package/build/src/openfga.js +4 -2
  82. package/build/src/reconcile.d.ts +0 -1
  83. package/build/src/reconcile.js +0 -1
  84. package/build/src/relation_partition_trigger.d.ts +0 -1
  85. package/build/src/relation_partition_trigger.js +0 -1
  86. package/build/src/relations/check_many.d.ts +0 -1
  87. package/build/src/relations/check_many.js +0 -1
  88. package/build/src/relations/define_relations_config.d.ts +25 -3
  89. package/build/src/relations/define_relations_config.js +8 -3
  90. package/build/src/relations/deny_reach.d.ts +102 -0
  91. package/build/src/relations/deny_reach.js +230 -0
  92. package/build/src/relations/group_depth.d.ts +215 -0
  93. package/build/src/relations/group_depth.js +453 -0
  94. package/build/src/relations/manager.d.ts +14 -7
  95. package/build/src/relations/manager.js +60 -15
  96. package/build/src/relations/page.d.ts +19 -0
  97. package/build/src/relations/page.js +74 -0
  98. package/build/src/relations/reconcile.d.ts +58 -4
  99. package/build/src/relations/reconcile.js +266 -12
  100. package/build/src/relations/tuple_key.d.ts +0 -1
  101. package/build/src/relations/tuple_key.js +0 -1
  102. package/build/src/relations/tuple_order.d.ts +5 -4
  103. package/build/src/relations/tuple_order.js +5 -4
  104. package/build/src/relations_config_store.d.ts +57 -2
  105. package/build/src/relations_config_store.js +155 -5
  106. package/build/src/relations_reconcile.d.ts +0 -1
  107. package/build/src/relations_reconcile.js +0 -1
  108. package/build/src/scope_outbox.d.ts +36 -3
  109. package/build/src/scope_outbox.js +78 -18
  110. package/build/src/shared/backend_guard.d.ts +52 -1
  111. package/build/src/shared/backend_guard.js +164 -2
  112. package/build/src/shared/sql_expiry.d.ts +0 -1
  113. package/build/src/shared/sql_expiry.js +0 -1
  114. package/build/src/shared/transaction_guard.d.ts +0 -1
  115. package/build/src/shared/transaction_guard.js +0 -1
  116. package/build/src/sql_descendants.d.ts +0 -1
  117. package/build/src/sql_descendants.js +0 -1
  118. package/build/src/testing/contract.d.ts +26 -3
  119. package/build/src/testing/contract.js +258 -20
  120. package/build/src/testing/main.d.ts +0 -1
  121. package/build/src/testing/main.js +0 -1
  122. package/build/src/testing/migration_contract.d.ts +0 -1
  123. package/build/src/testing/migration_contract.js +0 -1
  124. package/build/src/testing/relations_contract.d.ts +7 -1
  125. package/build/src/testing/relations_contract.js +174 -7
  126. package/build/src/testing/relations_reconcile_contract.d.ts +0 -1
  127. package/build/src/testing/relations_reconcile_contract.js +0 -1
  128. package/build/src/testing/scope_outbox_contract.d.ts +0 -1
  129. package/build/src/testing/scope_outbox_contract.js +0 -1
  130. package/build/src/testing/scope_tree.d.ts +0 -1
  131. package/build/src/testing/scope_tree.js +0 -1
  132. package/build/src/testing/test_clock.d.ts +0 -1
  133. package/build/src/testing/test_clock.js +0 -1
  134. package/build/src/traits/authz_scopes.d.ts +0 -1
  135. package/build/src/traits/authz_scopes.js +0 -1
  136. package/build/src/traits/has_uuid.d.ts +5 -6
  137. package/build/src/traits/has_uuid.js +0 -1
  138. package/build/src/types.d.ts +138 -14
  139. package/build/src/types.js +6 -1
  140. package/build/stubs/config/authorization.stub +22 -6
  141. package/build/stubs/main.d.ts +0 -1
  142. package/build/stubs/main.js +0 -1
  143. package/package.json +4 -3
  144. package/build/commands/authz_catalog_diff.d.ts.map +0 -1
  145. package/build/commands/authz_catalog_diff.js.map +0 -1
  146. package/build/commands/authz_catalog_prune_orphans.d.ts.map +0 -1
  147. package/build/commands/authz_catalog_prune_orphans.js.map +0 -1
  148. package/build/commands/authz_catalog_sync.d.ts.map +0 -1
  149. package/build/commands/authz_catalog_sync.js.map +0 -1
  150. package/build/commands/authz_freeze.d.ts.map +0 -1
  151. package/build/commands/authz_freeze.js.map +0 -1
  152. package/build/commands/authz_reconcile.d.ts.map +0 -1
  153. package/build/commands/authz_reconcile.js.map +0 -1
  154. package/build/commands/authz_relations_reconcile.d.ts.map +0 -1
  155. package/build/commands/authz_relations_reconcile.js.map +0 -1
  156. package/build/commands/authz_scopes_relay.d.ts.map +0 -1
  157. package/build/commands/authz_scopes_relay.js.map +0 -1
  158. package/build/commands/authz_scopes_requeue.d.ts.map +0 -1
  159. package/build/commands/authz_scopes_requeue.js.map +0 -1
  160. package/build/commands/authz_unfreeze.d.ts.map +0 -1
  161. package/build/commands/authz_unfreeze.js.map +0 -1
  162. package/build/commands/main.d.ts.map +0 -1
  163. package/build/commands/main.js.map +0 -1
  164. package/build/commands/openfga_provision.d.ts.map +0 -1
  165. package/build/commands/openfga_provision.js.map +0 -1
  166. package/build/configure.d.ts.map +0 -1
  167. package/build/configure.js.map +0 -1
  168. package/build/index.d.ts.map +0 -1
  169. package/build/index.js.map +0 -1
  170. package/build/providers/authz_provider.d.ts.map +0 -1
  171. package/build/providers/authz_provider.js.map +0 -1
  172. package/build/services/main.d.ts.map +0 -1
  173. package/build/services/main.js.map +0 -1
  174. package/build/services/relations.d.ts.map +0 -1
  175. package/build/services/relations.js.map +0 -1
  176. package/build/src/catalog/catalog.d.ts.map +0 -1
  177. package/build/src/catalog/catalog.js.map +0 -1
  178. package/build/src/catalog/catalog_cache.d.ts.map +0 -1
  179. package/build/src/catalog/catalog_cache.js.map +0 -1
  180. package/build/src/clock.d.ts.map +0 -1
  181. package/build/src/clock.js.map +0 -1
  182. package/build/src/define_config.d.ts.map +0 -1
  183. package/build/src/define_config.js.map +0 -1
  184. package/build/src/drivers/database_driver.d.ts.map +0 -1
  185. package/build/src/drivers/database_driver.js.map +0 -1
  186. package/build/src/drivers/database_relations_driver.d.ts.map +0 -1
  187. package/build/src/drivers/database_relations_driver.js.map +0 -1
  188. package/build/src/drivers/openfga_driver.d.ts.map +0 -1
  189. package/build/src/drivers/openfga_driver.js.map +0 -1
  190. package/build/src/drivers/openfga_facts.d.ts.map +0 -1
  191. package/build/src/drivers/openfga_facts.js.map +0 -1
  192. package/build/src/drivers/openfga_relations_driver.d.ts.map +0 -1
  193. package/build/src/drivers/openfga_relations_driver.js.map +0 -1
  194. package/build/src/errors.d.ts.map +0 -1
  195. package/build/src/errors.js.map +0 -1
  196. package/build/src/expiry.d.ts.map +0 -1
  197. package/build/src/expiry.js.map +0 -1
  198. package/build/src/freeze.d.ts.map +0 -1
  199. package/build/src/freeze.js.map +0 -1
  200. package/build/src/hierarchical_resolver.d.ts.map +0 -1
  201. package/build/src/hierarchical_resolver.js.map +0 -1
  202. package/build/src/http/app_access_middleware.d.ts.map +0 -1
  203. package/build/src/http/app_access_middleware.js.map +0 -1
  204. package/build/src/http/resource_access_middleware.d.ts.map +0 -1
  205. package/build/src/http/resource_access_middleware.js.map +0 -1
  206. package/build/src/identity.d.ts.map +0 -1
  207. package/build/src/identity.js.map +0 -1
  208. package/build/src/manager.d.ts.map +0 -1
  209. package/build/src/manager.js.map +0 -1
  210. package/build/src/memoize_ancestors.d.ts.map +0 -1
  211. package/build/src/memoize_ancestors.js.map +0 -1
  212. package/build/src/models/authz_assignment.d.ts.map +0 -1
  213. package/build/src/models/authz_assignment.js.map +0 -1
  214. package/build/src/models/authz_deny.d.ts.map +0 -1
  215. package/build/src/models/authz_deny.js.map +0 -1
  216. package/build/src/models/authz_permission.d.ts.map +0 -1
  217. package/build/src/models/authz_permission.js.map +0 -1
  218. package/build/src/models/authz_role.d.ts.map +0 -1
  219. package/build/src/models/authz_role.js.map +0 -1
  220. package/build/src/models/authz_role_permission.d.ts.map +0 -1
  221. package/build/src/models/authz_role_permission.js.map +0 -1
  222. package/build/src/openfga.d.ts.map +0 -1
  223. package/build/src/openfga.js.map +0 -1
  224. package/build/src/reconcile.d.ts.map +0 -1
  225. package/build/src/reconcile.js.map +0 -1
  226. package/build/src/relation_partition_trigger.d.ts.map +0 -1
  227. package/build/src/relation_partition_trigger.js.map +0 -1
  228. package/build/src/relations/check_many.d.ts.map +0 -1
  229. package/build/src/relations/check_many.js.map +0 -1
  230. package/build/src/relations/define_relations_config.d.ts.map +0 -1
  231. package/build/src/relations/define_relations_config.js.map +0 -1
  232. package/build/src/relations/manager.d.ts.map +0 -1
  233. package/build/src/relations/manager.js.map +0 -1
  234. package/build/src/relations/reconcile.d.ts.map +0 -1
  235. package/build/src/relations/reconcile.js.map +0 -1
  236. package/build/src/relations/tuple_key.d.ts.map +0 -1
  237. package/build/src/relations/tuple_key.js.map +0 -1
  238. package/build/src/relations/tuple_order.d.ts.map +0 -1
  239. package/build/src/relations/tuple_order.js.map +0 -1
  240. package/build/src/relations_config_store.d.ts.map +0 -1
  241. package/build/src/relations_config_store.js.map +0 -1
  242. package/build/src/relations_reconcile.d.ts.map +0 -1
  243. package/build/src/relations_reconcile.js.map +0 -1
  244. package/build/src/scope_outbox.d.ts.map +0 -1
  245. package/build/src/scope_outbox.js.map +0 -1
  246. package/build/src/shared/backend_guard.d.ts.map +0 -1
  247. package/build/src/shared/backend_guard.js.map +0 -1
  248. package/build/src/shared/sql_expiry.d.ts.map +0 -1
  249. package/build/src/shared/sql_expiry.js.map +0 -1
  250. package/build/src/shared/transaction_guard.d.ts.map +0 -1
  251. package/build/src/shared/transaction_guard.js.map +0 -1
  252. package/build/src/sql_descendants.d.ts.map +0 -1
  253. package/build/src/sql_descendants.js.map +0 -1
  254. package/build/src/testing/contract.d.ts.map +0 -1
  255. package/build/src/testing/contract.js.map +0 -1
  256. package/build/src/testing/main.d.ts.map +0 -1
  257. package/build/src/testing/main.js.map +0 -1
  258. package/build/src/testing/migration_contract.d.ts.map +0 -1
  259. package/build/src/testing/migration_contract.js.map +0 -1
  260. package/build/src/testing/relations_contract.d.ts.map +0 -1
  261. package/build/src/testing/relations_contract.js.map +0 -1
  262. package/build/src/testing/relations_reconcile_contract.d.ts.map +0 -1
  263. package/build/src/testing/relations_reconcile_contract.js.map +0 -1
  264. package/build/src/testing/scope_outbox_contract.d.ts.map +0 -1
  265. package/build/src/testing/scope_outbox_contract.js.map +0 -1
  266. package/build/src/testing/scope_tree.d.ts.map +0 -1
  267. package/build/src/testing/scope_tree.js.map +0 -1
  268. package/build/src/testing/test_clock.d.ts.map +0 -1
  269. package/build/src/testing/test_clock.js.map +0 -1
  270. package/build/src/traits/authz_scopes.d.ts.map +0 -1
  271. package/build/src/traits/authz_scopes.js.map +0 -1
  272. package/build/src/traits/has_uuid.d.ts.map +0 -1
  273. package/build/src/traits/has_uuid.js.map +0 -1
  274. package/build/src/types.d.ts.map +0 -1
  275. package/build/src/types.js.map +0 -1
  276. package/build/stubs/main.d.ts.map +0 -1
  277. package/build/stubs/main.js.map +0 -1
@@ -10,7 +10,7 @@ import { assertKnownScope, canonicalScopeTargets, isAuthzError, resolveChain, ro
10
10
  import { CatalogCache, GLOBAL_OWNER_KEY, invalidateAuthzCatalog, isRoleVisibleWith, readLocalRoles, withAuthzCatalogWrite } from './catalog/catalog_cache.js';
11
11
  import { assertAssignableAt } from './catalog/catalog.js';
12
12
  import { systemClock } from './clock.js';
13
- import { ActorRequiredError, AuthorizationBackendError, AuthorizationBackendTimeoutError, isPartialWrite, 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';
13
+ import { ActorRequiredError, AuthorizationBackendTimeoutError, isPartialWrite, AuthorizationFrozenError, AuthorizationConfigError, AuthorizationInternalError, CatalogConflictError, InvalidIdentityError, MassPurgeRefusedError, PruneInterruptedError, NoDescendantsResolverError, NotWithinError, PermissionNotDelegableError, RankExceededError, RoleImmutableError, RoleLevelAboveOwnerError, ScopeCycleError, ScopeResolverError, ScopeTreeDriftError, TooManyScopesError, UnknownPermissionError, UnknownRoleError, UnknownScopeError, FreezeHeldError, UnsupportedOperationError, ViewExpiredError, WithinRequiredError, WithinRootForbiddenError, ScopeDriftUnguardedError, ScopeDepthLimitError, ScopeRequeueRefusedError, StaleSourceRefusedError, OperatorWindowExpiredError, isDeploymentFailure, isTransientRelayFailure, markDeploymentFailure, } from './errors.js';
14
14
  import { APP_SCOPE_TYPE, normalizeAuthorizationCapabilities } from './types.js';
15
15
  import { memoizeAncestors } from './memoize_ancestors.js';
16
16
  import { AUTHZ_TABLES_ORIGIN } from './reconcile.js';
@@ -77,6 +77,14 @@ export const DEFAULT_RELAY_BATCH = 100;
77
77
  export const DEFAULT_RELAY_LIMIT = 10_000;
78
78
  /** Páginas que `authz:reconcile` pasea para MEDIR la ventana del relay (3b-3a). */
79
79
  const RELAY_WINDOW_MAX_PAGES = 1_000;
80
+ /**
81
+ * **Fallos del relay que solo dicen algo de la posición del HIJO** (asentar · cierre 2, O-2 del re-ataque): el
82
+ * `E_AUTHZ_SCOPE_TREE_DRIFT` del respaldo (el consumidor no pone al hijo bajo el padre ENCOLADO). La pasada contamina
83
+ * solo la clave del hijo, no la del padre encolado: un `attached` de OTRO hijo bajo ese padre escribe otra arista y
84
+ * conmuta (la misma lectura que la variante B del censo de aparcadas, alpha.8). Hasta aquí, mientras la entrada fallaba
85
+ * —hasta `maxAttempts` pasadas, y una más tras cada `requeue`— taponaba la org entera para los hijos nuevos.
86
+ */
87
+ const CHILD_ONLY_FAILURES = new WeakSet();
80
88
  /**
81
89
  * **Saltos que la sonda de `only` sigue hacia atrás para heredar el ORIGEN de
82
90
  * un bloqueo** (2.4.0-alpha.6). Cada salto es una consulta `earlierPending`
@@ -767,11 +775,15 @@ export class AuthorizationManager {
767
775
  // se contrasta es la de origen, resuelta en fresco.
768
776
  attached: async (child, parent, options) => {
769
777
  const actor = await this.#writeOptions(options, 'scopes.attached');
770
- const edge = await this.#assertEdge(child, parent, 'scopes.attached');
778
+ // RC2-B (rotación 2 · cierre 2, firmado: REORDENAR): `within` —destino Y origen, en fresco— se decide ANTES que el
779
+ // anti-ciclos y el techo de profundidad. Al revés, `SCOPE_CYCLE`/`SCOPE_DEPTH_LIMIT` sobre un scope AJENO decían
780
+ // DÓNDE está (de qué otro scope ajeno desciende, a cuántos saltos de la raíz) antes del `NOT_WITHIN`.
781
+ const edge = await this.#resolveEdge(child, parent, 'scopes.attached');
771
782
  this.#assertWithinChain(parent, edge.chain, options, 'scopes.attached');
772
783
  // Un hijo que el árbol ya conoce se está MOVIENDO (el `attach` de un
773
784
  // nodo existente es un `move`): su origen también tiene que estar dentro.
774
785
  await this.#assertWithinOrigin(child, options, 'scopes.attached', 'if-known');
786
+ this.#judgeEdge(edge, parent, 'scopes.attached');
775
787
  const outbox = this.#outbox();
776
788
  if (outbox) {
777
789
  const receipt = await outbox.enqueue({ op: 'attached', child: edge.child, parent: edge.chain[0] }, { transaction: options?.transaction, ...actor });
@@ -795,9 +807,11 @@ export class AuthorizationManager {
795
807
  */
796
808
  moved: async (child, newParent, options) => {
797
809
  const actor = await this.#writeOptions(options, 'scopes.moved');
798
- const edge = await this.#assertEdge(child, newParent, 'scopes.moved');
810
+ // RC2-B: `within` (destino y origen) antes que el anti-ciclos y el techo (ver `attached`).
811
+ const edge = await this.#resolveEdge(child, newParent, 'scopes.moved');
799
812
  this.#assertWithinChain(newParent, edge.chain, options, 'scopes.moved');
800
813
  await this.#assertWithinOrigin(child, options, 'scopes.moved', 'required');
814
+ this.#judgeEdge(edge, newParent, 'scopes.moved');
801
815
  const outbox = this.#outbox();
802
816
  if (outbox) {
803
817
  const receipt = await outbox.enqueue({ op: 'moved', child: edge.child, parent: edge.chain[0] }, { transaction: options?.transaction, ...actor });
@@ -1214,6 +1228,7 @@ export class AuthorizationManager {
1214
1228
  const parked = new Set();
1215
1229
  /** El último id visto: la outbox pagina desde ahí (lo saltado se queda). */
1216
1230
  let after;
1231
+ const passContext = { outbox, batchSize, appliedThisPass: [] };
1217
1232
  const defer = (item, keys, reason, error, origin) => {
1218
1233
  for (const key of keys)
1219
1234
  block(key, item.id, origin);
@@ -1295,24 +1310,42 @@ export class AuthorizationManager {
1295
1310
  }
1296
1311
  }
1297
1312
  }
1313
+ let appliedHow;
1298
1314
  try {
1299
- await this.#applyScopeChange(driver, item);
1315
+ appliedHow = await this.#applyScopeChange(driver, item, passContext);
1300
1316
  }
1301
1317
  catch (error) {
1302
1318
  const message = error instanceof Error ? error.message : String(error);
1303
- const transient = _a.#isTransientRelayFailure(error);
1319
+ const transient = isTransientRelayFailure(error);
1320
+ const code = typeof error?.code === 'string' ? error.code : undefined;
1304
1321
  await outbox.markFailed(item.id, message, { transient });
1305
- for (const key of keys)
1322
+ const failedKeys = typeof error === 'object' && error !== null && CHILD_ONLY_FAILURES.has(error) ? [scopeKey(item.change.child)] : keys;
1323
+ for (const key of failedKeys)
1306
1324
  block(key, item.id, 'failed');
1307
1325
  parked.add(id);
1308
- failures.push({ id: item.id, change: item.change, error: message, transient });
1326
+ failures.push({
1327
+ id: item.id,
1328
+ change: item.change,
1329
+ error: message,
1330
+ transient,
1331
+ ...(code !== undefined ? { code } : {}),
1332
+ ...(isDeploymentFailure(error) ? { deployment: true } : {}),
1333
+ });
1309
1334
  entries.push({ id: item.id, change: item.change, status: 'failed', reason: message, attempts: item.attempts });
1310
1335
  continue;
1311
1336
  }
1312
1337
  await outbox.markApplied(item.id);
1313
1338
  done.add(id);
1314
1339
  applied.push({ id: item.id, change: item.change, attempts: item.attempts });
1315
- entries.push({ id: item.id, change: item.change, status: 'applied', attempts: item.attempts });
1340
+ passContext.appliedThisPass.push(item.change);
1341
+ entries.push({
1342
+ id: item.id,
1343
+ change: item.change,
1344
+ status: 'applied',
1345
+ attempts: item.attempts,
1346
+ ...(appliedHow?.reason !== undefined ? { reason: appliedHow.reason } : {}),
1347
+ ...(appliedHow?.appliedParent !== undefined ? { appliedParent: appliedHow.appliedParent } : {}),
1348
+ });
1316
1349
  }
1317
1350
  // Una outbox que ignora `after` devuelve el mismo lote atascado: la
1318
1351
  // pasada termina aquí en vez de dar vueltas (lo que quede, y lo que
@@ -1380,18 +1413,6 @@ export class AuthorizationManager {
1380
1413
  'lo que no se pidió (filtra en el puerto, no en memoria).');
1381
1414
  }
1382
1415
  }
1383
- /**
1384
- * La caída del BACKEND no dice nada de la entrada (alpha.5 · R2a): un
1385
- * `AuthorizationBackendError` (503; el timeout es uno), y en general
1386
- * cualquier 503 (el freeze, el resolutor caído). Todo lo demás —422, 500,
1387
- * `E_AUTHZ_SCOPE_TREE_DRIFT`, el padre inexistente, un error sin status de
1388
- * un driver de terceros— es de la entrada y cuenta hacia el aparcado.
1389
- */
1390
- static #isTransientRelayFailure(error) {
1391
- if (error instanceof AuthorizationBackendError)
1392
- return true;
1393
- return typeof error === 'object' && error !== null && error.status === 503;
1394
- }
1395
1416
  /**
1396
1417
  * Las claves de scope de lo APARCADO ⇒ el id aparcado que las nombra
1397
1418
  * (alpha.5 · R2a). El censo lo lee `#relayInputs`: el `dead` ya leído si
@@ -2060,7 +2081,7 @@ export class AuthorizationManager {
2060
2081
  *
2061
2082
  * **La raíz `app` NO es clave** (2.4.0-alpha.8; panel relay 2, cuestión 1;
2062
2083
  * decisión del dueño 2026-09-10 (4)). La raíz no se mueve, no se purga y
2063
- * nunca es hijo (los tres 422 de `#assertEdge`/`scopes.detached`), así que
2084
+ * nunca es hijo (los tres 422 de `#resolveEdge`/`#judgeEdge`/`scopes.detached`), así que
2064
2085
  * dos cambios cuyo ÚNICO cruce sea `app` conmutan: ningún orden que
2065
2086
  * conservar (el auditor lo buscó y no lo encontró; `m2-orden.ts`). Con
2066
2087
  * `[hijo, app]` toda alta de tenant compartía clave con todas las demás:
@@ -2112,17 +2133,105 @@ export class AuthorizationManager {
2112
2133
  * —el driver demuestra cero o lanza—, arista al final** (S6). Al revés,
2113
2134
  * una purga muerta a medias dejaría grants vivos en un scope sin ancestro,
2114
2135
  * los denies heredados dejarían de aplicar y esos permisos serían
2115
- * INDENEGABLES (invariante 2).
2136
+ * INDENEGABLES (invariante 2). **Salvo el PADRE** (lote C · TREE): un
2137
+ * `attached`/`moved` se aplica con el padre FRESCO del consumidor, no con
2138
+ * el encolado (ver el cuerpo); devuelve `'superseded'` si el nodo ya no
2139
+ * existe en el consumidor (aplicada sin arista).
2116
2140
  */
2117
- async #applyScopeChange(driver, item) {
2141
+ async #applyScopeChange(driver, item, context) {
2118
2142
  const change = item.change;
2119
- if (change.op === 'attached') {
2120
- await driver.onScopeAttached?.(change.child, change.parent);
2121
- return;
2122
- }
2123
- if (change.op === 'moved') {
2124
- await driver.onScopeMoved?.(change.child, change.parent);
2125
- return;
2143
+ if (change.op === 'attached' || change.op === 'moved') {
2144
+ // **Asentar · cierre 1 (B4 del auditor del panel de estrategia)**: el padre FRESCO es SOLO para un driver que
2145
+ // mantiene el árbol como HECHOS en su store (`capabilities.hierarchyFacts`: `openfga`). En `database` no hay árbol
2146
+ // que pueda divergir —decide `resolveChain` en cada pregunta—, así que su relay se comporta EXACTAMENTE como en
2147
+ // `f19fd37`: el padre ENCOLADO, sin resolutor ni modos de fallo nuevos (caso de paridad en `scope_outbox.spec`).
2148
+ if (driver.capabilities?.hierarchyFacts !== true) {
2149
+ if (change.op === 'attached')
2150
+ await driver.onScopeAttached?.(change.child, change.parent);
2151
+ else
2152
+ await driver.onScopeMoved?.(change.child, change.parent);
2153
+ return undefined;
2154
+ }
2155
+ // **El relay aplica el padre FRESCO del consumidor** (lote C · TREE, decisión del dueño 2026-10-20 (1), (d)
2156
+ // corregida del juez; 🟠 «TREE con outbox»). El padre ENCOLADO es el que el consumidor quería al notificar, y la
2157
+ // receta publicada notifica ANTES de confirmar la fila: dos `moved` del MISMO nodo pueden confirmar en un orden y
2158
+ // encolarse en el otro, y relevar el padre encolado dejaba el store, para siempre y en silencio, con OTRO padre que
2159
+ // la base del consumidor (medido: 1000/1000 forzado en PG y 200/200 en MySQL). Convergencia (juez TREE §2): la
2160
+ // entrada del ÚLTIMO commit de un nodo se aplica después de ese commit y lee el estado final.
2161
+ //
2162
+ // **Pero el resolutor no es fiable por construcción** (🔴 1 del auditor del paso 4; firma del dueño 2026-10-21): uno
2163
+ // filtrado por tenant responde `null` de todo en un comando (README «the realistic accident») y uno con caché/réplica
2164
+ // responde el padre VIEJO. Aplicar a ciegas convertía un fallo ruidoso en un TREE persistente y MUDO. Regla mínima:
2165
+ // el padre fresco se aplica cuando lo RESPALDA la propia cola —otra entrada del mismo hijo que lo dice (pendiente,
2166
+ // aplicada en esta pasada o aplicada después)—; si no, la entrada FALLA con un error que CONSUME intento (y se aparca
2167
+ // con su motivo tras `maxAttempts`, exit ≠ 0 en cada pasada):
2168
+ // - cadena con padre = encolado ⇒ se aplica (el caso normal);
2169
+ // - padre ≠ encolado RESPALDADO (otra entrada del hijo con ese padre) ⇒ se aplica el fresco, `reason:
2170
+ // 'consumer-parent'` y `appliedParent` en el reporte (la carrera S1: el store converge);
2171
+ // - padre ≠ encolado SIN respaldo ⇒ 500 `E_AUTHZ_SCOPE_TREE_DRIFT` (resolutor rancio, o un cambio no notificado);
2172
+ // con un resolutor que se refresca (TTL), un reintento posterior ve el padre encolado y lo aplica;
2173
+ // - `null` RESPALDADO por un `detached` del hijo en la cola ⇒ aplicada SIN arista, `reason: 'superseded'` (D×D no
2174
+ // resucita un nodo borrado);
2175
+ // - `null` sin respaldo ⇒ 422 `E_AUTHZ_UNKNOWN_SCOPE` (resolutor filtrado por tenant, o hijo huérfano en la base);
2176
+ // - cadena TRUNCADA (`[hijo]` de un nodo que no es la raíz: le falta `app`) ⇒ 503 `E_AUTHZ_RESOLVER_FAILED`
2177
+ // (auditor ⚪ 7: jamás inventar la raíz);
2178
+ // - el resolutor LANZA ⇒ 503 transitorio, no consume intento (regla de alpha.5);
2179
+ // - sin `scopes.resolveChain` ⇒ 500 `E_AUTHZ_CONFIG` (desviación 6 del paso 4).
2180
+ // Residuo declarado: con un resolutor rancio Y otra entrada del mismo hijo que respalde justo el padre rancio (dos
2181
+ // movimientos seguidos del nodo dentro del TTL de la caché) el relay no puede distinguirlo de la carrera S1 y aplica;
2182
+ // el reporte lo dice (`consumer-parent`, exit 1 del relay) y la red (`authz:reconcile --dry-run`, su fase `tree`) lo
2183
+ // ve; el campo `drift.treeMismatch` NO existe todavía (llega en 2.6). Límite declarado `openfga.relay.stale-resolver`.
2184
+ let resolver;
2185
+ try {
2186
+ resolver = this.#resolver('authz:scopes:relay');
2187
+ }
2188
+ catch (error) {
2189
+ // Desviación 6 del paso 4, con su caso (asentar · cierre 1, 🟡-3 del tester): un proceso relay sin
2190
+ // `scopes.resolveChain` es un DESPLIEGUE mal configurado ⇒ transitorio para el relay (inv. 5): no consume intento
2191
+ // ni aparca la cola entera. Hasta el caso, el 500 contaba intento y la aparcaba en `maxAttempts` pasadas.
2192
+ throw markDeploymentFailure(error);
2193
+ }
2194
+ const chain = await resolveChain(resolver, change.child, 'authz:scopes:relay');
2195
+ const childKey = scopeKey(change.child);
2196
+ if (chain === null) {
2197
+ if (await this.#relayBacking(context, item, (other) => other.op === 'detached' && scopeKey(other.child) === childKey)) {
2198
+ return { reason: 'superseded', appliedParent: null };
2199
+ }
2200
+ throw new UnknownScopeError(`authz:scopes:relay: el resolutor del consumidor no conoce ${childKey} (devolvió null) y ninguna entrada ` +
2201
+ `'detached' de ese nodo en la cola lo respalda. No se aplica nada: un resolutor filtrado por tenant (sin ` +
2202
+ `petición en el comando) o con caché, o una fila huérfana en tu árbol. El resolutor del relay tiene que leer ` +
2203
+ `lo CONFIRMADO, sin contexto de tenant. La entrada consume intento y se aparca si persiste.`);
2204
+ }
2205
+ if (chain.length < 2) {
2206
+ throw new ScopeResolverError('authz:scopes:relay', new TypeError(`resolveChain devolvió una cadena de un solo elemento para ${childKey}: le falta la raíz 'app'`));
2207
+ }
2208
+ const parent = chain[1];
2209
+ const parentKey = scopeKey(parent);
2210
+ let reason;
2211
+ if (parentKey !== scopeKey(change.parent)) {
2212
+ const backed = await this.#relayBacking(context, item, (other) => other.op !== 'detached' && scopeKey(other.child) === childKey && scopeKey(other.parent) === parentKey);
2213
+ if (!backed) {
2214
+ const drift = new ScopeTreeDriftError(`authz:scopes:relay: el padre de ${childKey} que da el resolutor del consumidor (${parentKey}) no es el ` +
2215
+ `encolado (${scopeKey(change.parent)}) y ninguna otra entrada de ese nodo en la cola lo respalda. No se ` +
2216
+ `aplica nada: un resolutor con caché o réplica atrasada, o un cambio de árbol no notificado. El resolutor del ` +
2217
+ `relay tiene que leer lo CONFIRMADO, sin TTL. La entrada consume intento y se aparca si persiste. Si tu base ` +
2218
+ `ya tiene a ${childKey} bajo ${parentKey} y esta entrada es una intención VIEJA (otra entrada del nodo se ` +
2219
+ `aplicó antes en otra pasada), la salida es volver a notificar scopes.moved(${childKey}, ${parentKey}) —la ` +
2220
+ `entrada nueva la respalda— y reencolar #${String(item.id)} con authz:scopes:requeue ` +
2221
+ `--id=${String(item.id)} --apply. Si ese requeue se niega (E_AUTHZ_REQUEUE_REFUSED) solo por cambios ` +
2222
+ `aplicados de OTROS hijos bajo ${scopeKey(change.parent)}, el conflicto es falso: authz:scopes:requeue ` +
2223
+ `--id=${String(item.id)} --force y después authz:scopes:relay (el relay vuelve a resolver el padre en ` +
2224
+ `fresco y exige respaldo: no revierte nada).`);
2225
+ CHILD_ONLY_FAILURES.add(drift);
2226
+ throw drift;
2227
+ }
2228
+ reason = 'consumer-parent';
2229
+ }
2230
+ if (change.op === 'attached')
2231
+ await driver.onScopeAttached?.(change.child, parent);
2232
+ else
2233
+ await driver.onScopeMoved?.(change.child, parent);
2234
+ return { ...(reason !== undefined ? { reason } : {}), appliedParent: parent };
2126
2235
  }
2127
2236
  // La auditoría no pierde al autor por haber pasado por una cola.
2128
2237
  const event = {
@@ -2134,6 +2243,32 @@ export class AuthorizationManager {
2134
2243
  await driver.onScopeDetached?.(change.child);
2135
2244
  await this.#notify(event);
2136
2245
  }
2246
+ /**
2247
+ * **¿La cola RESPALDA el padre fresco?** (asentar · cierre 1, 🔴 1 del auditor). Otra entrada del mismo hijo que lo
2248
+ * dice: aplicada en ESTA pasada, PENDIENTE (cualquier id; se pagina la cola entera, solo cuando el fresco no coincide
2249
+ * con el encolado: raro) o APLICADA con un id posterior (`laterApplied`, si la outbox lo trae). Nunca una aplicada
2250
+ * ANTERIOR de otra pasada: una caché rancia diría justo el padre de esa entrada vieja.
2251
+ */
2252
+ async #relayBacking(context, item, matches) {
2253
+ if (context.appliedThisPass.some(matches))
2254
+ return true;
2255
+ const mine = String(item.id);
2256
+ let after;
2257
+ for (let page = 0; page < RELAY_WINDOW_MAX_PAGES; page++) {
2258
+ const batch = await context.outbox.pending(context.batchSize, after);
2259
+ if (batch.some((other) => String(other.id) !== mine && matches(other.change)))
2260
+ return true;
2261
+ if (batch.length < context.batchSize)
2262
+ break;
2263
+ after = batch[batch.length - 1].id;
2264
+ }
2265
+ if (typeof context.outbox.laterApplied === 'function') {
2266
+ const later = await context.outbox.laterApplied(item.id, [item.change.child], RELAY_WINDOW_MAX_PAGES);
2267
+ if (later.some((other) => matches(other.change)))
2268
+ return true;
2269
+ }
2270
+ return false;
2271
+ }
2137
2272
  /** Cota entera positiva de las opciones del relay (500 si llega otra cosa). */
2138
2273
  /**
2139
2274
  * **Una opción desconocida es 500, nunca silencio** (2.4.0-alpha.8 · X-1;
@@ -2269,16 +2404,35 @@ export class AuthorizationManager {
2269
2404
  return;
2270
2405
  this.#assertWithinChain(child, chain, options, operation);
2271
2406
  }
2272
- /** Lo mismo con una cadena ya resuelta (en fresco) por el llamante. */
2273
- #assertWithinChain(scope, chain, options, operation) {
2407
+ /**
2408
+ * Lo mismo con una cadena ya resuelta (en fresco) por el llamante. `asked` es cómo nombra el `message` lo que el
2409
+ * llamante PASÓ cuando el scope contrastado NO lo pasó él (RC2-A, rotación 2 · cierre 2: en `updateScopedRole`/
2410
+ * `deleteScopedRole` se contrasta el OWNER del rol, que sale del catálogo — el llamante solo pasó el uuid del rol).
2411
+ */
2412
+ #assertWithinChain(scope, chain, options, operation, asked) {
2274
2413
  const within = this.#requiredWithin(scope, options, operation);
2275
2414
  if (!within)
2276
2415
  return;
2277
2416
  if (!chain.some((s) => _a.#sameScope(s, within))) {
2278
- throw new NotWithinError(`${operation}: ${scope.type}:${scope.uuid ?? ''} no está dentro de ` +
2279
- `${within.type}:${within.uuid ?? ''} (la cadena es ${chain.map((s) => `${s.type}:${s.uuid ?? ''}`).join(' → ')}); ` +
2280
- `no se escribe fuera del scope declarado.`);
2417
+ // R2-03: el `message` llega al tenant tal cual — solo nombra lo que el llamante PASÓ (el scope, o el rol, y su
2418
+ // `within`); la cadena (identificadores de OTRO tenant) va a `error.details`, no enumerable, para el log del servidor.
2419
+ throw new NotWithinError(`${operation}: ${asked ?? `${scope.type}:${scope.uuid ?? ''}`} no está dentro de ${within.type}:${within.uuid ?? ''}; ` +
2420
+ `no se escribe fuera del scope declarado.`, { chain: chain.map((s) => ({ type: s.type, uuid: s.uuid ?? null })) });
2421
+ }
2422
+ }
2423
+ /**
2424
+ * La cadena (fresca) del OWNER de un rol local para `updateScopedRole`/`deleteScopedRole`, o 422
2425
+ * `E_AUTHZ_UNKNOWN_SCOPE` si el owner ya no resuelve (rol DORMIDO). El `message` nombra el ROL tal como lo pasó el
2426
+ * llamante, nunca el owner: el owner es un identificador que el llamante no aportó y puede ser de otro tenant (RC2-A).
2427
+ * El owner va a `error.details.owner`, no enumerable. El CÓDIGO no cambia.
2428
+ */
2429
+ async #ownerChainOf(owner, roleUuid, operation) {
2430
+ const chain = await resolveChain(this.#freshResolver(), owner, operation);
2431
+ if (!chain) {
2432
+ throw new UnknownScopeError(`El owner del rol ${roleUuid} no existe para el resolutor de la cadena (${operation}): el rol está dormido. ` +
2433
+ `No se escribe sobre un scope que el árbol no reconoce; lo retira la plataforma (authz:catalog:prune-orphans).`, { owner: { type: owner.type, uuid: owner.uuid ?? null } });
2281
2434
  }
2435
+ return chain;
2282
2436
  }
2283
2437
  /** El `within` a contrastar, validado y exigido según `requireWithin`; `null` si no hay que contrastar nada. */
2284
2438
  #requiredWithin(scope, options, operation) {
@@ -2327,13 +2481,13 @@ export class AuthorizationManager {
2327
2481
  return resolver;
2328
2482
  }
2329
2483
  /**
2330
- * Valida la arista y devuelve la cadena (fresca) del padre y el HIJO
2331
- * CANÓNICO (invariante 17). El hijo canónico se devuelve desde 3b-2d
2484
+ * Resuelve la arista —identidad, padre conocido— y devuelve la cadena (fresca) del padre y el HIJO
2485
+ * CANÓNICO (invariante 17); el anti-ciclos y el techo los juzga `#judgeEdge`, DESPUÉS de `within` (RC2-B). El hijo canónico se devuelve desde 3b-2d
2332
2486
  * porque la outbox lo encola: lo que se guarda en la cola es la fila del
2333
2487
  * árbol, no lo que escribió el llamante — si no, el relay abriría días
2334
2488
  * después una rama nueva en el store por un alias del uuid.
2335
2489
  */
2336
- async #assertEdge(child, parent, operation) {
2490
+ async #resolveEdge(child, parent, operation) {
2337
2491
  const resolver = this.#resolver(operation);
2338
2492
  assertScope(child);
2339
2493
  assertScope(parent);
@@ -2345,14 +2499,22 @@ export class AuthorizationManager {
2345
2499
  // El hijo, si el árbol ya lo conoce, con su identidad canónica (K1): un
2346
2500
  // alias del uuid no puede colarse por debajo de la comprobación de ciclo.
2347
2501
  const known = await resolveChain(resolver, child, operation);
2348
- const canonicalChild = known ? known[0] : child;
2349
- const childKey = _a.#scopeKey(canonicalChild);
2350
- if (chain.some((s) => _a.#scopeKey(s) === childKey)) {
2502
+ return { chain, child: known ? known[0] : child };
2503
+ }
2504
+ /**
2505
+ * La otra mitad de la arista: anti-ciclos y techo de profundidad sobre lo que `#resolveEdge` resolvió. **Va DESPUÉS de
2506
+ * `within`** (rotación 2 · cierre 2 · RC2-B, firmado: REORDENAR): los dos errores hablan de la cadena del PADRE —«desciende
2507
+ * de …», «lo dejaría a N saltos»— y con un padre de otro tenant eran un oráculo de ESTRUCTURA ajena (de qué org cuelga
2508
+ * una unit ajena, a qué profundidad está) que salía antes del `NOT_WITHIN`. Sin `within`, o con uno que pasa, el orden
2509
+ * de los demás errores no cambia (identidad → padre desconocido → ciclo → profundidad).
2510
+ */
2511
+ #judgeEdge(edge, parent, operation) {
2512
+ const childKey = _a.#scopeKey(edge.child);
2513
+ if (edge.chain.some((s) => _a.#scopeKey(s) === childKey)) {
2351
2514
  throw new ScopeCycleError(`${operation}: ${parent.type}:${parent.uuid} desciende de ${childKey.replace('\u001f', ':')} (o es él mismo); ` +
2352
2515
  `colgarlo cerraría un ciclo y la herencia dejaría de ser solo hacia abajo.`);
2353
2516
  }
2354
- this.#assertDepth(chain, canonicalChild, parent, operation);
2355
- return { chain, child: canonicalChild };
2517
+ this.#assertDepth(edge.chain, edge.child, parent, operation);
2356
2518
  }
2357
2519
  /**
2358
2520
  * **El gate de profundidad, opt-in** (2.4.0-alpha.5 · R0; `scopes.maxDepth`).
@@ -2467,8 +2629,10 @@ export class AuthorizationManager {
2467
2629
  if (!chain)
2468
2630
  return [];
2469
2631
  const catalog = await this.#catalogFor(driver).view();
2470
- const { granted } = await this.#rolesAlong(driver, subject, chain, catalog);
2471
- const denied = await this.#deniedAlong(driver, subject, chain, 'effectivePermissions');
2632
+ // Roles y denies en UNA foto (rotación 2 · cierre 3, L12): ver `#decisionFacts`.
2633
+ const facts = await this.#decisionFacts(driver, subject, { chain, catalog }, 'effectivePermissions');
2634
+ const { granted } = this.#grantedFrom(facts.roles, chain, catalog);
2635
+ const denied = this.#deniedIn(facts.denies, chain);
2472
2636
  return [...granted].filter((permission) => !denied.has(permission));
2473
2637
  }
2474
2638
  /**
@@ -2492,11 +2656,18 @@ export class AuthorizationManager {
2492
2656
  * `E_AUTHZ_AMBIGUOUS_ROLE`) si hay homónimos visibles. Nunca elige uno.
2493
2657
  */
2494
2658
  async #rolesAlong(driver, subject, chain, catalog) {
2659
+ return this.#grantedFrom(await this.#readRoles(driver, subject, chain, catalog), chain, catalog);
2660
+ }
2661
+ /** Los roles del holder en la cadena por el PUERTO: `rolesInChain` si el driver lo trae; si no, la composición por slug. */
2662
+ async #readRoles(driver, subject, chain, catalog) {
2663
+ return typeof driver.rolesInChain === 'function'
2664
+ ? await driver.rolesInChain(subject, chain)
2665
+ : await this.#rolesFromSlugs(driver, subject, chain, chainKeysFrom(chain), catalog);
2666
+ }
2667
+ /** Lo que conceden unos roles YA LEÍDOS a lo largo de la cadena, y el rank más alto (puro: el catálogo es el memo). */
2668
+ #grantedFrom(roles, chain, catalog) {
2495
2669
  const keysFrom = chainKeysFrom(chain);
2496
2670
  const levelIndex = new Map(chain.map((s, i) => [scopeKey(s), i]));
2497
- const roles = typeof driver.rolesInChain === 'function'
2498
- ? await driver.rolesInChain(subject, chain)
2499
- : await this.#rolesFromSlugs(driver, subject, chain, keysFrom, catalog);
2500
2671
  const granted = new Set();
2501
2672
  let rank = 0;
2502
2673
  for (const { scope: level, role } of roles) {
@@ -2595,18 +2766,91 @@ export class AuthorizationManager {
2595
2766
  return undefined;
2596
2767
  }
2597
2768
  }
2598
- /** Permisos DENEGADOS al holder en algún scope de la cadena: `listDenies` en UNA lectura (500 `E_AUTHZ_UNSUPPORTED` sin él). */
2599
- async #deniedAlong(driver, subject, chain, primitive) {
2600
- const listDenies = this.#optional(driver, 'listDenies', primitive);
2769
+ /** Permisos DENEGADOS al holder en algún scope de la cadena, sobre denies YA LEÍDOS (los de `listDenies(subject)`). */
2770
+ #deniedIn(denies, chain) {
2601
2771
  const key = _a.#scopeKey;
2602
2772
  const chainKeys = new Set(chain.map(key));
2603
2773
  const denied = new Set();
2604
- for (const deny of await listDenies(subject)) {
2774
+ for (const deny of denies) {
2605
2775
  if (chainKeys.has(key(deny.scope)))
2606
2776
  denied.add(deny.permission);
2607
2777
  }
2608
2778
  return denied;
2609
2779
  }
2780
+ /**
2781
+ * **El punto de paso ÚNICO de las decisiones que el manager COMPONE con concesión y deny** (rotación 2 · cierre 3;
2782
+ * 4.ª aparición del linaje L12 «lector rasgado»). `effectivePermissions`, la policy de delegación de
2783
+ * `defineScopedRole`/`updateScopedRole` (`#assertDelegable`) y `authorizedScopes` combinan lo que el holder TIENE
2784
+ * (`rolesInChain`, o `listScopes` de un permiso) con lo que tiene DENEGADO (`listDenies`). Hasta aquí eran dos métodos
2785
+ * del puerto, dos lecturas sin foto común: un offboarding suelto (revoke y luego removeDeny) entre las dos hacía que
2786
+ * `effectivePermissions` listara un permiso, `authorizedScopes` una unit y `defineScopedRole` delegara algo que el
2787
+ * holder no tenía en NINGÚN estado real (repro `.claude/reproducciones/database-authorize-rasgado.spec.ts`, caso 5).
2788
+ *
2789
+ * Regla: con un driver que DECLARA la capacidad `snapshotDecisions: true` (opcional por D15) las DOS mitades salen de
2790
+ * UNA llamada a `readSnapshot`, que el driver responde desde una sola foto de sus hechos; la respuesta se contrasta
2791
+ * con lo pedido (una clave que falta o que no es un array es un bug del driver: 500 nombrándolo, como
2792
+ * `authorizeMany`). Decide la CAPACIDAD, no la presencia del método (rotación 2 · cierre 3b, H3 del auditor; el
2793
+ * patrón de `membersOfNative`): declarar `true` sin traer `readSnapshot` es 500 `E_AUTHZ_CONFIG` antes de leer nada,
2794
+ * y un driver que declara `false` (o nada) compone como siempre aunque tenga un método con ese nombre —el hueco
2795
+ * DECLARADO de un driver sin la capacidad (`openfga`, terceros)—. En los dos caminos, sin `listDenies` es 500
2796
+ * `E_AUTHZ_UNSUPPORTED` antes de leer nada: nunca un «sin denies».
2797
+ *
2798
+ * `catalog` va con `chain` porque la composición por slug lo necesita; el catálogo y la cadena se leen FUERA de la
2799
+ * foto (el memo contrasta la versión compartida; la cadena es del consumidor: «cadena frente a hechos», declarado).
2800
+ *
2801
+ * **Cuándo sale el 500 de «`true` sin `readSnapshot`»** (rotación 2 · cierre 3c, J3 del auditor): en la PRIMERA
2802
+ * decisión compuesta (`effectivePermissions`, `authorizedScopes`, la policy de `defineScopedRole`/`updateScopedRole`),
2803
+ * no al resolver el driver — `authorize` y los `list*` no lo necesitan y siguen respondiendo. Fail-closed y ruidoso,
2804
+ * pero tarde; el patrón es el de `#optional` (una operación que falta se dice cuando se pide). El mensaje nombra el
2805
+ * driver que lo EMITIÓ —la clave de `config.drivers` y la clase del objeto que la factory devolvió—, no solo
2806
+ * `config.default`.
2807
+ */
2808
+ async #decisionFacts(driver, subject, request, primitive) {
2809
+ const listDenies = this.#optional(driver, 'listDenies', primitive);
2810
+ if (driver.capabilities?.snapshotDecisions === true) {
2811
+ if (typeof driver.readSnapshot !== 'function') {
2812
+ throw new AuthorizationConfigError(`${primitive}: el driver ${this.#emitter(driver)} declara snapshotDecisions: true y no trae readSnapshot. ` +
2813
+ 'La capacidad promete leer en UNA foto lo que el manager compone; sin el método no hay foto que leer. ' +
2814
+ 'Implementa readSnapshot o declara snapshotDecisions: false (el manager compone con dos lecturas, el hueco declarado).');
2815
+ }
2816
+ const answer = await driver.readSnapshot(subject, {
2817
+ ...(request.chain ? { rolesInChain: request.chain } : {}),
2818
+ ...(request.permission !== undefined ? { listScopes: request.permission } : {}),
2819
+ listDenies: true,
2820
+ });
2821
+ const got = (answer ?? {});
2822
+ const missing = [
2823
+ ...(request.chain ? ['rolesInChain'] : []),
2824
+ ...(request.permission !== undefined ? ['listScopes'] : []),
2825
+ 'listDenies',
2826
+ ].filter((key) => !Array.isArray(got[key]));
2827
+ if (answer === null || typeof answer !== 'object' || missing.length) {
2828
+ throw new AuthorizationInternalError(`${primitive}: el driver ${this.#emitter(driver)} respondió a readSnapshot sin ${missing.join(', ') || 'un objeto'}; ` +
2829
+ 'el puerto exige un array por cada lectura pedida, todas de la misma foto.');
2830
+ }
2831
+ return {
2832
+ roles: got.rolesInChain,
2833
+ scopes: got.listScopes,
2834
+ denies: got.listDenies,
2835
+ };
2836
+ }
2837
+ // Fallback DECLARADO (driver sin `snapshotDecisions: true`): dos lecturas del puerto sin foto común. Un método
2838
+ // `readSnapshot` de un driver que no declara la capacidad NO se llama (puede ser otra cosa con el mismo nombre).
2839
+ const roles = request.chain ? await this.#readRoles(driver, subject, request.chain, request.catalog) : undefined;
2840
+ const scopes = request.permission !== undefined ? await driver.listScopes(subject, request.permission) : undefined;
2841
+ return { roles, scopes, denies: await listDenies(subject) };
2842
+ }
2843
+ /**
2844
+ * Cómo se nombra en un error el driver que lo EMITIÓ (cierre 3c, J3): la clave de `config.drivers` que lo resolvió y la
2845
+ * clase del objeto que devolvió la factory (una vista `Object.create`/`withClock` hereda la del driver). La clave sola
2846
+ * puede no decir qué objeto respondió (una factory que elige entre varios, una vista).
2847
+ */
2848
+ #emitter(driver) {
2849
+ const kind = driver?.constructor?.name;
2850
+ return typeof kind === 'string' && kind !== '' && kind !== 'Object'
2851
+ ? `'${this.#config.default}' (${kind})`
2852
+ : `'${this.#config.default}'`;
2853
+ }
2610
2854
  // ── Roles locales a un scope (3B · B3): la API de DELEGACIÓN ─────────
2611
2855
  // Un administrador de un scope (el actor) define roles que solo existen
2612
2856
  // dentro de ese scope (owner) con permisos que él mismo tiene efectivos
@@ -2658,8 +2902,10 @@ export class AuthorizationManager {
2658
2902
  // Composición y lista blanca antes de leer hechos: lo barato primero.
2659
2903
  this.#assertComposable(parsed.permissions, parsed, catalog);
2660
2904
  await this.#assertLevelUnderOwner(parsed.scopeType, chain, 'defineScopedRole');
2661
- const { granted, rank: actorRank } = await this.#rolesAlong(driver, who, chain, catalog);
2662
- const denied = await this.#deniedAlong(driver, who, chain, 'defineScopedRole');
2905
+ // Lo que el actor tiene y lo que tiene denegado, en UNA foto (rotación 2 · cierre 3, L12: `#decisionFacts`).
2906
+ const facts = await this.#decisionFacts(driver, who, { chain, catalog }, 'defineScopedRole');
2907
+ const { granted, rank: actorRank } = this.#grantedFrom(facts.roles, chain, catalog);
2908
+ const denied = this.#deniedIn(facts.denies, chain);
2663
2909
  this.#assertDelegable(parsed.permissions, granted, denied, owner);
2664
2910
  this.#assertRank(parsed.rank, actorRank, catalog.topGlobalRank);
2665
2911
  const shadowedByAncestor = await this.#assertNoRoleCollision(parsed.slug, parsed.scopeType, owner, chain, catalog.rolesNamed(parsed.slug, parsed.scopeType));
@@ -2679,18 +2925,20 @@ export class AuthorizationManager {
2679
2925
  const known = new Set(catalog.rolesNamed(parsed.slug, parsed.scopeType).map((r) => r.owner));
2680
2926
  const fresh = [...new Set(rows.map((r) => String(r.owner_scope_key)).filter((o) => !known.has(o)))];
2681
2927
  if (fresh.length) {
2682
- // Los homónimos que el memo no tenía son de una escritura confirmada
2683
- // mientras validábamos. Los que se pueden juzgar SIN salir de la
2684
- // transacción (global, o un ancestro-o-igual del owner: la cadena ya
2685
- // está resuelta) dan el 422 preciso; el resto se rechaza igual —
2686
- // resolver su cadena aquí dentro pediría otra conexión mientras se
2687
- // sostiene el cerrojo del catálogo (con un pool de 1, un abrazo
2688
- // mortal). Reintentar es correcto: el memo ya está invalidado y el
2689
- // segundo intento valida con la foto buena.
2928
+ // Los homónimos que el memo no tenía son de una escritura confirmada mientras validábamos. Se juzgan con la
2929
+ // MISMA regla de colisión por AUTORIDAD que la comprobación previa (3F · S3, invariante 18), en lo que se puede
2930
+ // juzgar SIN salir de la transacción —la cadena del owner ya está resuelta—: global o ancestro-o-igual ⇒ 422
2931
+ // preciso (eso mantiene M2: dos `define` del mismo owner acaban en uno). **Cualquier otro owner NO es colisión**
2932
+ // (rotación 2 · R2-05, decisión del dueño 2026-10-22): un hermano —otro tenant— puede tener el mismo slug, y
2933
+ // un DESCENDIENTE queda ensombrecido, que es el segundo final legal de la carrera ancestro↔descendiente
2934
+ // (3G · Y1). Hasta este cierre los dos se rechazaban con un 422 que NOMBRABA el owner ajeno («owner
2935
+ // organization|<uuid de A>»): un oráculo de qué tenants definen qué slug, y una colisión falsa (reintentar
2936
+ // entraba). Lo que NO se hace aquí, a propósito: resolver la cadena del owner nuevo dentro de la transacción
2937
+ // (pediría otra conexión con el cerrojo del catálogo sostenido: abrazo mortal con pool 1), así que el rango
2938
+ // sobre un descendiente confirmado EN la ventana no se mide (W3 protege a los roles que YA existían al validar;
2939
+ // el mismo actor obtiene lo mismo yendo primero) y no sale en `shadowedByAncestor` del evento — sí en
2940
+ // `authz:catalog:diff`. Declarado en el CHANGELOG.
2690
2941
  await this.#assertNoRoleCollision(parsed.slug, parsed.scopeType, owner, chain, fresh.map((o) => ({ owner: o })), 'sin-árbol');
2691
- throw new CatalogConflictError(`El catálogo cambió mientras se validaba '${parsed.slug}@${parsed.scopeType}': apareció un rol con ese nombre ` +
2692
- `(owner ${fresh.join(', ')}) que no estaba en la foto con la que se comprobaron las colisiones. ` +
2693
- `No se escribe a ciegas; reintenta la operación.`);
2694
2942
  }
2695
2943
  const now = systemClock();
2696
2944
  await trx.table('authz_roles').insert({
@@ -2742,10 +2990,10 @@ export class AuthorizationManager {
2742
2990
  const catalog = await this.#catalogFor(driver).view();
2743
2991
  const role = this.#localRoleOrFail(catalog, roleUuid);
2744
2992
  const owner = this.#ownerOf(role);
2745
- const chain = await assertKnownScope(this.#freshResolver(), owner, 'updateScopedRole');
2993
+ const chain = await this.#ownerChainOf(owner, role.uuid, 'updateScopedRole');
2746
2994
  // El scope contrastado es el OWNER del rol (3D · M3): editar un rol es
2747
2995
  // escribir dentro de su contenedor.
2748
- this.#assertWithinChain(owner, chain, options, 'updateScopedRole');
2996
+ this.#assertWithinChain(owner, chain, options, 'updateScopedRole', `el rol ${role.uuid}`);
2749
2997
  const current = [...catalog.rolePermissionsOf(role.uuid)].sort();
2750
2998
  const next = {
2751
2999
  name: parsed.name ?? null,
@@ -2759,15 +3007,24 @@ export class AuthorizationManager {
2759
3007
  // POR ENCIMA de su owner (creado antes de 3E o a mano) no se perpetúa:
2760
3008
  // es una mina de slug y lo que toca es purgarlo, no editarlo.
2761
3009
  await this.#assertLevelUnderOwner(role.scopeType, chain, 'updateScopedRole');
2762
- const { granted, rank: actorRank } = await this.#rolesAlong(driver, who, chain, catalog);
3010
+ // Con permisos nuevos la policy combina lo que el actor tiene con lo que tiene denegado: UNA foto (rotación 2 ·
3011
+ // cierre 3, L12: `#decisionFacts`). Sin permisos solo hace falta el rank (una mitad: no rasga). Un driver SIN
3012
+ // `listDenies` no tiene denies que leer: se leen solo los roles y el 500 `E_AUTHZ_UNSUPPORTED` sale DESPUÉS del 422
3013
+ // de rango, en el orden de siempre (rotación 2 · cierre 3b, H4 del auditor).
3014
+ const withDenies = Boolean(parsed.permissions) && typeof driver.listDenies === 'function';
3015
+ const facts = withDenies
3016
+ ? await this.#decisionFacts(driver, who, { chain, catalog }, 'updateScopedRole')
3017
+ : { roles: await this.#readRoles(driver, who, chain, catalog), denies: [] };
3018
+ const { granted, rank: actorRank } = this.#grantedFrom(facts.roles, chain, catalog);
2763
3019
  this.#assertAboveRole(actorRank, role);
2764
3020
  // 3G · W3: este rol puede estar ENSOMBRECIENDO al homónimo de un
2765
3021
  // descendiente (3F · S3). Cambiarle rank o permisos es seguir ejerciendo
2766
3022
  // esa autoridad, así que exige lo mismo que crearlo: superarlo en rango.
2767
3023
  this.#assertAboveShadowed(actorRank, await this.#shadowedBelow(scopeKey(owner), new Set(chain.map(scopeKey)), catalog.rolesNamed(role.slug, role.scopeType).filter((other) => other.uuid !== role.uuid), 'updateScopedRole'), 'updateScopedRole');
2768
3024
  if (parsed.permissions) {
2769
- const denied = await this.#deniedAlong(driver, who, chain, 'updateScopedRole');
2770
- this.#assertDelegable(parsed.permissions, granted, denied, owner);
3025
+ if (!withDenies)
3026
+ this.#optional(driver, 'listDenies', 'updateScopedRole');
3027
+ this.#assertDelegable(parsed.permissions, granted, this.#deniedIn(facts.denies, chain), owner);
2771
3028
  }
2772
3029
  if (parsed.rank !== undefined)
2773
3030
  this.#assertRank(parsed.rank, actorRank, catalog.topGlobalRank);
@@ -2836,8 +3093,8 @@ export class AuthorizationManager {
2836
3093
  const catalog = await this.#catalogFor(driver).view();
2837
3094
  const role = this.#localRoleOrFail(catalog, roleUuid);
2838
3095
  const owner = this.#ownerOf(role);
2839
- const chain = await assertKnownScope(this.#freshResolver(), owner, 'deleteScopedRole');
2840
- this.#assertWithinChain(owner, chain, options, 'deleteScopedRole');
3096
+ const chain = await this.#ownerChainOf(owner, role.uuid, 'deleteScopedRole');
3097
+ this.#assertWithinChain(owner, chain, options, 'deleteScopedRole', `el rol ${role.uuid}`);
2841
3098
  const { rank: actorRank } = await this.#rolesAlong(driver, who, chain, catalog);
2842
3099
  this.#assertAboveRole(actorRank, role);
2843
3100
  const permissions = [...catalog.rolePermissionsOf(role.uuid)].sort();
@@ -2934,6 +3191,21 @@ export class AuthorizationManager {
2934
3191
  * antes de su `purgeRole`; si ha vuelto, el rol se salta y se cuenta en
2935
3192
  * `skipped` con `reason: 'owner-came-back'`.
2936
3193
  *
3194
+ * **Tercer disparador del seguro** (rotación 2 de M4 · R2-02, decisión del dueño 2026-10-22 (2)): la
3195
+ * proporción la controla un TENANT —con la API de delegación legítima infla su catálogo local hasta que los
3196
+ * huérfanos de los demás bajan del 50 %—, y la variante realista del resolutor ciego no es «`null` para todo»
3197
+ * sino un contexto de tenant COLADO en el job de plataforma, que solo deja vivo a ese tenant. Así que, con
3198
+ * `force`, si los huérfanos son de MÁS DE UN owner y ALGUNO todavía concede (`stillGranting === true`) o no se
3199
+ * sabe (`undefined`: el driver no cuenta), también es 500 `E_AUTHZ_MASS_PURGE_REFUSED` antes de borrar nada,
3200
+ * salvo `allowMassPurge`. Un solo owner huérfano con asignaciones vivas sigue pasando (es el caso normal de un
3201
+ * tenant que se va, y el listado lo marca aparte). El `--dry-run` no lanza: lo marca en `massPurge` y dice por
3202
+ * qué en `massPurgeReasons` (`'all-owners' | 'proportion' | 'granting-across-owners'`).
3203
+ * - **Cota de lectura** (R2-04): `maxLocalRoles` (default `DEFAULT_MAX_LOCAL_ROLES`, `--max-local-roles` en el
3204
+ * comando) acota la lectura del catálogo local; pasarla es 500 `E_AUTHZ_TOO_MANY_LOCAL_ROLES`, nunca una lista
3205
+ * parcial. Hasta este cierre la opción no existía y el mensaje pedía «subir la cota» sin mando: un tenant con
3206
+ * 10 001 roles locales dejaba `prune-orphans` muerto para todos. Un valor que no es un entero ≥ 1 es 500
3207
+ * `E_AUTHZ_CONFIG` antes de leer nada.
3208
+ *
2937
3209
  * Coste: una lectura del catálogo local + un `resolveChain` por OWNER
2938
3210
  * DISTINTO (memoizado) + UNA llamada a `countRoleAssignments` con los
2939
3211
  * uuids de los huérfanos (ninguna si no hay) + un `resolveChain` más por
@@ -2942,6 +3214,12 @@ export class AuthorizationManager {
2942
3214
  * una petición.
2943
3215
  */
2944
3216
  async pruneOrphanRoles(options = {}) {
3217
+ // R2-04: la cota es una entrada del operador y se valida antes de leer nada (L5: entrada inerte o rechazada).
3218
+ const maxLocalRoles = options.maxLocalRoles;
3219
+ if (maxLocalRoles !== undefined && !(Number.isInteger(maxLocalRoles) && maxLocalRoles >= 1)) {
3220
+ throw new AuthorizationConfigError(`pruneOrphanRoles: maxLocalRoles (--max-local-roles) debe ser un entero >= 1 (llegó ${String(maxLocalRoles)}). ` +
3221
+ `No se ha leído nada.`);
3222
+ }
2945
3223
  const force = options.force === true;
2946
3224
  this.#resolver('authz:catalog:prune-orphans');
2947
3225
  const driver = await this.driver();
@@ -2953,7 +3231,7 @@ export class AuthorizationManager {
2953
3231
  if (force)
2954
3232
  await this.#assertNotFrozen('authz:catalog:prune-orphans');
2955
3233
  const resolver = this.#freshResolver();
2956
- const locals = await readLocalRoles({ driver: this.#config.default });
3234
+ const locals = await readLocalRoles({ driver: this.#config.default, maxLocalRoles });
2957
3235
  const resolved = new Map();
2958
3236
  const orphans = [];
2959
3237
  for (const { role, permissions } of locals) {
@@ -2986,15 +3264,33 @@ export class AuthorizationManager {
2986
3264
  }
2987
3265
  const owners = new Set(locals.map(({ role }) => role.owner));
2988
3266
  const orphanOwners = new Set(orphans.map(({ role }) => role.owner));
2989
- const massPurge = orphans.length > 0 && (orphanOwners.size === owners.size || orphans.length * 2 > locals.length);
3267
+ // Los TRES disparadores del seguro (AA2 + R2-02). Cada uno se reporta: el `--dry-run` dice POR QUÉ lo marca.
3268
+ const granting = orphans.filter(({ stillGranting }) => stillGranting !== false);
3269
+ const massPurgeReasons = [];
3270
+ if (orphans.length > 0) {
3271
+ if (orphanOwners.size === owners.size)
3272
+ massPurgeReasons.push('all-owners');
3273
+ if (orphans.length * 2 > locals.length)
3274
+ massPurgeReasons.push('proportion');
3275
+ if (orphanOwners.size > 1 && granting.length > 0)
3276
+ massPurgeReasons.push('granting-across-owners');
3277
+ }
3278
+ const massPurge = massPurgeReasons.length > 0;
2990
3279
  if (!force)
2991
- return { orphans, purged: [], skipped: [], massPurge, dryRun: true };
3280
+ return { orphans, purged: [], skipped: [], massPurge, massPurgeReasons, dryRun: true };
2992
3281
  if (massPurge && options.allowMassPurge !== true) {
3282
+ // Solo CUENTAS: ni uuids ni slugs de owners (la lista ya la ve el operador en el `--dry-run`).
3283
+ const motivos = massPurgeReasons.map((reason) => reason === 'all-owners'
3284
+ ? `todos los owners distintos (${orphanOwners.size} de ${owners.size}) salen huérfanos`
3285
+ : reason === 'proportion'
3286
+ ? `los huérfanos son más del 50 % de los roles locales (${orphans.length} de ${locals.length})`
3287
+ : `los huérfanos son de ${orphanOwners.size} owners distintos y ${granting.length} de ellos TODAVÍA conceden ` +
3288
+ `o no se sabe (stillGranting true/undefined)`);
2993
3289
  throw new MassPurgeRefusedError(`pruneOrphanRoles: ${orphans.length} de ${locals.length} roles locales (${orphanOwners.size} de ${owners.size} ` +
2994
- `owners distintos) tienen el owner fuera del árbol. Esa es la firma de un 'scopes.resolveChain' ciego —filtrado ` +
2995
- `por el tenant de la petición, o sin contexto— que devuelve null para todo: una pasada así borra el catálogo ` +
2996
- `local de todos los tenants. No se ha borrado nada. Comprueba el resolutor y, si la poda es real, repite con ` +
2997
- `allowMassPurge: true (--allow-mass-purge).`);
3290
+ `owners distintos) tienen el owner fuera del árbol, y ${motivos.join('; ')}. Esa es la firma de un ` +
3291
+ `'scopes.resolveChain' ciego —filtrado por el tenant de la petición, con el contexto de UN tenant colado en el ` +
3292
+ `job, o sin contexto—: una pasada así borra el catálogo local, y las asignaciones vivas, de otros tenants. No se ` +
3293
+ `ha borrado nada. Comprueba el resolutor y, si la poda es real, repite con allowMassPurge: true (--allow-mass-purge).`);
2998
3294
  }
2999
3295
  const purged = [];
3000
3296
  const skipped = [];
@@ -3032,7 +3328,7 @@ export class AuthorizationManager {
3032
3328
  purged.push(role);
3033
3329
  await this.#notifyCatalog({ action: 'role_purged', role, owner, permissions });
3034
3330
  }
3035
- return { orphans, purged, skipped, massPurge, dryRun: false };
3331
+ return { orphans, purged, skipped, massPurge, massPurgeReasons, dryRun: false };
3036
3332
  }
3037
3333
  /** El actor de la API de delegación: obligatorio SIEMPRE (sin él no hay policy que evaluar) y bien formado. */
3038
3334
  async #requireActor(actor, operation) {
@@ -3557,12 +3853,13 @@ export class AuthorizationManager {
3557
3853
  // resuelven una vez.
3558
3854
  const view = this.#readResolver ? this : this.forRequest();
3559
3855
  const driver = await view.#reader();
3560
- const listDenies = this.#optional(driver, 'listDenies', 'authorizedScopes');
3561
3856
  const key = _a.#scopeKey;
3562
- const direct = await driver.listScopes(subject, permission);
3857
+ // Los scopes directos que conceden y los denies, en UNA foto (rotación 2 · cierre 3, L12: `#decisionFacts`).
3858
+ const facts = await view.#decisionFacts(driver, subject, { permission }, 'authorizedScopes');
3859
+ const direct = facts.scopes;
3563
3860
  if (direct.length === 0)
3564
3861
  return { kind: 'none' };
3565
- const denied = (await listDenies(subject)).filter((d) => d.permission === permission).map((d) => d.scope);
3862
+ const denied = facts.denies.filter((d) => d.permission === permission).map((d) => d.scope);
3566
3863
  if (direct.some((s) => s.type === APP_SCOPE_TYPE)) {
3567
3864
  if (denied.length > maxScopes) {
3568
3865
  throw new TooManyScopesError(`authorizedScopes: ${denied.length} subárboles excluidos superan maxScopes=${maxScopes}; no se devuelve una lista parcial.`);
@@ -3642,7 +3939,7 @@ export class AuthorizationManager {
3642
3939
  }
3643
3940
  /**
3644
3941
  * Clave LAXA de un scope, solo para agrupar/deduplicar candidatos dentro
3645
- * de una operación (`authorizedScopes`, el anti-ciclo de `#assertEdge`).
3942
+ * de una operación (`authorizedScopes`, el anti-ciclo de `#judgeEdge`).
3646
3943
  * NO es `scopeKey` de `identity.ts` —esa valida la gramática y es la que
3647
3944
  * identifica hechos, owners e ids de binding, y es la que usa
3648
3945
  * `#rolesAlong` (3D · N5)—: aquí los scopes vienen del `descendantsOf` del
@@ -3826,4 +4123,3 @@ export class AuthorizationManager {
3826
4123
  }
3827
4124
  }
3828
4125
  _a = AuthorizationManager;
3829
- //# sourceMappingURL=manager.js.map