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

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 (214) hide show
  1. package/README.md +693 -36
  2. package/build/commands/authz_catalog_diff.js +1 -1
  3. package/build/commands/authz_catalog_diff.js.map +1 -1
  4. package/build/commands/authz_catalog_prune_orphans.d.ts +78 -0
  5. package/build/commands/authz_catalog_prune_orphans.d.ts.map +1 -0
  6. package/build/commands/authz_catalog_prune_orphans.js +136 -0
  7. package/build/commands/authz_catalog_prune_orphans.js.map +1 -0
  8. package/build/commands/authz_catalog_sync.d.ts +17 -0
  9. package/build/commands/authz_catalog_sync.d.ts.map +1 -1
  10. package/build/commands/authz_catalog_sync.js +27 -4
  11. package/build/commands/authz_catalog_sync.js.map +1 -1
  12. package/build/commands/authz_freeze.d.ts +44 -0
  13. package/build/commands/authz_freeze.d.ts.map +1 -0
  14. package/build/commands/authz_freeze.js +95 -0
  15. package/build/commands/authz_freeze.js.map +1 -0
  16. package/build/commands/authz_reconcile.d.ts +102 -0
  17. package/build/commands/authz_reconcile.d.ts.map +1 -0
  18. package/build/commands/authz_reconcile.js +294 -0
  19. package/build/commands/authz_reconcile.js.map +1 -0
  20. package/build/commands/authz_relations_reconcile.d.ts +97 -0
  21. package/build/commands/authz_relations_reconcile.d.ts.map +1 -0
  22. package/build/commands/authz_relations_reconcile.js +313 -0
  23. package/build/commands/authz_relations_reconcile.js.map +1 -0
  24. package/build/commands/authz_scopes_relay.d.ts +47 -0
  25. package/build/commands/authz_scopes_relay.d.ts.map +1 -0
  26. package/build/commands/authz_scopes_relay.js +141 -0
  27. package/build/commands/authz_scopes_relay.js.map +1 -0
  28. package/build/commands/authz_unfreeze.d.ts +37 -0
  29. package/build/commands/authz_unfreeze.d.ts.map +1 -0
  30. package/build/commands/authz_unfreeze.js +92 -0
  31. package/build/commands/authz_unfreeze.js.map +1 -0
  32. package/build/commands/main.d.ts +6 -1
  33. package/build/commands/main.d.ts.map +1 -1
  34. package/build/commands/main.js +6 -1
  35. package/build/commands/main.js.map +1 -1
  36. package/build/commands/openfga_provision.d.ts +46 -4
  37. package/build/commands/openfga_provision.d.ts.map +1 -1
  38. package/build/commands/openfga_provision.js +90 -7
  39. package/build/commands/openfga_provision.js.map +1 -1
  40. package/build/configure.d.ts +11 -0
  41. package/build/configure.d.ts.map +1 -1
  42. package/build/configure.js +37 -1
  43. package/build/configure.js.map +1 -1
  44. package/build/index.d.ts +50 -10
  45. package/build/index.d.ts.map +1 -1
  46. package/build/index.js +45 -6
  47. package/build/index.js.map +1 -1
  48. package/build/providers/authz_provider.d.ts +26 -2
  49. package/build/providers/authz_provider.d.ts.map +1 -1
  50. package/build/providers/authz_provider.js +55 -2
  51. package/build/providers/authz_provider.js.map +1 -1
  52. package/build/services/relations.d.ts +14 -0
  53. package/build/services/relations.d.ts.map +1 -0
  54. package/build/services/relations.js +17 -0
  55. package/build/services/relations.js.map +1 -0
  56. package/build/src/{catalog.d.ts → catalog/catalog.d.ts} +41 -2
  57. package/build/src/catalog/catalog.d.ts.map +1 -0
  58. package/build/src/{catalog.js → catalog/catalog.js} +120 -14
  59. package/build/src/catalog/catalog.js.map +1 -0
  60. package/build/src/{catalog_cache.d.ts → catalog/catalog_cache.d.ts} +46 -22
  61. package/build/src/catalog/catalog_cache.d.ts.map +1 -0
  62. package/build/src/{catalog_cache.js → catalog/catalog_cache.js} +53 -43
  63. package/build/src/catalog/catalog_cache.js.map +1 -0
  64. package/build/src/define_config.d.ts +137 -3
  65. package/build/src/define_config.d.ts.map +1 -1
  66. package/build/src/define_config.js.map +1 -1
  67. package/build/src/drivers/database_driver.d.ts +128 -7
  68. package/build/src/drivers/database_driver.d.ts.map +1 -1
  69. package/build/src/drivers/database_driver.js +510 -24
  70. package/build/src/drivers/database_driver.js.map +1 -1
  71. package/build/src/drivers/database_relations_driver.d.ts +113 -0
  72. package/build/src/drivers/database_relations_driver.d.ts.map +1 -0
  73. package/build/src/drivers/database_relations_driver.js +679 -0
  74. package/build/src/drivers/database_relations_driver.js.map +1 -0
  75. package/build/src/drivers/openfga_driver.d.ts +729 -122
  76. package/build/src/drivers/openfga_driver.d.ts.map +1 -1
  77. package/build/src/drivers/openfga_driver.js +2083 -476
  78. package/build/src/drivers/openfga_driver.js.map +1 -1
  79. package/build/src/drivers/openfga_facts.d.ts +384 -0
  80. package/build/src/drivers/openfga_facts.d.ts.map +1 -0
  81. package/build/src/drivers/openfga_facts.js +836 -0
  82. package/build/src/drivers/openfga_facts.js.map +1 -0
  83. package/build/src/drivers/openfga_relations_driver.d.ts +141 -0
  84. package/build/src/drivers/openfga_relations_driver.d.ts.map +1 -0
  85. package/build/src/drivers/openfga_relations_driver.js +590 -0
  86. package/build/src/drivers/openfga_relations_driver.js.map +1 -0
  87. package/build/src/errors.d.ts +290 -5
  88. package/build/src/errors.d.ts.map +1 -1
  89. package/build/src/errors.js +296 -7
  90. package/build/src/errors.js.map +1 -1
  91. package/build/src/freeze.d.ts +141 -0
  92. package/build/src/freeze.d.ts.map +1 -0
  93. package/build/src/freeze.js +217 -0
  94. package/build/src/freeze.js.map +1 -0
  95. package/build/src/http/app_access_middleware.d.ts.map +1 -0
  96. package/build/src/http/app_access_middleware.js.map +1 -0
  97. package/build/src/http/resource_access_middleware.d.ts +105 -0
  98. package/build/src/http/resource_access_middleware.d.ts.map +1 -0
  99. package/build/src/http/resource_access_middleware.js +81 -0
  100. package/build/src/http/resource_access_middleware.js.map +1 -0
  101. package/build/src/identity.d.ts +74 -1
  102. package/build/src/identity.d.ts.map +1 -1
  103. package/build/src/identity.js +100 -2
  104. package/build/src/identity.js.map +1 -1
  105. package/build/src/manager.d.ts +330 -4
  106. package/build/src/manager.d.ts.map +1 -1
  107. package/build/src/manager.js +1285 -188
  108. package/build/src/manager.js.map +1 -1
  109. package/build/src/models/authz_assignment.d.ts +7 -7
  110. package/build/src/models/authz_assignment.d.ts.map +1 -1
  111. package/build/src/models/authz_deny.d.ts +7 -7
  112. package/build/src/models/authz_deny.d.ts.map +1 -1
  113. package/build/src/models/authz_permission.d.ts +7 -7
  114. package/build/src/models/authz_permission.d.ts.map +1 -1
  115. package/build/src/models/authz_role.d.ts +7 -7
  116. package/build/src/models/authz_role.d.ts.map +1 -1
  117. package/build/src/models/authz_role_permission.d.ts +7 -7
  118. package/build/src/models/authz_role_permission.d.ts.map +1 -1
  119. package/build/src/openfga.d.ts +18 -2
  120. package/build/src/openfga.d.ts.map +1 -1
  121. package/build/src/openfga.js +15 -1
  122. package/build/src/openfga.js.map +1 -1
  123. package/build/src/reconcile.d.ts +37 -0
  124. package/build/src/reconcile.d.ts.map +1 -0
  125. package/build/src/reconcile.js +69 -0
  126. package/build/src/reconcile.js.map +1 -0
  127. package/build/src/relation_partition_trigger.d.ts +8 -0
  128. package/build/src/relation_partition_trigger.d.ts.map +1 -0
  129. package/build/src/relation_partition_trigger.js +93 -0
  130. package/build/src/relation_partition_trigger.js.map +1 -0
  131. package/build/src/relations/define_relations_config.d.ts +75 -0
  132. package/build/src/relations/define_relations_config.d.ts.map +1 -0
  133. package/build/src/relations/define_relations_config.js +173 -0
  134. package/build/src/relations/define_relations_config.js.map +1 -0
  135. package/build/src/relations/manager.d.ts +60 -0
  136. package/build/src/relations/manager.d.ts.map +1 -0
  137. package/build/src/relations/manager.js +213 -0
  138. package/build/src/relations/manager.js.map +1 -0
  139. package/build/src/relations/reconcile.d.ts +111 -0
  140. package/build/src/relations/reconcile.d.ts.map +1 -0
  141. package/build/src/relations/reconcile.js +200 -0
  142. package/build/src/relations/reconcile.js.map +1 -0
  143. package/build/src/relations_config_store.d.ts +22 -0
  144. package/build/src/relations_config_store.d.ts.map +1 -0
  145. package/build/src/relations_config_store.js +74 -0
  146. package/build/src/relations_config_store.js.map +1 -0
  147. package/build/src/scope_outbox.d.ts +69 -0
  148. package/build/src/scope_outbox.d.ts.map +1 -0
  149. package/build/src/scope_outbox.js +298 -0
  150. package/build/src/scope_outbox.js.map +1 -0
  151. package/build/src/{drivers → shared}/backend_guard.d.ts +42 -0
  152. package/build/src/shared/backend_guard.d.ts.map +1 -0
  153. package/build/src/{drivers → shared}/backend_guard.js +78 -1
  154. package/build/src/shared/backend_guard.js.map +1 -0
  155. package/build/src/shared/sql_expiry.d.ts.map +1 -0
  156. package/build/src/shared/sql_expiry.js.map +1 -0
  157. package/build/src/shared/transaction_guard.d.ts +50 -0
  158. package/build/src/shared/transaction_guard.d.ts.map +1 -0
  159. package/build/src/shared/transaction_guard.js +60 -0
  160. package/build/src/shared/transaction_guard.js.map +1 -0
  161. package/build/src/sql_descendants.d.ts +47 -1
  162. package/build/src/sql_descendants.d.ts.map +1 -1
  163. package/build/src/sql_descendants.js +75 -1
  164. package/build/src/sql_descendants.js.map +1 -1
  165. package/build/src/testing/contract.d.ts +129 -4
  166. package/build/src/testing/contract.d.ts.map +1 -1
  167. package/build/src/testing/contract.js +912 -177
  168. package/build/src/testing/contract.js.map +1 -1
  169. package/build/src/testing/main.d.ts +8 -2
  170. package/build/src/testing/main.d.ts.map +1 -1
  171. package/build/src/testing/main.js +4 -1
  172. package/build/src/testing/main.js.map +1 -1
  173. package/build/src/testing/migration_contract.d.ts +284 -0
  174. package/build/src/testing/migration_contract.d.ts.map +1 -0
  175. package/build/src/testing/migration_contract.js +586 -0
  176. package/build/src/testing/migration_contract.js.map +1 -0
  177. package/build/src/testing/relations_contract.d.ts +73 -0
  178. package/build/src/testing/relations_contract.d.ts.map +1 -0
  179. package/build/src/testing/relations_contract.js +1069 -0
  180. package/build/src/testing/relations_contract.js.map +1 -0
  181. package/build/src/testing/relations_reconcile_contract.d.ts +24 -0
  182. package/build/src/testing/relations_reconcile_contract.d.ts.map +1 -0
  183. package/build/src/testing/relations_reconcile_contract.js +220 -0
  184. package/build/src/testing/relations_reconcile_contract.js.map +1 -0
  185. package/build/src/traits/authz_scopes.js +1 -1
  186. package/build/src/traits/authz_scopes.js.map +1 -1
  187. package/build/src/traits/has_uuid.d.ts +8 -8
  188. package/build/src/traits/has_uuid.d.ts.map +1 -1
  189. package/build/src/types.d.ts +1053 -83
  190. package/build/src/types.d.ts.map +1 -1
  191. package/build/src/types.js +10 -0
  192. package/build/src/types.js.map +1 -1
  193. package/build/stubs/config/authorization.stub +104 -4
  194. package/build/stubs/migration.stub +136 -0
  195. package/build/stubs/scopes_outbox_migration.stub +57 -0
  196. package/package.json +4 -2
  197. package/build/commands/openfga_import.d.ts +0 -34
  198. package/build/commands/openfga_import.d.ts.map +0 -1
  199. package/build/commands/openfga_import.js +0 -97
  200. package/build/commands/openfga_import.js.map +0 -1
  201. package/build/src/catalog.d.ts.map +0 -1
  202. package/build/src/catalog.js.map +0 -1
  203. package/build/src/catalog_cache.d.ts.map +0 -1
  204. package/build/src/catalog_cache.js.map +0 -1
  205. package/build/src/drivers/backend_guard.d.ts.map +0 -1
  206. package/build/src/drivers/backend_guard.js.map +0 -1
  207. package/build/src/drivers/sql_expiry.d.ts.map +0 -1
  208. package/build/src/drivers/sql_expiry.js.map +0 -1
  209. package/build/src/middleware/app_access_middleware.d.ts.map +0 -1
  210. package/build/src/middleware/app_access_middleware.js.map +0 -1
  211. /package/build/src/{middleware → http}/app_access_middleware.d.ts +0 -0
  212. /package/build/src/{middleware → http}/app_access_middleware.js +0 -0
  213. /package/build/src/{drivers → shared}/sql_expiry.d.ts +0 -0
  214. /package/build/src/{drivers → shared}/sql_expiry.js +0 -0
@@ -1,27 +1,35 @@
1
1
  import type { ClientBatchCheckItem, ClientBatchCheckSingleResponse } from '@openfga/sdk';
2
- import type { AuthorizationDriver, DenyRef, GrantOptions, GrantOutcome, HolderTypeMap, RoleQuery, ScopeChainResolver, ScopeRef, ScopeType, SubjectRef } from '../types.js';
3
- import { CatalogCache } from '../catalog_cache.js';
4
- import type { CatalogRevalidate, CatalogRoleRef } from '../catalog_cache.js';
2
+ import type { AuthorizationDriver, CatalogProjection, DenyRef, GrantOptions, GrantOutcome, HolderTypeMap, ReconcileFactPage, ReconcileOptions, ReconcileReport, ReconcileSource, RoleQuery, ScopeChainResolver, ScopeOutbox, ScopeRef, ScopeType, SubjectRef, WriteOptions } from '../types.js';
3
+ import { CatalogCache } from '../catalog/catalog_cache.js';
4
+ import type { CatalogRevalidate, CatalogRoleRef } from '../catalog/catalog_cache.js';
5
+ import { assertHolderTypes } from './openfga_facts.js';
6
+ import type { FactsRelationsConfig } from './openfga_facts.js';
5
7
  import type { Clock } from '../clock.js';
6
8
  /**
7
- * Driver `openfga` — los HECHOS (asignaciones y denies) viven en un servidor
8
- * OpenFGA; el CATÁLOGO (roles/permisos/vínculos) y la JERARQUÍA (orgs/units)
9
- * siguen siendo metadata local en las tablas del chasis (split de propiedad
10
- * de datos del análisis: cambiar de driver = migrar una tabla de hechos).
9
+ * Driver `openfga` — **modo `facts` y solo `facts`** (3b-2k · K2). Los
10
+ * HECHOS (asignaciones y denies), el ÁRBOL de scopes y la PROYECCIÓN del
11
+ * catálogo viven en un servidor OpenFGA, que es el PDP: `authorize` es UN
12
+ * solo `Check` y no consulta el árbol del consumidor. El CATÁLOGO sigue
13
+ * siendo propiedad local en las tablas `authz_*` (la proyección es derivada,
14
+ * reconstruible y nunca se lee como catálogo).
11
15
  *
12
- * Modelo FGA genérico (ver `openFgaAuthorizationModel()`):
13
- * - `role_binding:<scopeKey>|<roleUuid>` #assignee asignación de rol
14
- * - `deny_binding:<scopeKey>|<permissionUuid>` #denied deny explícito
15
- * (3A · A1: el id lleva el UUID del catálogo, nunca el slug. Un store
16
- * escrito por 1.x/2.0–2.1 —ids con slug— NO lo lee 2.2: `openfga:import
17
- * --reconcile` lo cuenta como `extra` y `--prune` lo borra. No hay comando
18
- * de migración por decisión del dueño: no había stores en producción.)
16
+ * Modelo (c2r), en `openfga_facts.ts` (`openFgaFactsModel`):
17
+ * - `role:<roleUuid>#permits_<P>@<holder>:*` la proyección del catálogo
18
+ * - `role_binding:<scopeKey>|<roleUuid>` #assigneela asignación
19
+ * (3A · A1: el id lleva el UUID del catálogo, nunca el slug; se parsea
20
+ * desde la derecha), `#role` su rol
21
+ * - `scope:<scopeKey>` con `#parent` (el árbol), `#binding` (dónde es
22
+ * visible el rol), `#denied_<P>` (el deny explícito) y `#rooted` (la
23
+ * alcanzabilidad de la raíz: `can_<P> = (<P> but not denied_<P>) and
24
+ * rooted`, 3b-2i)
19
25
  * - Expiración vía condition `not_expired` (valid_until en la tupla,
20
26
  * current_time en cada check) — ni scheduler necesita.
21
27
  *
22
- * La herencia se resuelve igual que en el driver database: la cadena de
23
- * scopes se calcula localmente (el árbol lo declara el consumidor vía
24
- * `resolveChain`) y se consulta FGA por batchCheck sobre la cadena.
28
+ * **El modo `resolver` ya no existe** (3b-2k · K2, breaking): hasta 2.2 este
29
+ * driver expandía la cadena del consumidor a un `batchCheck` de N×M y
30
+ * guardaba los denies en objetos `deny_binding` propios. Con (c2r) esa rama
31
+ * era código muerto: se borró entera, y con ella `openFgaAuthorizationModel`
32
+ * y `openfga:import` (su sustituto es `authz:reconcile`, 3b-3).
25
33
  *
26
34
  * NADA del dominio está cableado: los holders llegan como `holderTypes`
27
35
  * (morph name → tipo FGA) y los niveles de scope se derivan del propio
@@ -35,18 +43,16 @@ import type { Clock } from '../clock.js';
35
43
  */
36
44
  export type { HolderTypeMap };
37
45
  /**
38
- * `holderTypes` tiene que ser INYECTIVO. Si dos morph names caen en el mismo
39
- * tipo FGA, para el store son un solo holder: un grant a `users:U` autoriza a
40
- * `integrations:U`, `listSubjects` devuelve el morph equivocado y un revoke
41
- * borra al otro (invariante 4, L0.2). El generador del modelo lo "sabía"
42
- * (deduplicaba con un Set) y publicaba sin quejarse: ahora lanza aquí, al
43
- * construir el driver y al generar el modelo, antes de tocar nada.
46
+ * La inyectividad de `holderTypes` la comprueba el módulo del modelo
47
+ * (`openfga_facts.ts`, compartido por los dos generadores). Se re-exporta
48
+ * desde aquí porque el subpath `/openfga` es la puerta publicada.
44
49
  */
45
- export declare function assertHolderTypes(holderTypes: HolderTypeMap): void;
50
+ export { assertHolderTypes };
46
51
  /**
47
52
  * Id de binding (`<scopeKey>|<uuid>`: `app|<uuid>` o `<tipo>|<uuidScope>|<uuid>`)
48
- * → scope + uuid del catálogo (del rol en `role_binding`, del permiso en
49
- * `deny_binding`). Se parsea DESDE LA DERECHA (3A · A1): el último componente
53
+ * → scope + uuid del ROL (3b-2k · K2: el `deny_binding`, que era el otro
54
+ * consumidor de esta gramática, se fue con el modo `resolver`; el deny es hoy
55
+ * una relación del scope). Se parsea DESDE LA DERECHA (3A · A1): el último componente
50
56
  * es el uuid y el resto la clave del scope, que tiene 1 parte (`app`) o 2
51
57
  * (`<tipo>|<uuid>`). Antes el último componente era el slug codificado
52
58
  * (`docs~read`) y el parseo contaba partes: ambiguo en cuanto la clave del
@@ -57,7 +63,7 @@ export declare function assertHolderTypes(holderTypes: HolderTypeMap): void;
57
63
  * gramática —el scope, la de identidad; el uuid, la de UUID canónico del
58
64
  * catálogo—: un id que el driver no escribiría no es un hecho del motor
59
65
  * aunque esté en el store. Los ids de 1.x/2.0–2.1 (con slug) caen aquí:
60
- * 2.2 no los lee, y `openfga:import --reconcile` los cuenta como `extra`.
66
+ * 2.2 no los lee, y `authz:reconcile` (3b-3) los reportará como deriva.
61
67
  * Exportada para probarla sin servidor.
62
68
  */
63
69
  export declare function parseBindingId(id: string): {
@@ -74,18 +80,30 @@ export declare function parseBindingId(id: string): {
74
80
  */
75
81
  export declare function correlateBatchResults(checks: ClientBatchCheckItem[], results: ClientBatchCheckSingleResponse[]): ClientBatchCheckSingleResponse[];
76
82
  /**
77
- * El authorization model en formato JSON del API de FGA, generado a partir
78
- * de los holders del consumidor. El mismo mapa debe usarse al construir el
79
- * driver: si difieren, los checks no encuentran las tuplas.
80
- */
81
- export declare function openFgaAuthorizationModel(holderTypeMap: HolderTypeMap): any;
82
- /**
83
- * Crea un store nuevo + escribe el authorization model derivado de los
84
- * holders del consumidor. Para bootstrap de un appliance o del harness de
85
- * tests. El `name` lo decide el caller (el comando openfga:provision
86
- * resuelve APP_NAME del entorno el motor no lee env).
83
+ * Crea un store nuevo + escribe el authorization model **`facts` (c2r)**
84
+ * derivado de los holders y de los PERMISOS del consumidor (3b-2k · K2:
85
+ * antes escribía el modelo del modo `resolver`, que ya no existe). Para
86
+ * bootstrap de un appliance o del harness de tests. El `name` lo decide el
87
+ * caller (el comando `openfga:provision` resuelve APP_NAME del entorno — el
88
+ * motor no lee env).
89
+ *
90
+ * Los permisos entran aquí porque el modelo (c2r) declara CUATRO relaciones
91
+ * por permiso: un store provisionado sin ellos no puede responder a ninguna
92
+ * pregunta. `assertFactsModelPublishable` comprueba antes las cotas (nombre
93
+ * de relación y techo de 262.144 bytes ⇒ 500 `E_AUTHZ_MODEL_TOO_LARGE`).
94
+ * Añadir un permiso al catálogo obliga a republicar el modelo: es lo que
95
+ * hace `syncAuthzCatalog` con la proyección inyectada, y por eso el modelo
96
+ * versionado del store se escribe con `--store-id`.
97
+ *
98
+ * **Y los tipos de RELACIÓN (ReBAC) van FUSIONADOS** (Fase 4-8): si el
99
+ * consumidor declaró `relations.config`, `openfga:provision` los pasa aquí y
100
+ * el modelo publicado los incluye, de modo que un store recién aprovisionado
101
+ * ya acepta tuplas de relación (`document#viewer`…) SIN un
102
+ * `authz:catalog:sync` previo. Sin ellos el modelo es facts-only y una tupla
103
+ * de relación es un `validation_error` del servidor («type 'document' not
104
+ * found»). El gate de bytes mide el modelo FUSIONADO.
87
105
  */
88
- export declare function provisionOpenFgaStore(apiUrl: string, name: string, holderTypeMap: HolderTypeMap): Promise<{
106
+ export declare function provisionOpenFgaStore(apiUrl: string, name: string, holderTypeMap: HolderTypeMap, permissions: readonly string[], relations?: FactsRelationsConfig): Promise<{
89
107
  storeId: string;
90
108
  modelId: string;
91
109
  }>;
@@ -96,8 +114,15 @@ export declare function provisionOpenFgaStore(apiUrl: string, name: string, hold
96
114
  * "alguien escribió antes" y se propagan clasificados, con el error del SDK
97
115
  * como causa (D6). Verificado contra OpenFGA v1.19: el duplicado llega como
98
116
  * HTTP 400 con `apiErrorCode: 'write_failed_due_to_invalid_input'` y el
99
- * mensaje "cannot write a tuple which already exists"; un 409 se acepta por
100
- * si una versión del servidor lo devuelve así.
117
+ * mensaje "cannot write a tuple which already exists".
118
+ *
119
+ * **Y el 409 tiene nombre propio** (3b-2f · R3, medido contra el servidor):
120
+ * es el `Aborted` de un `Write` transaccional cuyas tuplas escribió otra
121
+ * transacción a la vez ("transactional write failed due to conflict: one or
122
+ * more tuples to write were inserted by another transaction"). Dice lo mismo
123
+ * —otro escritor llegó antes— y se trata igual: releer y re-aplicar. Lo que
124
+ * NO dice es QUÉ tupla chocó, así que quién existía lo decide la relectura y
125
+ * nunca el mensaje.
101
126
  */
102
127
  export declare function isDuplicateWrite(error: unknown): boolean;
103
128
  export interface OpenFgaDriverOptions {
@@ -169,7 +194,44 @@ export interface OpenFgaDriverOptions {
169
194
  * producción lo normal es `clock` en el config del manager (`withClock`).
170
195
  */
171
196
  now?: Clock;
197
+ /**
198
+ * La outbox del árbol del config (`scopes.outbox`), la MISMA instancia
199
+ * (3b-2d). El driver no la usa para nada: quien encola es el manager. Está
200
+ * aquí como EVIDENCIA del gate — un driver `facts` que se construye sin
201
+ * ella y sin `acceptScopeDriftRisk` es un montaje en el que un `rollback`
202
+ * del consumidor deja una escalada persistente e invisible (cruce 4 · S5),
203
+ * y eso se descubre al construir, no en la primera escritura de un tenant.
204
+ */
205
+ outbox?: ScopeOutbox;
206
+ /**
207
+ * «Sé que sin outbox un rollback de mi transacción deja el árbol de FGA
208
+ * adelantado al mío, y lo asumo». Es la salida explícita del gate para
209
+ * quien mueve el árbol solo desde la plataforma, en un proceso que no
210
+ * comparte transacción con nada. Tiene que ser el booleano `true`: un
211
+ * valor «truthy» no es una aceptación.
212
+ */
213
+ acceptScopeDriftRisk?: boolean;
172
214
  }
215
+ /**
216
+ * **El gate de construcción de `facts`** (3b-2d, panel 2 cruce 4 · S5).
217
+ *
218
+ * En `hierarchy: 'facts'` el árbol vive en el store de FGA y FGA es el PDP.
219
+ * El consumidor notifica `scopes.moved` dentro de su transacción, el paquete
220
+ * escribe la arista en FGA… y si esa transacción hace `rollback` —una
221
+ * constraint, una validación, un timeout de pool: no hace falta un crash—
222
+ * la escritura de FGA NO se deshace. SQL sigue diciendo que la unit es del
223
+ * tenant A y FGA que es del B: todos los holders con rol en B tienen acceso
224
+ * a una unidad de A, y la aplicación, que lista y audita contra SQL, no
225
+ * puede verlo. La ventana no es un hueco entre dos operaciones: dura hasta
226
+ * que alguien lo descubra.
227
+ *
228
+ * Por eso `scopes.outbox` no puede ser una recomendación: una recomendación
229
+ * no es un mecanismo. O está el puerto, o está la firma del dueño.
230
+ */
231
+ export declare function assertScopeDriftGuarded(options: {
232
+ outbox?: ScopeOutbox;
233
+ acceptScopeDriftRisk?: boolean;
234
+ }): void;
173
235
  export declare const DEFAULT_TIMEOUT_MS = 5000;
174
236
  /**
175
237
  * Cota de páginas de una enumeración (1.000.000 de tuplas a 100 por página).
@@ -178,68 +240,37 @@ export declare const DEFAULT_TIMEOUT_MS = 5000;
178
240
  * el deadline es por llamada (D12, auditor H7).
179
241
  */
180
242
  export declare const MAX_READ_PAGES = 10000;
181
- export interface ImportFactsResult {
182
- /** Tuplas nuevas escritas. */
183
- written: number;
184
- /** Tuplas que existían con OTRA condición y se reescribieron (delete+write). */
185
- updated: number;
186
- /** Tuplas que ya estaban exactamente igual. */
187
- unchanged: number;
188
- /**
189
- * Tuplas `role_binding`/`deny_binding` del store SIN correspondencia en SQL
190
- * (un grant revocado en SQL, un holder que nunca estuvo, una asignación ya
191
- * expirada). Solo se cuentan con `reconcile` (D14); sin `prune` siguen
192
- * concediendo y el reporte lo dice.
193
- */
194
- extra: number;
195
- /** De las `extra`, las borradas (`prune`). En `dryRun`, las que se borrarían. */
196
- deleted: number;
197
- /** Asignaciones ya expiradas en SQL, no se copian. */
198
- skippedExpired: number;
199
- dryRun: boolean;
200
- }
201
- export interface ImportFactsOptions {
202
- dryRun?: boolean;
203
- /**
204
- * Permite importar sobre un store CON tuplas: por cada hecho se lee la
205
- * tupla exacta; ausente ⇒ write, presente con otra condición ⇒ delete+write
206
- * (`updated`), igual ⇒ `unchanged`. Además se lee el store ENTERO
207
- * (`Read({})` paginado) y lo que SQL no tiene se cuenta como `extra` (D14).
208
- * Sin esto, un store no vacío es 409 `E_AUTHZ_STORE_NOT_EMPTY`.
209
- */
210
- reconcile?: boolean;
211
- /**
212
- * Con `reconcile`: borra las tuplas `extra` (`deleted`). Es lo que hace que
213
- * el reconcile CONVERJA: sin prune, un reporte de ceros no distingue "en
214
- * sync" de "sobra algo que sigue concediendo". Sin `reconcile` es 500
215
- * `E_AUTHZ_CONFIG`.
216
- */
217
- prune?: boolean;
218
- /** Reloj con el que se decide qué asignación de SQL ya expiró (`skippedExpired`). Default: la hora del proceso. */
219
- now?: Clock;
220
- }
221
243
  /**
222
- * Migración de hechos database openfga: copia las asignaciones vigentes y
223
- * los denies de las tablas `authz_*` como tuples del store FGA.
224
- *
225
- * - COPIA, no mueve: las tablas locales quedan intactas el rollback es
226
- * volver a AUTHZ_DRIVER=database (solo se pierde lo escrito mientras se
227
- * operó con openfga). El catálogo y la jerarquía nunca migran: son
228
- * metadata local para ambos drivers.
229
- * - Las asignaciones ya expiradas se saltan (no tiene sentido copiarlas);
230
- * las de expiración futura viajan con la condition `not_expired`.
231
- * - NUNCA `onDuplicateWrites: Ignore` (S7): en FGA la condición no es parte
232
- * de la clave, así que "ignorar el duplicado" dejaba la caducidad vieja y
233
- * reportaba éxito. Un store con tuplas exige `reconcile`, que compara
234
- * tupla a tupla, reescribe las que difieren y cuenta las que SQL no tiene
235
- * (`extra`); con `prune` las borra (`deleted`) y el reconcile converge
236
- * (D14). Nunca silencioso: el reporte distingue written / updated /
237
- * unchanged / extra / deleted / skippedExpired.
238
- *
239
- * Herramienta explícitamente de OpenFGA: los errores del SDK salen crudos.
244
+ * Tope de saltos al subir el árbol DEL STORE (3b-2e · E1). No es el techo de
245
+ * decisión —ese lo pone el servidor al evaluar (c2) y está medido en
246
+ * `FACTS_MAX_RESOLVE_DEPTH`—: es la red del recorrido, para que un árbol con
247
+ * una deriva que el anti-ciclos no vio no deje el proceso dando vueltas.
240
248
  */
241
- export declare function importAuthzFactsToOpenFga(options: OpenFgaDriverOptions & ImportFactsOptions): Promise<ImportFactsResult>;
249
+ export declare const MAX_SCOPE_CHAIN_HOPS = 1000;
242
250
  export declare class OpenFgaAuthorizationDriver implements AuthorizationDriver {
251
+ /**
252
+ * Lo que este driver declara (3b-2e · E2). Depende del MODO, así que es un
253
+ * getter y no un campo: una vista por prototipo (`withChainResolver`,
254
+ * `withClock`) declara lo mismo que su original.
255
+ *
256
+ * `roleInheritanceNative` y `listObjectsInherited` son `false` **también en
257
+ * `facts`**, y eso es el cruce 6 del panel: `hasRole`/`listRoles`/
258
+ * `listRoleScopes`/`listSubjects`/`listScopes` siguen usando `resolveChain`
259
+ * (en (c2) no hay alternativa, y está medido), y los `list*` enumeran con
260
+ * `Read` paginado, nunca con `ListObjects` (que trunca al tope del servidor
261
+ * sin señal). Lo único que `facts` cambia es `authorize`.
262
+ */
263
+ get capabilities(): Readonly<{
264
+ hierarchyFacts: true;
265
+ singleCheckAuthorize: true;
266
+ roleInheritanceNative: false;
267
+ listObjectsInherited: false;
268
+ purgeRole: true;
269
+ countRoleAssignments: true;
270
+ canonicalScopeReads: false;
271
+ enumerateFacts: true;
272
+ transactionalWrites: false;
273
+ }>;
243
274
  private client;
244
275
  private chainResolver;
245
276
  private holderTypes;
@@ -277,8 +308,10 @@ export declare class OpenFgaAuthorizationDriver implements AuthorizationDriver {
277
308
  private chain;
278
309
  /** La cadena o 422: una escritura no puede ir a un scope que nadie reconoce. */
279
310
  private knownScope;
280
- /** El scope canónico para `revoke`/`removeDeny`/`purgeScope` (ver `canonicalScope`). */
311
+ /** El scope canónico para `scopes.detached`/`purgeScope` (ver `canonicalScope`). */
281
312
  private canonicalOrSelf;
313
+ /** Los destinos de un delete de hechos (`revoke`/`removeDeny`): canónico, o el fan-out de alias (3b-8 · A4). */
314
+ private canonicalTargets;
282
315
  /**
283
316
  * Vista de este driver con OTRO resolutor de ancestros y el mismo estado
284
317
  * (cliente, memo del catálogo, deadline, diagnósticos). Es lo que usa
@@ -326,14 +359,30 @@ export declare class OpenFgaAuthorizationDriver implements AuthorizationDriver {
326
359
  * la cadena del scope del BINDING (desde él hacia la raíz).
327
360
  */
328
361
  private declaredRole;
362
+ authorize(subject: SubjectRef, permission: string, scope: ScopeRef): Promise<boolean>;
329
363
  /**
330
- * Los checks de UNA pregunta (subject, permiso, cadena): los denies de cada
331
- * nivel y los roles del catálogo que conceden el permiso en cada nivel.
332
- * Sin rol que conceda ⇒ `null`: la respuesta es `false` digan lo que digan
333
- * los denies y no se pregunta al backend (2A).
364
+ * **`authorize` del modo `facts` (3b-2c): UN solo `Check`.**
365
+ *
366
+ * `can_<P>` sobre `scope:<key>` con el subject como user. El modelo (c2)
367
+ * ya lleva dentro las tres cosas que el modo `resolver` compone aquí:
368
+ * la herencia hacia abajo (`<P> from parent`), el deny explícito heredado
369
+ * (`denied_<P> from parent`) y la resta que hace ganar al deny
370
+ * (`can_<P> = <P> but not denied_<P>`). No hay `batchCheck`, no se expande
371
+ * la cadena y **no se llama al resolutor del consumidor** (cruce 6 del
372
+ * panel 2, que es también el literal que el README puede prometer).
373
+ *
374
+ * Lo único local que queda es el MEMO del catálogo, y es OBLIGATORIO: lo
375
+ * comprueba el llamante antes de llegar aquí. Sin esa guardia un permiso
376
+ * desconocido sería un `Check` de una relación que el modelo no declara —
377
+ * un 400 del servidor que saldría como 503— en vez del `false` que exige el
378
+ * invariante 5.
379
+ *
380
+ * Un scope que el árbol del consumidor no conoce no tiene tuplas en el
381
+ * store: responde `false` sin preguntar por él (invariante 9), pero aquí
382
+ * eso lo decide el propio store, no `resolveChain`. Cualquier fallo del
383
+ * backend sale como 503 desde el cliente envuelto; jamás un `false` mudo.
334
384
  */
335
- private checksFor;
336
- authorize(subject: SubjectRef, permission: string, scope: ScopeRef): Promise<boolean>;
385
+ private factsAuthorize;
337
386
  /**
338
387
  * `authorize` sobre N scopes con UN batchCheck (2.1, B6): los checks de
339
388
  * todas las cadenas viajan juntos (el SDK trocea a 50 y paraleliza) y se
@@ -343,13 +392,46 @@ export declare class OpenFgaAuthorizationDriver implements AuthorizationDriver {
343
392
  * Scope desconocido o sin rol que conceda ⇒ false sin checks.
344
393
  */
345
394
  authorizeMany(subject: SubjectRef, permission: string, scopes: ScopeRef[]): Promise<boolean[]>;
395
+ /**
396
+ * `authorizeMany` del modo `facts` (3b-2c): **UN `batchCheck` de N items**,
397
+ * uno por scope DISTINTO. En el modo `resolver` cada scope aporta los
398
+ * denies de su cadena más un check por (nivel, rol que concede): el lote
399
+ * crecía como N×M. Aquí cada scope es exactamente una pregunta,
400
+ * `can_<P>@scope:<key>`, y un scope repetido comparte item y respuesta
401
+ * (G2, CR9) en vez de duplicar el lote.
402
+ *
403
+ * Un `error` en cualquier check sigue siendo 503 entero (invariante 5, D1):
404
+ * lo lanza `batchCheckAll` antes de mirar un solo `allowed`.
405
+ */
406
+ private factsAuthorizeMany;
407
+ /**
408
+ * **`{ transaction }` se rechaza también AQUÍ** (L-5, defensa en
409
+ * profundidad como F-05 en L-0): la puerta 1 vive en el manager, pero
410
+ * `manager.driver()` es la salida documentada de las barreras y por ahí un
411
+ * `{ transaction }` llegaría al driver; sin esta guarda el driver
412
+ * escribiría la tupla en el store IGNORANDO la transacción — una escritura
413
+ * que finge ir en tu transacción y no se deshace con tu rollback, que es
414
+ * exactamente el fail-open que `transactionalWrites: false` declara no
415
+ * poder evitar. Primera línea de las cuatro escrituras, antes de la
416
+ * identidad, del catálogo y del store: CERO llamadas al cliente.
417
+ */
418
+ private rejectTransaction;
346
419
  grant(subject: SubjectRef, role: RoleQuery, scope: ScopeRef, options?: GrantOptions): Promise<GrantOutcome>;
347
420
  /**
348
- * Write directo; si la key ya existía (409), camino largo. Devuelve si
349
- * existía. Cualquier otro fallo se propaga tal cual (ya clasificado).
421
+ * Write directo de la asignación CON su estructura (3b-2f · R3); si algo ya
422
+ * estaba, camino largo. Devuelve si existía la ASIGNACIÓN —lo dice la
423
+ * relectura, no el error: el choque puede ser de las aristas, que en (c2)
424
+ * las comparten todos los holders del mismo rol en el mismo scope—.
425
+ * Cualquier otro fallo se propaga tal cual (ya clasificado).
350
426
  */
351
427
  private writeAssignment;
352
- /** delete + write (dos llamadas: FGA no admite ambas sobre la misma key en una). */
428
+ /**
429
+ * delete + write (dos llamadas: FGA no admite ambas sobre la misma key en
430
+ * una). El write repone la ESTRUCTURA junto a la asignación: si un
431
+ * `purgeScope` concurrente se llevó la arista `scope#binding` entre medias,
432
+ * lo que queda vuelve a ser coherente en vez de una asignación inerte que
433
+ * `listRoles` ve y `authorize` no (3b-2f · R3).
434
+ */
353
435
  private replaceAssignment;
354
436
  /**
355
437
  * Estado actual de una asignación, con TRES resultados posibles y no dos.
@@ -361,10 +443,24 @@ export declare class OpenFgaAuthorizationDriver implements AuthorizationDriver {
361
443
  * que quien no pueda seguir sin él lo propague.
362
444
  */
363
445
  private readAssignment;
364
- revoke(subject: SubjectRef, role: RoleQuery, scope: ScopeRef): Promise<void>;
446
+ revoke(subject: SubjectRef, role: RoleQuery, scope: ScopeRef, options?: WriteOptions): Promise<void>;
365
447
  hasRole(subject: SubjectRef, role: RoleQuery, scope: ScopeRef): Promise<boolean>;
366
- deny(subject: SubjectRef, permission: string, scope: ScopeRef): Promise<void>;
367
- removeDeny(subject: SubjectRef, permission: string, scope: ScopeRef): Promise<void>;
448
+ deny(subject: SubjectRef, permission: string, scope: ScopeRef, options?: WriteOptions): Promise<void>;
449
+ removeDeny(subject: SubjectRef, permission: string, scope: ScopeRef, options?: WriteOptions): Promise<void>;
450
+ /**
451
+ * El hecho de un deny (3b-2c): una relación DEL SCOPE,
452
+ * `scope:<key>#denied_<P>@<holder>`. Así el modelo lo hereda hacia abajo
453
+ * por `parent` y `can_<P>` puede restarlo dentro del mismo `Check`
454
+ * (invariante 2) sin que el paquete pasee la cadena. Hasta 3b-2k · K2 el
455
+ * modo `resolver` lo guardaba en un objeto propio
456
+ * (`deny_binding:<scopeKey>|<permissionUuid>`) y el paquete expandía la
457
+ * cadena a un check por nivel; ese tipo se borró con el modo.
458
+ *
459
+ * La relación lleva el SLUG proyectado (no el uuid) porque el modelo la
460
+ * declara por nombre. El slug que llega aquí es el del catálogo: el
461
+ * llamante ya pasó por `findPermission`, que es quien decide qué existe.
462
+ */
463
+ private denyTuple;
368
464
  /**
369
465
  * Holders con asignación vigente del rol en el scope exacto: `Read` por
370
466
  * objeto exacto, paginado, con la caducidad filtrada en cliente. Antes era
@@ -378,6 +474,17 @@ export declare class OpenFgaAuthorizationDriver implements AuthorizationDriver {
378
474
  private listBindings;
379
475
  /** Scopes (por clave) donde el subject tiene un deny directo del permiso (por su uuid). */
380
476
  private deniedScopeKeys;
477
+ /**
478
+ * Los denies DIRECTOS del holder en el modo `facts`, ya traducidos a
479
+ * `(scope, permiso)`. No hay `deny_binding` que enumerar: se leen de una
480
+ * pasada las tuplas del holder sobre objetos `scope:` y se quedan las de la
481
+ * familia `denied_<P>`. La vuelta de relación a slug la da el CATÁLOGO —una
482
+ * relación que ya no declara ningún permiso no es un deny (D5), igual que
483
+ * un `deny_binding` de un permiso retirado—, y una clave de scope que el
484
+ * motor no entiende se cuenta y se registra, nunca se descarta en silencio
485
+ * (L0.16).
486
+ */
487
+ private factsDenies;
381
488
  private parseBindings;
382
489
  private warn;
383
490
  listRoles(subject: SubjectRef, scope: ScopeRef): Promise<string[]>;
@@ -394,23 +501,523 @@ export declare class OpenFgaAuthorizationDriver implements AuthorizationDriver {
394
501
  listRoleScopes(subject: SubjectRef, scopeType: ScopeType): Promise<ScopeRef[]>;
395
502
  listScopes(subject: SubjectRef, permission: string): Promise<ScopeRef[]>;
396
503
  /**
397
- * Denies directos del holder (2.1, B5): `Read` paginado de sus
398
- * `deny_binding` (nunca ListObjects, L0.7), filtrados por el catálogo (un
399
- * permiso retirado no es un deny, D5), por scope exacto si se pide, y por
400
- * scopes que el árbol conoce (D8).
504
+ * Denies directos del holder (2.1, B5): `Read` paginado de sus relaciones
505
+ * `denied_<P>` sobre objetos `scope:` (nunca ListObjects, L0.7), filtrados
506
+ * por el catálogo (un permiso retirado no es un deny, D5), por scope exacto
507
+ * si se pide, y por scopes que el árbol conoce (D8).
401
508
  */
402
509
  listDenies(subject: SubjectRef, scope?: ScopeRef): Promise<DenyRef[]>;
510
+ /**
511
+ * Un nodo tiene como mucho UN padre. El paquete nunca escribe dos (cada
512
+ * `moved` sustituye la arista entera dentro de un `Write`), así que dos
513
+ * padres son DERIVA: alguien más escribe en el store, y mientras tanto la
514
+ * herencia está trayendo hechos de dos ramas. Se lanza; no se "arregla",
515
+ * porque elegir cuál sobrevive sería adivinar cuál de las dos concesiones
516
+ * vivas es la buena (cruce 8: «si devuelve >1 ⇒ drift ⇒ lanza»).
517
+ */
518
+ private assertOneParent;
519
+ /**
520
+ * El consumidor colgó un scope nuevo (o recolgó uno que ya existía: un
521
+ * `attach` sobre un nodo conocido ES un move). En modo `facts` eso es UNA
522
+ * tupla `scope:<hijo>#parent@scope:<padre>`; en modo `resolver` no es nada
523
+ * (la jerarquía la resuelve el paquete en cada pregunta).
524
+ */
525
+ onScopeAttached(child: ScopeRef, parent: ScopeRef): Promise<void>;
526
+ /**
527
+ * El consumidor movió un scope. Procedimiento fijado en el cruce 8 del
528
+ * panel 2: un `Read` del padre actual —obligatorio, porque FGA rechaza
529
+ * borrar una tupla inexistente—, la cadena del padre NUEVO en el store
530
+ * (3b-8 · A5: el anti-ciclos del consumidor no ve un store
531
+ * desincronizado) y **UN solo `Write`** con el delete del padre viejo y el
532
+ * write del nuevo, que es atómico dentro de la request. O(profundidad)
533
+ * requests, UNA mutación.
534
+ */
535
+ onScopeMoved(child: ScopeRef, newParent: ScopeRef): Promise<void>;
536
+ /**
537
+ * **Anti-ciclos, en el PAQUETE y antes de escribir** (cruce 3 del panel 2,
538
+ * bloqueante S2). Medido contra OpenFGA v1.19: el servidor ACEPTA una
539
+ * arista que cierra un ciclo, no se cuelga, responde en 2-7 ms y la
540
+ * herencia se vuelve bidireccional —un grant en un descendiente concede en
541
+ * el ancestro, y con la raíz dentro del ciclo concede en todo el store—.
542
+ * Fail-open mudo: no hay nada que capturar. La suite lo reproduce contra el
543
+ * `:8101` para que nadie proponga delegar esto en el backend.
544
+ *
545
+ * Las tres validaciones del cruce 8, en orden y sin escribir nada si
546
+ * fallan: (i) la raíz no cuelga de nadie; (ii) el padre EXISTE según el
547
+ * árbol del consumidor; (iii) el hijo no es ancestro-o-igual del padre.
548
+ * Las mismas que hace `AuthorizationManager.#assertEdge`: aquí se repiten
549
+ * por defensa en profundidad, porque `manager.driver()` es la salida
550
+ * documentada de todas las barreras del paquete.
551
+ *
552
+ * Devuelve las dos claves CANÓNICAS (invariante 17): un alias del uuid ni
553
+ * evade la comprobación de ciclo ni abre una segunda rama en el store.
554
+ */
555
+ private assertEdge;
556
+ /**
557
+ * El consumidor sacó un scope del árbol. En modo `facts` se borra su
558
+ * arista `#parent` — y **la arista es lo ÚLTIMO** (S6, cruce 9): el
559
+ * manager llama primero a `purgeScope`, que borra los hechos del scope y
560
+ * DEMUESTRA cero o lanza (invariante 11).
561
+ *
562
+ * **El motivo del orden cambió con (c2r) y el orden NO** (3b-2i). La razón
563
+ * que se escribió en 3b-2b —«un scope sin ancestro dejaría de heredar los
564
+ * denies del padre y sus permisos serían INDENEGABLES»— ya no es cierta:
565
+ * ése era exactamente el 🔴 1 del auditor R2 y hoy un scope que no alcanza
566
+ * `app` no concede nada (`can_<P>` exige `rooted`). Lo que sigue justificando
567
+ * el orden es lo otro: una purga que muere a medias tiene que dejar denies
568
+ * de MÁS, nunca de menos, y borrar la arista antes convertiría el fallo en
569
+ * «se quedaron hechos vivos en un nodo que ya nadie purga» (los recoge
570
+ * `authz:reconcile`, pero mientras tanto el nodo es invisible para el
571
+ * árbol). Con la arista al final, una purga fallida se reintenta.
572
+ *
573
+ * No se tocan las aristas de los HIJOS (`scope:<hijo>#parent@scope:<este>`):
574
+ * el consumidor notifica un `detached` por nodo, o un `moved` para
575
+ * recolgarlos — y **desde (c2r) esos hijos, mientras tanto, DENIEGAN** (su
576
+ * cadena ya no llega a la raíz) en vez de conceder de más. Lo que quede sin
577
+ * nodo arriba lo ve `authz:reconcile` (3b-3).
578
+ */
579
+ onScopeDetached(child: ScopeRef): Promise<void>;
580
+ /**
581
+ * Escribe la arista del árbol dejando UNA sola: se lee la que hay y se
582
+ * sustituye en el mismo `Write`. Sin diferencia no se llama al servidor
583
+ * (invariante 6: re-anexar al mismo padre es un no-op seguro, y además
584
+ * escribir una tupla que ya está sería un conflicto con los defaults
585
+ * estrictos del SDK).
586
+ *
587
+ * **Y el choque con otro escritor del árbol no es una caída** (3b-2h ·
588
+ * 🟠 4, invariante 6). Medido contra el `:8101`: dos `attached` del mismo
589
+ * nodo al mismo padre a la vez —lo que hacen dos pasadas del relay sobre el
590
+ * mismo lote, porque `pending()` no reserva nada— y el perdedor se llevaba
591
+ * un **503 «el backend no respondió»**, cuando el backend respondió
592
+ * perfectamente («cannot write a tuple which already exists»). Aquí se hace
593
+ * lo que el invariante 6 manda desde `grant`: releer y re-aplicar sobre lo
594
+ * que quedó —así el re-intento ve la arista del otro y sale por el no-op—,
595
+ * y una contención que no cede en `TREE_WRITE_ATTEMPTS` vueltas sale como
596
+ * 409 `E_AUTHZ_WRITE_CONFLICT`, nunca como un 503.
597
+ *
598
+ * Esto NO convierte dos escritores en uno: dos `attached` del mismo nodo a
599
+ * padres DISTINTOS siguen pudiendo dejar dos aristas (FGA no tiene
600
+ * compare-and-set y el `Read` de arriba es un check-then-write). Lo que
601
+ * impide esa carrera es el ESCRITOR ÚNICO del relay (`ScopeOutbox.acquire`).
602
+ */
603
+ private reparent;
604
+ /**
605
+ * **El barrido del rol local** (3b-2e · E1; decisión del dueño del
606
+ * 2026-08-30, opción 1).
607
+ *
608
+ * En (c2) el modelo no tiene `owner`, así que `authorize` NO vuelve a
609
+ * decidir con el árbol de hoy si un rol LOCAL sigue siendo visible: un
610
+ * `role_binding` concede mientras su scope alcance al que pregunta. Sin
611
+ * esto, mover una unit fuera de la organización dueña de un rol local
612
+ * dejaría de retirar lo concedido (el invariante 18 en `database`), que es
613
+ * un **fail-open** — y encima uno que solo se ve comparando drivers.
614
+ *
615
+ * Lo que se toca es la arista `scope#binding`, que es lo que hace
616
+ * ALCANZABLE la asignación: se BORRA donde el owner del rol ya no está en
617
+ * la cadena y se REESCRIBE donde vuelve a estarlo (invariante 18: volver la
618
+ * unit a su sitio restaura). No se toca el `assignee` —el hecho de la
619
+ * asignación no cambia porque el árbol se mueva, igual que en `database`—
620
+ * ni nada de un rol GLOBAL, cuya visibilidad no depende del árbol.
621
+ *
622
+ * **Por subárbol, no por nodo** (consecuencia 2): los descendientes del
623
+ * nodo movido también cambian de cadena.
624
+ *
625
+ * Coste: si el catálogo no tiene NI UN rol local —el caso de todo consumidor
626
+ * que no usa delegación— son **cero** requests y `moved` sigue siendo el
627
+ * `Read` + `Write` del cruce 8. Con roles locales: una lectura por rol local
628
+ * (sus bindings, por `role_binding#role`), la bajada del subárbol y un
629
+ * `Write` por lote.
630
+ */
631
+ private sweepLocalRoleBindings;
632
+ /**
633
+ * **El barrido por NIVEL** (3b-2g · R1; decisión del dueño del 2026-08-30
634
+ * (2), mismo mecanismo que E1).
635
+ *
636
+ * El modelo (c2) tampoco lleva el NIVEL (`scope_type`) del rol: la
637
+ * proyección dice qué permisos vincula, no en qué nivel se declara. Sin
638
+ * esto, cambiar el `scope_type` de un rol retira lo concedido en `database`
639
+ * —donde `declaredRoleAt` se evalúa en cada pregunta— y **sigue
640
+ * concediendo** en `facts`, que es la divergencia R1 del lote 2e.
641
+ *
642
+ * Se cierra igual que el owner: barriendo la arista `scope#binding` de los
643
+ * bindings de ESE rol con la regla única de visibilidad. Lo llama
644
+ * `projectCatalogRole`, que es el hook de «una escritura de catálogo cambió
645
+ * este rol»: el manager lo dispara tras `defineScopedRole`/`updateScopedRole`
646
+ * y un escritor «a mano» de `authz_*` tiene el mismo deber que ya tenía con
647
+ * el espejo de permisos (sin él, en `facts` un rol recién definido no
648
+ * concedería nada y quitarle un permiso seguiría concediéndolo).
649
+ *
650
+ * Coste: una lectura (los bindings del rol) y, **solo si el rol es LOCAL y
651
+ * tiene bindings**, la cadena del store de cada scope distinto donde cuelga
652
+ * uno; un `Write` por lote si hay algo que barrer. Un rol sin bindings —el
653
+ * caso de todo `defineScopedRole`— son 0 escrituras.
654
+ */
655
+ private sweepRoleVisibility;
656
+ /**
657
+ * La arista `scope#binding` de un binding, a escribir o a borrar según la
658
+ * **regla única de visibilidad** (`declaredRoleAt`, la misma que evalúa
659
+ * `database` en cada pregunta): el rol tiene que estar declarado para el
660
+ * NIVEL de ese scope (3b-2g · R1) y ser global o tener a su owner en la
661
+ * cadena (3b-2e · E1). Visible ⇒ la arista se (re)escribe; no visible ⇒ se
662
+ * borra. El `assignee` no se toca: la asignación existe igual, lo que
663
+ * cambia es dónde se la ve.
664
+ */
665
+ private classifyBindingEdge;
666
+ /**
667
+ * Aplica el barrido en lotes ≤ 100 (el límite del `Write`).
668
+ *
669
+ * `Ignore` en las dos direcciones: el barrido dice el estado que DEBE
670
+ * quedar, no el delta — borrar lo que ya no está y reescribir lo que ya
671
+ * estaba son no-ops, no errores (invariante 6).
672
+ */
673
+ private applyBindingSweep;
674
+ /**
675
+ * `[key, ...ancestros]` según el ÁRBOL DEL STORE (3b-2e · E1), subiendo por
676
+ * `scope#parent`. Es la cadena con la que FGA va a decidir, que es la que
677
+ * tiene que gobernar el barrido; el resolutor del consumidor no participa.
678
+ * Un nodo con más de un padre es deriva y se dice (`assertOneParent`), y el
679
+ * recorrido está acotado por el mismo tope de páginas que las
680
+ * enumeraciones: un ciclo escrito a mano no cuelga el proceso.
681
+ */
682
+ private storeChain;
403
683
  /**
404
684
  * Purga del scope exacto en FGA (N7, S6, B2). No hay "borrar todo lo de
405
685
  * este objeto": se leen por objeto EXACTO los bindings posibles — un
406
- * `role_binding` por cada rol del catálogo de ese `scope_type` y un
407
- * `deny_binding` por cada permiso paginando `Read` (nunca ListObjects:
408
- * trunca sin avisar, L0.7), se borra en lotes ≤ 100 (límite del Write) y
409
- * se vuelve a leer cada objeto: si queda algo, se lanza. Un rol retirado
410
- * del catálogo deja bindings inalcanzables por esta vía; es el precio de no
411
- * tener un índice por objeto, y lo vigilará `authz:reconcile` (3b).
686
+ * `role_binding` por cada rol del catálogo de ese `scope_type` más el
687
+ * objeto `scope:<key>` (donde viven los `denied_<P>` y el `#binding`),
688
+ * paginando `Read` (nunca ListObjects: trunca sin avisar, L0.7), se borra
689
+ * en lotes ≤ 100 (límite del Write) y se vuelve a leer cada objeto: si
690
+ * queda algo, se lanza. Un rol retirado del catálogo deja bindings
691
+ * inalcanzables por esta vía; es el precio de no tener un índice por
692
+ * objeto, y lo vigilará `authz:reconcile` (3b).
412
693
  */
413
694
  purgeScope(purged: ScopeRef): Promise<void>;
695
+ /**
696
+ * La **proyección derivada del catálogo** en el store (3b-2a · A5; regla
697
+ * del catálogo reescrita, panel 2 cruce 7). Se pasa a `syncAuthzCatalog`,
698
+ * que la usa en dos momentos: comprueba que el catálogo que va a quedar es
699
+ * publicable (cotas de nombre y techo del modelo) ANTES de escribir, y
700
+ * rehace las tuplas `role:<uuid>#permits_<P>@<holder>:*` con el catálogo ya
701
+ * confirmado.
702
+ *
703
+ * Sigue sin ser el catálogo: es un espejo reconstruible que ningún camino
704
+ * de LECTURA de este driver consulta para responder qué permisos tiene un
705
+ * rol (A6). Quien decide es `authz_*` a través del memo.
706
+ */
707
+ catalogProjection(): CatalogProjection;
708
+ /**
709
+ * **El marcador de raíz de (c2r)** (3b-2i): `scope:app#rooted@<holder>:*`,
710
+ * una tupla por holder type en todo el store y CERO por scope.
711
+ *
712
+ * Va aquí —en la proyección del catálogo, o sea en cada `syncAuthzCatalog`—
713
+ * y no en `attached`, porque el evento que hace falta cubrir es **añadir un
714
+ * holderType al `config`**: sin esto ese holder denegaría en TODO el store
715
+ * aunque el modelo se haya republicado, que es la única forma realista de
716
+ * quedarse sin marcador en un store vivo. Es idempotente: un `Read` y, solo
717
+ * si falta algo, un `Write` con lo que falta (0 escrituras en el caso
718
+ * normal).
719
+ *
720
+ * Solo en modo `facts`: el modelo del modo `resolver` no declara `rooted` y
721
+ * escribirlo sería un 400 del servidor.
722
+ *
723
+ * ⚠️ Sin marcador el store entero DENIEGA (fail-closed, medido). Por eso se
724
+ * repone en cada sync y `authz:reconcile` (3b-3) tiene el deber escrito de
725
+ * reportarlo como deriva cuando falte.
726
+ */
727
+ private ensureFactsRoot;
728
+ /**
729
+ * Espeja los vínculos rol→permiso: escribe lo que falta y BORRA lo que
730
+ * sobra, en UN `Write` por lote con deletes y writes juntos (cruce 8:
731
+ * queda prohibido el patrón `deleteTuples()` + `writeTuples()`, que no es
732
+ * atómico). Con (c2) quitar un permiso de un rol son tantos deletes como
733
+ * holders y ninguna reescritura del modelo.
734
+ *
735
+ * Sin diferencias no se llama al servidor: un `sync` que no cambió el
736
+ * catálogo escribe CERO tuplas.
737
+ */
738
+ private projectCatalog;
739
+ /**
740
+ * **Purga un ROL con sus hechos** (3b-2e · E4; hasta aquí este driver no lo
741
+ * traía y por eso `defineScopedRole` era 500 `E_AUTHZ_UNSUPPORTED` antes de
742
+ * escribir, 3E · P4).
743
+ *
744
+ * Lo que lo hace posible es (c2): el binding APUNTA A SU ROL
745
+ * (`role_binding:…#role@role:<uuid>`), así que los bindings de un rol se
746
+ * enumeran filtrando por `user` — y con la arista `scope#binding` se sabe de
747
+ * qué scope cuelga cada uno. En el modo `resolver` esas dos aristas no
748
+ * existen y el método TAMPOCO: el constructor lo retira (el manager lo lee
749
+ * como «no sé purgar» y se niega antes de escribir).
750
+ *
751
+ * Orden: **hechos primero, catálogo después** (el mismo de `detached`, S6).
752
+ * No hay transacción que abarque FGA y SQL, así que lo que se garantiza es
753
+ * la dirección segura: mientras la fila del rol siga viva, lo que quede en
754
+ * el store es visible y reintentable; al revés quedarían hechos huérfanos
755
+ * que resucitarían al recrear el slug. Y se DEMUESTRA cero (invariante 11):
756
+ * si algo sobrevive, 500 `E_AUTHZ_PURGE_INCOMPLETE` y el catálogo no se
757
+ * toca.
758
+ */
759
+ purgeRole(roleUuid: string): Promise<void>;
760
+ /**
761
+ * Cuántos hechos VIGENTES tiene cada rol, en todos los scopes (3b-2j).
762
+ *
763
+ * Aquí los hechos son TUPLAS, no filas: `role_binding:<scope>|<rol>#assignee@<holder>`,
764
+ * con la caducidad en su *condition*. Por eso esta pregunta es del PUERTO
765
+ * y no del barrido: hasta 3b-2j `pruneOrphanRoles` contaba
766
+ * `authz_assignments` —la tabla del driver `database`, vacía con este— y
767
+ * el `stillGranting` que se lee justo antes de purgar decía SIEMPRE «este
768
+ * rol no concede», sobre roles que concedían (medido en el lote 2i).
769
+ *
770
+ * Lo hace posible lo mismo que hace posible `purgeRole`: el binding apunta
771
+ * a su rol (`role_binding:…#role@role:<uuid>`, (c2)), así que los bindings
772
+ * de un rol se enumeran filtrando por `user`. En modo `resolver` esa arista
773
+ * no existe y el método TAMPOCO (el constructor lo retira, y el manager lo
774
+ * lee como «no lo sé»).
775
+ *
776
+ * La arista estructural se lee con `includeExpired` —no caduca, la
777
+ * caducidad está en el `assignee`— y los assignees sin él: la caducidad es
778
+ * ESTRICTA y con el reloj del driver, igual que en `authorize`. Un `user`
779
+ * que no se entiende como holder se cuenta igual (a diferencia de
780
+ * `listSubjects`, que lo descarta): aquí contar de MÁS es el lado seguro —
781
+ * marca el rol para que un humano lo mire— y contar de menos es decir «no
782
+ * concede» sobre algo que sí.
783
+ *
784
+ * Coste: por rol preguntado, una lectura de sus bindings más una por
785
+ * binding. Lo llama `pruneOrphanRoles` con los HUÉRFANOS de la pasada (no
786
+ * con el catálogo entero) y corre en un comando de plataforma, no en el
787
+ * camino de una petición.
788
+ */
789
+ countRoleAssignments(roleUuids: string[]): Promise<number[]>;
790
+ /**
791
+ * **Rehace la proyección de UN rol** (3b-2e · E4). En (c2) lo que un rol
792
+ * concede son tuplas (`role:<uuid>#permits_<P>@<holder>:*`), no el catálogo
793
+ * local: una escritura de catálogo que no las toque deja un rol que no
794
+ * concede nada (`defineScopedRole`) o que sigue concediendo lo que ya no
795
+ * vincula (`updateScopedRole`) — lo segundo es un fail-open. `syncAuthzCatalog`
796
+ * ya lo hace para el catálogo entero cuando el consumidor le pasa la
797
+ * proyección; esto es lo mismo para las escrituras de la API de delegación,
798
+ * y cuesta una lectura por holder.
799
+ *
800
+ * **Y son DOS proyecciones, no una** (3b-2g · R1): lo que el rol concede
801
+ * (`permits_<P>`) y **dónde es visible** (`scope#binding`, `sweepRoleVisibility`).
802
+ * El modelo (c2) no lleva el NIVEL del rol, así que un `scope_type` que
803
+ * cambia sin barrer deja la asignación concediendo en un nivel que el
804
+ * catálogo ya no declara — retirado en `database` y vivo aquí, que es la
805
+ * divergencia R1 del lote 2e.
806
+ */
807
+ projectCatalogRole(roleUuid: string): Promise<void>;
808
+ /**
809
+ * **Reconstruye el store desde `authz_*` + el árbol del consumidor.**
810
+ *
811
+ * Es la razón de ser de la fase («todo en un driver o todo en otro, con una
812
+ * migración idempotente y bidireccional») y la ÚNICA primitiva de migración
813
+ * del paquete: `openfga:import` se borró en 3b-2k · K2 porque escribía las
814
+ * tuplas de un modelo que ya no existe.
815
+ *
816
+ * Migra las TRES cosas que hacen completo a este driver:
817
+ * 1. el **marcador de raíz** (`scope:app#rooted@<holder>:*`, 3b-2i) — sin
818
+ * él el store entero DENIEGA, así que va primero;
819
+ * 2. la **proyección del catálogo** (`role:<uuid>#permits_<P>`), leída con
820
+ * la MISMA función que usa `syncAuthzCatalog` (`readCatalogProjectionSnapshot`),
821
+ * para que reconcile no "arregle" en cada pasada lo que el sync deja bien;
822
+ * 3. el **árbol** (`scope#parent`) desde `scopes.enumerateEdges`, y
823
+ * 4. los **hechos**: `authz_assignments` (assignee + las dos aristas de
824
+ * (c2)) y `authz_denies` (`scope#denied_<P>`).
825
+ *
826
+ * **Qué borra y qué no.** Lo DERIVADO —marcador, catálogo y árbol— es un
827
+ * espejo de datos locales que nadie más escribe: lo que sobra se borra
828
+ * siempre (cruce 9 · S7 lo exige para las aristas que `enumerateEdges` no
829
+ * respalda, y es lo que repara un nodo con DOS padres, 3b-2h · 🟠 4). Los
830
+ * HECHOS solo se borran con `prune`: son irreversibles y su origen depende
831
+ * de qué driver esté vivo. La excepción, a propósito, es la arista
832
+ * `scope#binding` de una asignación que el origen SÍ respalda pero cuya
833
+ * regla de visibilidad dice que NO (invariante 18): dejarla es fail-OPEN
834
+ * —es justo la escritura que `scopes.moved`/`projectCatalogRole` pudieron
835
+ * perder si el relay no pasó—, así que se borra siempre y se cuenta en
836
+ * `drift.roleVisibility`.
837
+ *
838
+ * **Nada de `Ignore` a ciegas** (cruce 9 · S7): el importador viejo escribía
839
+ * con `onDuplicateWrites: Ignore` y por eso una tupla que ya estaba con OTRA
840
+ * caducidad se quedaba como estaba y encima se contaba como escrita —rompía
841
+ * los invariantes 3 y 6—. Aquí el estado del destino se LEE entero antes de
842
+ * decidir, la diferencia de caducidad se resuelve con delete + write, y los
843
+ * contadores salen del diff, no del write. `Ignore` se conserva solo como
844
+ * red contra una carrera (y por eso la migración va con `manager.freeze()`).
845
+ *
846
+ * `dryRun` es el VERIFICADOR: mismo recorrido, cero escrituras, mismos
847
+ * números. Read-only por contrato (cruce 4 · S18): **un `--fix` está
848
+ * prohibido** y no se implementa ni se deja preparado.
849
+ */
850
+ reconcile(source: ReconcileSource, options?: ReconcileOptions): Promise<ReconcileReport>;
851
+ /**
852
+ * Las relaciones `permits_<P>` que DECLARA el modelo publicado del store, o
853
+ * `null` si no se puede saber (un store sin modelo). No es una barrera de
854
+ * seguridad: es la diferencia entre contar un permiso que este store no
855
+ * puede llevar y morirse con un 400 a mitad de la migración.
856
+ */
857
+ private modelPermissions;
858
+ /**
859
+ * El árbol del ORIGEN, paginado (`scopes.enumerateEdges`), con los ciclos
860
+ * apartados. Un ciclo no se escribe NUNCA: FGA lo evalúa y la herencia pasa
861
+ * a ser bidireccional (un grant en un descendiente concede en el ancestro,
862
+ * cruce 3), así que aquí sale como reporte y sus nodos se quedan sin arista
863
+ * —o sea, sin `rooted`, o sea denegando (fail-closed)—.
864
+ */
865
+ private readSourceTree;
866
+ /**
867
+ * Los HECHOS del origen (`authz_assignments` y `authz_denies`), leídos **por
868
+ * lotes con cursor** sobre la clave primaria: una pasada interrumpida se
869
+ * repite y converge (es idempotente), y una base grande no entra entera en
870
+ * memoria de golpe. Los DOS barridos van en la MISMA transacción de lectura
871
+ * repetible (3b-6, `withSourceSnapshot`): sueltos, componían dos mitades de
872
+ * dos operaciones distintas y FABRICABAN un permiso.
873
+ *
874
+ * Cada fila que NO se migra sale contada y con su motivo:
875
+ * - `unknown-scope`: el árbol del consumidor ya no resuelve ese scope
876
+ * (`detached` de un ancestro). Sus tuplas del store son las de la
877
+ * «resurrección» (3b-0b · AA4) y las borra `--prune`.
878
+ * - `unknown-role` / `unknown-permission`: el catálogo ya no lo declara
879
+ * (un rol retirado; invariante 11 dice que los recoge este comando).
880
+ * - `expired`: la asignación ya no concede; migrarla sería escribir una
881
+ * caducidad pasada.
882
+ * - `role-not-visible`: la asignación existe (y `listRoles`/`hasRole` la
883
+ * enumeran), pero el rol NO es visible en ese scope con el árbol y el
884
+ * catálogo de HOY (invariante 18), así que su arista `scope#binding` no
885
+ * se escribe — y si el store la tiene, se borra.
886
+ * - `unknown-holder-type`: el `holderTypes` del config no declara ese
887
+ * morph name, así que no hay usuario FGA que escribir.
888
+ */
889
+ private readSourceFacts;
890
+ /**
891
+ * **La foto CONSISTENTE del origen** (3b-6; 🔴 3 del panel 3, verificado en
892
+ * el código por el juez).
893
+ *
894
+ * `readSourceFacts` recorre `authz_assignments` y DESPUÉS `authz_denies`.
895
+ * Cada barrido se construía sobre la conexión global, así que entre los dos
896
+ * había un hueco —el tiempo de pasear la primera tabla entera por lotes de
897
+ * 100: segundos o minutos en una base real— por el que se colaban
898
+ * operaciones de negocio COMPUESTAS. Un offboarding es `revoke` +
899
+ * `removeDeny`: si cae ahí, la pasada se queda con la mitad de cada una y
900
+ * escribe en el destino **el rol sin su deny**, o sea un permiso que NI el
901
+ * estado anterior NI el posterior concedían, con el reporte diciendo
902
+ * `written=13 extra=0 skipped={} clean=true`. No es una pérdida: es una
903
+ * ESCALADA fabricada, y el operador no tiene ni un motivo para desconfiar
904
+ * del verde.
905
+ *
906
+ * Con los dos barridos dentro de UNA transacción de lectura repetible, el
907
+ * peor resultado de la ventana deja de ser «un estado que nunca existió» y
908
+ * pasa a ser «el estado consistente de `t0`»: la deriva recuperable que
909
+ * repite la pasada siguiente.
910
+ *
911
+ * **Qué garantiza cada motor, porque no es lo mismo** (Fase 2.5 dixit):
912
+ * - **PostgreSQL**: `BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ`.
913
+ * La foto se toma en la primera sentencia de la transacción y no se
914
+ * mueve; garantía del motor.
915
+ * - **MySQL/InnoDB**: `SET TRANSACTION ISOLATION LEVEL REPEATABLE READ` +
916
+ * `BEGIN`, y la lectura consistente queda fijada en la primera consulta.
917
+ * REPEATABLE READ es su default, pero se DECLARA a propósito: el default
918
+ * es config del servidor y no una promesa del paquete.
919
+ * - **SQLite**: no acepta nivel de aislamiento —knex avisa y lo ignora—,
920
+ * así que no se le pide: su transacción de LECTURA ya es una foto (en
921
+ * WAL el lector conserva su snapshot mientras un escritor confirma; sin
922
+ * WAL el escritor espera al lector). Por eso aquí se pregunta el
923
+ * dialecto en vez de mandar el nivel a ciegas.
924
+ *
925
+ * **Lo que esto NO cubre y queda declarado**: cubre la dirección cuyo
926
+ * origen es `authz_*` (`--to=openfga`). Cuando la fuente de verdad de los
927
+ * hechos es el STORE (`readOriginFacts` → `enumerateFacts`: `--to=database`
928
+ * y la pasada de mantenimiento) las páginas de `Read` **tampoco son una
929
+ * foto consistente** y no hay REPEATABLE READ que valga: ahí la misma
930
+ * composición de media transacción sigue siendo posible. La instrumentación
931
+ * posible en esa dirección es `readChanges({ startTime })` —detectar y
932
+ * nombrar la tupla, no prevenirla—, y no está hecha.
933
+ */
934
+ private withSourceSnapshot;
935
+ /**
936
+ * **Los hechos por el PUERTO** (3b-5): la otra fuente de verdad posible.
937
+ *
938
+ * Se usa cuando `authz_*` NO manda —el caso que faltaba: el motor ya sirve
939
+ * desde este driver y sus hechos son las tuplas del store—, y también sirve
940
+ * para migrar desde otro driver que sepa `enumerateFacts`. El recorrido y
941
+ * los motivos son **los mismos** que los de `readSourceFacts` (`expired`,
942
+ * `unknown-scope`, `unknown-role`, `unknown-holder-type`,
943
+ * `role-not-visible`): lo único que cambia es de dónde salen las filas, que
944
+ * es justo la decisión que no se estaba tomando.
945
+ *
946
+ * Consecuencia buscada: con el store como origen, `wanted` describe lo que
947
+ * el store YA tiene, así que la pasada no escribe ni borra un solo hecho —y
948
+ * sí rehace lo DERIVADO (marcador, catálogo, árbol) y aplica el barrido de
949
+ * visibilidad del invariante 18 con el árbol y el catálogo de HOY, que es
950
+ * la reparación que el invariante promete y no existía.
951
+ *
952
+ * Disciplina del cursor idéntica a la de `--to=database`: como mucho
953
+ * `limit` por página, el cursor tiene que AVANZAR y la cota `maxTuples` se
954
+ * aplica también al origen.
955
+ */
956
+ private readOriginFacts;
957
+ /**
958
+ * Una ASIGNACIÓN del origen (venga de `authz_*` o del puerto) traducida a
959
+ * lo que el store debe tener: el `assignee` con su caducidad, la arista
960
+ * `role_binding#role` y —solo si el rol es VISIBLE ahí— la `scope#binding`.
961
+ * Es la única implementación de esa regla en esta dirección: tenerla dos
962
+ * veces era tenerla distinta según de dónde salieran los hechos.
963
+ */
964
+ private wantAssignment;
965
+ /** Un DENY del origen (permiso ya como slug) traducido a `scope#denied_<P>`. */
966
+ private wantDeny;
967
+ /**
968
+ * Pasea una tabla `authz_*` por lotes de `batchSize` con cursor sobre
969
+ * `uuid` (clave primaria: orden total y estable). Es lo que hace la pasada
970
+ * REANUDABLE y lo que impide que una base grande entre entera de golpe.
971
+ *
972
+ * **El cliente llega por parámetro** (3b-6): antes cada página se construía
973
+ * sobre el `db` global, así que dos barridos consecutivos eran dos fotos
974
+ * distintas. Quien decide la foto es `withSourceSnapshot`, y aquí solo se
975
+ * obedece — un call-site nuevo que pase `db` vuelve a abrir el hueco, y por
976
+ * eso el parámetro es obligatorio y no tiene default.
977
+ */
978
+ private eachRow;
979
+ /**
980
+ * **Los hechos de este store, paginados** (3b-3b): la mitad ORIGEN de la
981
+ * migración, la que hace posible `authz:reconcile --to=database`.
982
+ *
983
+ * Se lee con `Read` y su `continuation_token`, no con `ListObjects`: el
984
+ * plan lo dice con esas palabras y el motivo es que `ListObjects` **no
985
+ * tiene `continuation_token`** —corta al tope del servidor sin señal
986
+ * (S16)—, así que no hay forma de pasear un store entero con él. `Read`
987
+ * sin filtro es además lo único que ve la basura de una versión anterior,
988
+ * que es lo que sale en `skipped`.
989
+ *
990
+ * **Nada se filtra**: una asignación caducada sale con su `expiresAt` para
991
+ * que el DESTINO la cuente con su motivo. Si se filtrara aquí desaparecería
992
+ * sin dejar rastro en ningún contador, que es exactamente lo que una
993
+ * migración no puede hacer. Por lo mismo NO interviene el reloj del driver.
994
+ *
995
+ * Lo que no es un hecho —la estructura de (c2) (`parent`, `binding`,
996
+ * `role`, `rooted`) y la proyección del catálogo (`permits_<P>`)— no se
997
+ * emite ni se cuenta: es DERIVADO, se rehace desde el catálogo y el árbol
998
+ * del consumidor, y en esta dirección ni siquiera se migra. Lo que sí se
999
+ * cuenta es lo que TENDRÍA que ser un hecho y no se entiende.
1000
+ */
1001
+ enumerateFacts(page: {
1002
+ limit: number;
1003
+ after?: string;
1004
+ }): Promise<ReconcileFactPage>;
1005
+ /**
1006
+ * El store ENTERO, con la caducidad de cada tupla. Un `Read` sin filtro es
1007
+ * la única forma de ver lo que SOBRA —incluida la basura de una versión
1008
+ * anterior, cuyos tipos el modelo de hoy ni declara (se lee y se borra, se
1009
+ * comprobó contra el servidor)—: filtrando por objeto solo se ve lo que ya
1010
+ * se sabe que existe. Paginado y acotado como todas las enumeraciones.
1011
+ */
1012
+ private readEveryTuple;
1013
+ /**
1014
+ * Aplica el plan en lotes ≤ 100 (el límite del `Write`). `Ignore` en las dos
1015
+ * direcciones es RED, no política: el plan sale de un diff sobre el estado
1016
+ * leído y la migración corre con las escrituras congeladas, así que un
1017
+ * duplicado o un borrado que ya no está solo puede venir de una carrera —y
1018
+ * en una carrera es preferible seguir a abortar la migración entera—.
1019
+ */
1020
+ private applyReconcileWrites;
414
1021
  /**
415
1022
  * TODAS las tuplas que casan con el filtro, paginando `Read` hasta agotar
416
1023
  * el `continuation_token`, sin las caducadas. Es la única primitiva de