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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (275) hide show
  1. package/README.md +743 -28
  2. package/build/commands/authz_catalog_diff.d.ts +0 -1
  3. package/build/commands/authz_catalog_diff.js +0 -1
  4. package/build/commands/authz_catalog_prune_orphans.d.ts +0 -1
  5. package/build/commands/authz_catalog_prune_orphans.js +0 -1
  6. package/build/commands/authz_catalog_sync.d.ts +0 -1
  7. package/build/commands/authz_catalog_sync.js +0 -1
  8. package/build/commands/authz_freeze.d.ts +0 -1
  9. package/build/commands/authz_freeze.js +0 -1
  10. package/build/commands/authz_reconcile.d.ts +0 -1
  11. package/build/commands/authz_reconcile.js +0 -1
  12. package/build/commands/authz_relations_reconcile.d.ts +6 -1
  13. package/build/commands/authz_relations_reconcile.js +163 -5
  14. package/build/commands/authz_scopes_relay.d.ts +0 -1
  15. package/build/commands/authz_scopes_relay.js +49 -5
  16. package/build/commands/authz_scopes_requeue.d.ts +0 -1
  17. package/build/commands/authz_scopes_requeue.js +14 -2
  18. package/build/commands/authz_unfreeze.d.ts +0 -1
  19. package/build/commands/authz_unfreeze.js +0 -1
  20. package/build/commands/main.d.ts +0 -1
  21. package/build/commands/main.js +0 -1
  22. package/build/commands/openfga_provision.d.ts +72 -1
  23. package/build/commands/openfga_provision.js +236 -4
  24. package/build/configure.d.ts +0 -1
  25. package/build/configure.js +0 -1
  26. package/build/index.d.ts +11 -9
  27. package/build/index.js +12 -5
  28. package/build/providers/authz_provider.d.ts +0 -1
  29. package/build/providers/authz_provider.js +0 -1
  30. package/build/services/main.d.ts +0 -1
  31. package/build/services/main.js +0 -1
  32. package/build/services/relations.d.ts +0 -1
  33. package/build/services/relations.js +0 -1
  34. package/build/src/catalog/catalog.d.ts +0 -1
  35. package/build/src/catalog/catalog.js +0 -1
  36. package/build/src/catalog/catalog_cache.d.ts +13 -1
  37. package/build/src/catalog/catalog_cache.js +25 -1
  38. package/build/src/clock.d.ts +0 -1
  39. package/build/src/clock.js +0 -1
  40. package/build/src/define_config.d.ts +0 -1
  41. package/build/src/define_config.js +24 -1
  42. package/build/src/drivers/database_driver.d.ts +6 -1
  43. package/build/src/drivers/database_driver.js +22 -2
  44. package/build/src/drivers/database_relations_driver.d.ts +46 -5
  45. package/build/src/drivers/database_relations_driver.js +297 -59
  46. package/build/src/drivers/openfga_driver.d.ts +159 -26
  47. package/build/src/drivers/openfga_driver.js +980 -231
  48. package/build/src/drivers/openfga_facts.d.ts +112 -1
  49. package/build/src/drivers/openfga_facts.js +351 -11
  50. package/build/src/drivers/openfga_relations_driver.d.ts +109 -8
  51. package/build/src/drivers/openfga_relations_driver.js +817 -53
  52. package/build/src/errors.d.ts +233 -1
  53. package/build/src/errors.js +283 -1
  54. package/build/src/expiry.d.ts +51 -2
  55. package/build/src/expiry.js +96 -2
  56. package/build/src/freeze.d.ts +0 -1
  57. package/build/src/freeze.js +0 -1
  58. package/build/src/hierarchical_resolver.d.ts +0 -1
  59. package/build/src/hierarchical_resolver.js +0 -1
  60. package/build/src/http/app_access_middleware.d.ts +0 -1
  61. package/build/src/http/app_access_middleware.js +0 -1
  62. package/build/src/http/resource_access_middleware.d.ts +0 -1
  63. package/build/src/http/resource_access_middleware.js +0 -1
  64. package/build/src/identity.d.ts +2 -1
  65. package/build/src/identity.js +4 -1
  66. package/build/src/manager.d.ts +0 -1
  67. package/build/src/manager.js +164 -31
  68. package/build/src/memoize_ancestors.d.ts +0 -1
  69. package/build/src/memoize_ancestors.js +0 -1
  70. package/build/src/models/authz_assignment.d.ts +5 -6
  71. package/build/src/models/authz_assignment.js +0 -1
  72. package/build/src/models/authz_deny.d.ts +5 -6
  73. package/build/src/models/authz_deny.js +0 -1
  74. package/build/src/models/authz_permission.d.ts +5 -6
  75. package/build/src/models/authz_permission.js +0 -1
  76. package/build/src/models/authz_role.d.ts +5 -6
  77. package/build/src/models/authz_role.js +0 -1
  78. package/build/src/models/authz_role_permission.d.ts +5 -6
  79. package/build/src/models/authz_role_permission.js +0 -1
  80. package/build/src/openfga.d.ts +3 -4
  81. package/build/src/openfga.js +5 -3
  82. package/build/src/reconcile.d.ts +0 -1
  83. package/build/src/reconcile.js +0 -1
  84. package/build/src/relation_partition_trigger.d.ts +0 -1
  85. package/build/src/relation_partition_trigger.js +0 -1
  86. package/build/src/relations/check_many.d.ts +1 -2
  87. package/build/src/relations/check_many.js +6 -5
  88. package/build/src/relations/define_relations_config.d.ts +140 -5
  89. package/build/src/relations/define_relations_config.js +205 -6
  90. package/build/src/relations/deny_reach.d.ts +102 -0
  91. package/build/src/relations/deny_reach.js +230 -0
  92. package/build/src/relations/group_depth.d.ts +215 -0
  93. package/build/src/relations/group_depth.js +453 -0
  94. package/build/src/relations/manager.d.ts +46 -8
  95. package/build/src/relations/manager.js +178 -16
  96. package/build/src/relations/page.d.ts +19 -0
  97. package/build/src/relations/page.js +74 -0
  98. package/build/src/relations/reconcile.d.ts +274 -2
  99. package/build/src/relations/reconcile.js +1006 -162
  100. package/build/src/relations/tuple_key.d.ts +0 -1
  101. package/build/src/relations/tuple_key.js +0 -1
  102. package/build/src/relations/tuple_order.d.ts +127 -0
  103. package/build/src/relations/tuple_order.js +599 -0
  104. package/build/src/relations_config_store.d.ts +57 -2
  105. package/build/src/relations_config_store.js +155 -5
  106. package/build/src/relations_reconcile.d.ts +0 -1
  107. package/build/src/relations_reconcile.js +0 -1
  108. package/build/src/scope_outbox.d.ts +0 -1
  109. package/build/src/scope_outbox.js +0 -1
  110. package/build/src/shared/backend_guard.d.ts +60 -1
  111. package/build/src/shared/backend_guard.js +179 -2
  112. package/build/src/shared/sql_expiry.d.ts +0 -1
  113. package/build/src/shared/sql_expiry.js +0 -1
  114. package/build/src/shared/transaction_guard.d.ts +0 -1
  115. package/build/src/shared/transaction_guard.js +0 -1
  116. package/build/src/sql_descendants.d.ts +0 -1
  117. package/build/src/sql_descendants.js +0 -1
  118. package/build/src/testing/contract.d.ts +7 -1
  119. package/build/src/testing/contract.js +77 -1
  120. package/build/src/testing/main.d.ts +1 -2
  121. package/build/src/testing/main.js +1 -2
  122. package/build/src/testing/migration_contract.d.ts +0 -1
  123. package/build/src/testing/migration_contract.js +0 -1
  124. package/build/src/testing/relations_contract.d.ts +25 -1
  125. package/build/src/testing/relations_contract.js +996 -49
  126. package/build/src/testing/relations_reconcile_contract.d.ts +9 -1
  127. package/build/src/testing/relations_reconcile_contract.js +207 -1
  128. package/build/src/testing/scope_outbox_contract.d.ts +0 -1
  129. package/build/src/testing/scope_outbox_contract.js +0 -1
  130. package/build/src/testing/scope_tree.d.ts +0 -1
  131. package/build/src/testing/scope_tree.js +0 -1
  132. package/build/src/testing/test_clock.d.ts +0 -1
  133. package/build/src/testing/test_clock.js +0 -1
  134. package/build/src/traits/authz_scopes.d.ts +0 -1
  135. package/build/src/traits/authz_scopes.js +0 -1
  136. package/build/src/traits/has_uuid.d.ts +5 -6
  137. package/build/src/traits/has_uuid.js +0 -1
  138. package/build/src/types.d.ts +170 -15
  139. package/build/src/types.js +6 -1
  140. package/build/stubs/config/authorization.stub +22 -6
  141. package/build/stubs/main.d.ts +0 -1
  142. package/build/stubs/main.js +0 -1
  143. package/package.json +4 -3
  144. package/build/commands/authz_catalog_diff.d.ts.map +0 -1
  145. package/build/commands/authz_catalog_diff.js.map +0 -1
  146. package/build/commands/authz_catalog_prune_orphans.d.ts.map +0 -1
  147. package/build/commands/authz_catalog_prune_orphans.js.map +0 -1
  148. package/build/commands/authz_catalog_sync.d.ts.map +0 -1
  149. package/build/commands/authz_catalog_sync.js.map +0 -1
  150. package/build/commands/authz_freeze.d.ts.map +0 -1
  151. package/build/commands/authz_freeze.js.map +0 -1
  152. package/build/commands/authz_reconcile.d.ts.map +0 -1
  153. package/build/commands/authz_reconcile.js.map +0 -1
  154. package/build/commands/authz_relations_reconcile.d.ts.map +0 -1
  155. package/build/commands/authz_relations_reconcile.js.map +0 -1
  156. package/build/commands/authz_scopes_relay.d.ts.map +0 -1
  157. package/build/commands/authz_scopes_relay.js.map +0 -1
  158. package/build/commands/authz_scopes_requeue.d.ts.map +0 -1
  159. package/build/commands/authz_scopes_requeue.js.map +0 -1
  160. package/build/commands/authz_unfreeze.d.ts.map +0 -1
  161. package/build/commands/authz_unfreeze.js.map +0 -1
  162. package/build/commands/main.d.ts.map +0 -1
  163. package/build/commands/main.js.map +0 -1
  164. package/build/commands/openfga_provision.d.ts.map +0 -1
  165. package/build/commands/openfga_provision.js.map +0 -1
  166. package/build/configure.d.ts.map +0 -1
  167. package/build/configure.js.map +0 -1
  168. package/build/index.d.ts.map +0 -1
  169. package/build/index.js.map +0 -1
  170. package/build/providers/authz_provider.d.ts.map +0 -1
  171. package/build/providers/authz_provider.js.map +0 -1
  172. package/build/services/main.d.ts.map +0 -1
  173. package/build/services/main.js.map +0 -1
  174. package/build/services/relations.d.ts.map +0 -1
  175. package/build/services/relations.js.map +0 -1
  176. package/build/src/catalog/catalog.d.ts.map +0 -1
  177. package/build/src/catalog/catalog.js.map +0 -1
  178. package/build/src/catalog/catalog_cache.d.ts.map +0 -1
  179. package/build/src/catalog/catalog_cache.js.map +0 -1
  180. package/build/src/clock.d.ts.map +0 -1
  181. package/build/src/clock.js.map +0 -1
  182. package/build/src/define_config.d.ts.map +0 -1
  183. package/build/src/define_config.js.map +0 -1
  184. package/build/src/drivers/database_driver.d.ts.map +0 -1
  185. package/build/src/drivers/database_driver.js.map +0 -1
  186. package/build/src/drivers/database_relations_driver.d.ts.map +0 -1
  187. package/build/src/drivers/database_relations_driver.js.map +0 -1
  188. package/build/src/drivers/openfga_driver.d.ts.map +0 -1
  189. package/build/src/drivers/openfga_driver.js.map +0 -1
  190. package/build/src/drivers/openfga_facts.d.ts.map +0 -1
  191. package/build/src/drivers/openfga_facts.js.map +0 -1
  192. package/build/src/drivers/openfga_relations_driver.d.ts.map +0 -1
  193. package/build/src/drivers/openfga_relations_driver.js.map +0 -1
  194. package/build/src/errors.d.ts.map +0 -1
  195. package/build/src/errors.js.map +0 -1
  196. package/build/src/expiry.d.ts.map +0 -1
  197. package/build/src/expiry.js.map +0 -1
  198. package/build/src/freeze.d.ts.map +0 -1
  199. package/build/src/freeze.js.map +0 -1
  200. package/build/src/hierarchical_resolver.d.ts.map +0 -1
  201. package/build/src/hierarchical_resolver.js.map +0 -1
  202. package/build/src/http/app_access_middleware.d.ts.map +0 -1
  203. package/build/src/http/app_access_middleware.js.map +0 -1
  204. package/build/src/http/resource_access_middleware.d.ts.map +0 -1
  205. package/build/src/http/resource_access_middleware.js.map +0 -1
  206. package/build/src/identity.d.ts.map +0 -1
  207. package/build/src/identity.js.map +0 -1
  208. package/build/src/manager.d.ts.map +0 -1
  209. package/build/src/manager.js.map +0 -1
  210. package/build/src/memoize_ancestors.d.ts.map +0 -1
  211. package/build/src/memoize_ancestors.js.map +0 -1
  212. package/build/src/models/authz_assignment.d.ts.map +0 -1
  213. package/build/src/models/authz_assignment.js.map +0 -1
  214. package/build/src/models/authz_deny.d.ts.map +0 -1
  215. package/build/src/models/authz_deny.js.map +0 -1
  216. package/build/src/models/authz_permission.d.ts.map +0 -1
  217. package/build/src/models/authz_permission.js.map +0 -1
  218. package/build/src/models/authz_role.d.ts.map +0 -1
  219. package/build/src/models/authz_role.js.map +0 -1
  220. package/build/src/models/authz_role_permission.d.ts.map +0 -1
  221. package/build/src/models/authz_role_permission.js.map +0 -1
  222. package/build/src/openfga.d.ts.map +0 -1
  223. package/build/src/openfga.js.map +0 -1
  224. package/build/src/reconcile.d.ts.map +0 -1
  225. package/build/src/reconcile.js.map +0 -1
  226. package/build/src/relation_partition_trigger.d.ts.map +0 -1
  227. package/build/src/relation_partition_trigger.js.map +0 -1
  228. package/build/src/relations/check_many.d.ts.map +0 -1
  229. package/build/src/relations/check_many.js.map +0 -1
  230. package/build/src/relations/define_relations_config.d.ts.map +0 -1
  231. package/build/src/relations/define_relations_config.js.map +0 -1
  232. package/build/src/relations/manager.d.ts.map +0 -1
  233. package/build/src/relations/manager.js.map +0 -1
  234. package/build/src/relations/reconcile.d.ts.map +0 -1
  235. package/build/src/relations/reconcile.js.map +0 -1
  236. package/build/src/relations/tuple_key.d.ts.map +0 -1
  237. package/build/src/relations/tuple_key.js.map +0 -1
  238. package/build/src/relations_config_store.d.ts.map +0 -1
  239. package/build/src/relations_config_store.js.map +0 -1
  240. package/build/src/relations_reconcile.d.ts.map +0 -1
  241. package/build/src/relations_reconcile.js.map +0 -1
  242. package/build/src/scope_outbox.d.ts.map +0 -1
  243. package/build/src/scope_outbox.js.map +0 -1
  244. package/build/src/shared/backend_guard.d.ts.map +0 -1
  245. package/build/src/shared/backend_guard.js.map +0 -1
  246. package/build/src/shared/sql_expiry.d.ts.map +0 -1
  247. package/build/src/shared/sql_expiry.js.map +0 -1
  248. package/build/src/shared/transaction_guard.d.ts.map +0 -1
  249. package/build/src/shared/transaction_guard.js.map +0 -1
  250. package/build/src/sql_descendants.d.ts.map +0 -1
  251. package/build/src/sql_descendants.js.map +0 -1
  252. package/build/src/testing/contract.d.ts.map +0 -1
  253. package/build/src/testing/contract.js.map +0 -1
  254. package/build/src/testing/main.d.ts.map +0 -1
  255. package/build/src/testing/main.js.map +0 -1
  256. package/build/src/testing/migration_contract.d.ts.map +0 -1
  257. package/build/src/testing/migration_contract.js.map +0 -1
  258. package/build/src/testing/relations_contract.d.ts.map +0 -1
  259. package/build/src/testing/relations_contract.js.map +0 -1
  260. package/build/src/testing/relations_reconcile_contract.d.ts.map +0 -1
  261. package/build/src/testing/relations_reconcile_contract.js.map +0 -1
  262. package/build/src/testing/scope_outbox_contract.d.ts.map +0 -1
  263. package/build/src/testing/scope_outbox_contract.js.map +0 -1
  264. package/build/src/testing/scope_tree.d.ts.map +0 -1
  265. package/build/src/testing/scope_tree.js.map +0 -1
  266. package/build/src/testing/test_clock.d.ts.map +0 -1
  267. package/build/src/testing/test_clock.js.map +0 -1
  268. package/build/src/traits/authz_scopes.d.ts.map +0 -1
  269. package/build/src/traits/authz_scopes.js.map +0 -1
  270. package/build/src/traits/has_uuid.d.ts.map +0 -1
  271. package/build/src/traits/has_uuid.js.map +0 -1
  272. package/build/src/types.d.ts.map +0 -1
  273. package/build/src/types.js.map +0 -1
  274. package/build/stubs/main.d.ts.map +0 -1
  275. package/build/stubs/main.js.map +0 -1
@@ -27,13 +27,16 @@
27
27
  * clave, niega la dirección rancia y congela; la guardia de superficie de
28
28
  * `command_flags.spec.ts` impide re-exportarlo por ningún subpath.
29
29
  */
30
- import { AuthorizationConfigError, InvalidIdentityError, MassReconcileRefusedError, RelationTypeUnknownError, RelationUnknownError, RelationUsersetUnsupportedError, UnsupportedOperationError, } from '../errors.js';
30
+ import { ActorRequiredError, AuthorizationConfigError, CheckManyBatchError, DenyMigrationRefusedError, GroupDepthLimitError, InvalidIdentityError, InvalidRelationPageError, InvalidSlugError, MassReconcileRefusedError, NoScopeResolverError, RelationConfigError, RelationConfigUnknownKeyError, RelationDenyExpiryError, RelationNotDenyableError, RelationTypeUnknownError, RelationUnknownError, RelationUsersetUnsupportedError, UnknownScopeError, UnsupportedOperationError, isPartialWrite, markPartialWrite, } from '../errors.js';
31
31
  import { systemClock } from '../clock.js';
32
32
  import { isActiveExpiry, sameInstant } from '../expiry.js';
33
33
  import { assertPartition, scopeKey } from '../identity.js';
34
34
  import { isRelUserset } from '../types.js';
35
- import { assertRelationDeclared, assertSupportedUserset } from './define_relations_config.js';
35
+ import { assertRelationDeclared, assertSupportedUserset, RELATION_DENY_PREFIX, RELATION_USERSET_TYPE, relationsUp } from './define_relations_config.js';
36
+ import { denyReachCheck } from './deny_reach.js';
36
37
  import { relationTupleKey } from './tuple_key.js';
38
+ import { carriedDenies, denyCarrierGroups, relationTransitionOrder } from './tuple_order.js';
39
+ import { groupChainDepth, plannedRelateOptions } from './group_depth.js';
37
40
  /**
38
41
  * La clave con la que se casan origen y destino es `relationTupleKey` (plan
39
42
  * 2.5 · lote A, P3-4): la MISMA función pura y exportada que un consumidor
@@ -44,6 +47,115 @@ import { relationTupleKey } from './tuple_key.js';
44
47
  * (`tests/relation_tuple_key.spec.ts` lo vigila por fuente).
45
48
  */
46
49
  const tupleKey = relationTupleKey;
50
+ /**
51
+ * **Una tupla ENUMERADA por un store con una identidad fuera de la gramática**
52
+ * (⚪ R2 del re-ataque al cierre del lote A, decisión del dueño 2026-10-15 (2)):
53
+ * `document:<key>|ABC` escrita a mano con el SDK, una fila con `object_uuid`
54
+ * en mayúsculas insertada por otro proceso… `relationTupleKey` valida cada
55
+ * componente (⚪ A9) y hasta el cierre-2 `planPartition` la aplicaba tal cual a
56
+ * lo enumerado, así que un dato malo del BACKEND salía como 422
57
+ * `E_AUTHZ_INVALID_IDENTITY` —«pregunta inválida» del llamante, invariante 5—
58
+ * y bloqueaba el reconcile de la partición entera, también con `--dry-run`.
59
+ * Ahora se CLASIFICA y se LISTA: en el destino es `extra` con motivo
60
+ * (`--prune` pide al driver que la borre; `--dry-run` la lista con su línea),
61
+ * en el origen no se migra (la API no puede escribirla) y es deriva. Nunca un
62
+ * 422 por un dato que no vino del llamante.
63
+ */
64
+ /**
65
+ * **Los motivos de `RelationsReconcileDetail`, en RUNTIME** (cierre 2 de D2 · ⚪ N6): la guardia L9
66
+ * (`tests/relations_reconcile_l9.spec.ts`) los recorre y exige que cada uno tenga su entrada en `RELATION_RESIDUES`
67
+ * (dónde se clasifica: el PLAN o la PASADA) y su caso. `renewal-unsafe` (cierre 2 de D2 · 🟠 N1, solo `destino`): la
68
+ * renovación (otra caducidad) de una membresía `group:*#member` que TRANSPORTA un deny, en un destino que no declara
69
+ * `atomicRenewal` (renueva con delete + write, `openfga`): entre los dos `Write` el deny no alcanzaría al sujeto. Desde el
70
+ * cierre 5 de D2 (opción A) NIEGA SU PARTICIÓN (`partitionsRefused`: cero escrituras y cero borrados en ella, el resto
71
+ * migra); la línea la nombra con su receta y es deriva. `withheld-by-unsafe-deny` (cierre 4) se fue con la retención.
72
+ */
73
+ export const RELATIONS_RECONCILE_DETAIL_REASONS = Object.freeze([
74
+ 'invalid-identity',
75
+ 'userset-unsupported',
76
+ 'undeclared',
77
+ 'deny-conditional',
78
+ 'renewal-unsafe',
79
+ 'invalid-condition',
80
+ ]);
81
+ /**
82
+ * **Una receta POR MOTIVO** (cierre 1 de D2 · 🟡 6, ⚪ 11). `honor` = el deny ya está en el destino y lo que falta es
83
+ * que lo honre (opt-in), no que lo escriba. La de `undeclared` está EJECUTADA en `relations_reconcile_deny.spec`
84
+ * (re-declarar → `removeDeny` → retirar ⇒ la pasada entra).
85
+ */
86
+ export function denyRefusalRecipe(reason, objectType, relation, honor = false) {
87
+ const pair = `${objectType}#${relation}`;
88
+ switch (reason) {
89
+ case 'no-capability':
90
+ return (`el destino no sabe escribir ni honrar denies: migra a un destino que declare relationDenies (los dos drivers del ` +
91
+ `paquete lo hacen desde 2.5.0-alpha.3), o retira esos denies en el ORIGEN con removeDeny, y repite.`);
92
+ case 'no-deny-phase':
93
+ return (`un driver de terceros que declara relationDenies tiene que traer denyPhase(type, relation) (puerto, 2.5.0-alpha.3): ` +
94
+ `actualízalo; o retira esos denies en el ORIGEN con removeDeny, y repite.`);
95
+ case 'phase':
96
+ return honor
97
+ ? `declara el opt-in de '${objectType}' en la config del DESTINO (deny: 'read' basta para honrar un deny que ya está) ` +
98
+ `—provision → fijar modelId → desplegar—, o retira ese deny del destino con removeDeny, y repite.`
99
+ : `pon '${objectType}' en deny: 'write' en la config del DESTINO —provision → fijar modelId → desplegar, README ` +
100
+ `«Upgrading to 2.5.0-alpha.3»—, o retira esos denies en el ORIGEN con removeDeny, y repite.`;
101
+ case 'undeclared':
102
+ return (`${pair} no está declarado. Si es una relación RETIRADA con denies vivos: vuelve a declarar la relación ` +
103
+ `TEMPORALMENTE en la config del proceso que escribe en el ORIGEN (la relación basta: removeDeny no exige el opt-in), ` +
104
+ `retira esos denies con removeDeny, quítala otra vez y repite — removeDeny sobre un par NO declarado es 422 (F-05). ` +
105
+ `Si el destino debe tenerla: declárala en la config del DESTINO.`);
106
+ case 'model':
107
+ return (`el modelo FIJADO del destino no declara ${objectType}#denied_${relation}: publica el modelo desde la config del ` +
108
+ `destino (openfga:provision --store-id) y fija el OPENFGA_MODEL_ID nuevo en el proceso que corre la pasada (y en la ` +
109
+ `flota), y repite.`);
110
+ case 'unmarked':
111
+ return (`el driver que enumera esa tupla (origen o destino: lo dice el mensaje) no marca kind: 'deny' — un driver de terceros ` +
112
+ `o un proceso < 2.5.0-alpha.3 —: actualízalo y repite. La pasada no la toca: su dueño es ese driver.`);
113
+ case 'invalid':
114
+ return (`la identidad o el sujeto del deny no entra en la gramática del destino, y removeDeny también la rechazaría: bórralo ` +
115
+ `a mano en el ORIGEN (database: DELETE de esa fila de authz_relations; openfga: un Write con delete) y repite.`);
116
+ }
117
+ }
118
+ /**
119
+ * **La receta de una partición NEGADA** (cierre 5 de D2, opción A), por el conjunto de causas. HONESTA a propósito: en
120
+ * un destino sin `atomicRenewal` (`openfga`: FGA no reemplaza la misma clave en un `Write` —no hay upsert,
121
+ * openfga/openfga#2151—) NO existe forma sin ventana de cambiar la caducidad de una membresía que transporta un deny, ni
122
+ * de rehacer permanente un deny con caducidad. Ninguna de las salidas de abajo se presenta como segura.
123
+ */
124
+ export function partitionRefusalRecipe(reasons) {
125
+ const set = new Set(reasons);
126
+ const out = [];
127
+ if (set.has('renewal-unsafe')) {
128
+ out.push(`renewal-unsafe: en este destino NO hay forma sin ventana de cambiar la caducidad de una membresía cuyo grupo ` +
129
+ `transporta un deny (la renovación son dos Write; openfga/openfga#2151, sin upsert). Salidas para ESTA partición ` +
130
+ `(ninguna sin precio): (1) unrelate de esa membresía en el ORIGEN, y repite (sin --prune el destino la conserva hasta ` +
131
+ `su caducidad; con --prune se borra, como en el origen). (2) unrelate + relate de esa membresía en el DESTINO con la ` +
132
+ `caducidad del ORIGEN (o SIN caducidad, si el origen no la tiene) aceptando la ventana de forma EXPLÍCITA — mientras ` +
133
+ `dura, el sujeto no recibe el deny del grupo y concede lo que otro camino le dé y ese deny corte —, y repite la pasada. ` +
134
+ `(3) Esperar a que venza la caducidad vieja del DESTINO: entonces la tupla cuenta como ausente, la reescritura es una ` +
135
+ `escritura normal y la partición migra sola — y en ese intervalo el destino concede lo que ese deny corta (la línea ` +
136
+ `lo fecha). Cambiar la membresía a SIN caducidad en el ORIGEN NO desbloquea la partición (la del destino caduca: sigue ` +
137
+ `siendo un cambio de caducidad). Para el FUTURO, el patrón recomendado: membresías SIN caducidad en los grupos con ` +
138
+ `deny, que se terminan con unrelate.`);
139
+ }
140
+ if (set.has('deny-conditional')) {
141
+ out.push(`deny-conditional: un deny no caduca (D3) y este destino no puede rehacerlo permanente en una escritura. removeDeny + ` +
142
+ `deny de esa tupla en el DESTINO aceptando la ventana de forma EXPLÍCITA — entre los dos el deny no niega —, y repite.`);
143
+ }
144
+ if (set.has('too-deep')) {
145
+ out.push(`too-deep: la cadena de grupos anidados que dejaría la pasada en esta partición es más larga que el techo del DESTINO ` +
146
+ `(en openfga, más allá todo check de un no-miembro es 503 E_AUTHZ_RESOLUTION_LIMIT). Saltar la arista que excede ` +
147
+ `quitaría los denies que transporta: no se hace. Aplana la anidación en el ORIGEN (unrelate de la arista y miembros ` +
148
+ `metidos en un grupo menos profundo) —sin --prune cuenta también lo que el destino ya tiene de más—, o sube el techo ` +
149
+ `del destino (OPENFGA_RESOLVE_NODE_LIMIT en el servidor; maxGroupDepth en la config solo lo baja), y repite.`);
150
+ }
151
+ if (set.has('invalid-condition')) {
152
+ out.push(`invalid-condition: una membresía de group con una condición que el paquete no escribe ni sabe leer puede estar VIVA en ` +
153
+ `el servidor (un not_expired con current_time congelado lo está) y transportar los denies de la partición: el paquete ` +
154
+ `no sabe qué concede y no migra la partición a ciegas. Revisa esa tupla en su store (SDK/CLI de OpenFGA): bórrala, o ` +
155
+ `reescríbela por el paquete (unrelate + relate con la caducidad que corresponda), y repite.`);
156
+ }
157
+ return out.join(' ');
158
+ }
47
159
  /**
48
160
  * Enumera TODAS las tuplas de las particiones pedidas, repartidas por clave de
49
161
  * partición. UNA pasada con `enumerateRelationsMany` si el driver lo trae
@@ -105,13 +217,40 @@ export async function reconcileRelations(options) {
105
217
  // alpha.6: origen y destino se enumeran UNA vez para todas las particiones.
106
218
  const enumeratedByKey = await enumerateAllMany(from, partitions, 'origen');
107
219
  const destByKeyAll = await enumerateAllMany(to, partitions, 'destino');
108
- // Primero se DECIDE todo (K diffs, K seguros R-17) y luego se escribe: un
109
- // seguro que salte en la partición 3 no deja las dos primeras a medias.
220
+ // Primero se DECIDE todo (K diffs, K seguros R-17, la puerta del deny) y luego se escribe: un seguro que salte en la
221
+ // partición 3 no deja las dos primeras a medias.
222
+ // Cierre 2 de D2 · 🟡 N3: el auto-corte del orden (un deny de `d` corta un allow de `a` sii `d ∈ up*(a)`, D5) sale de la
223
+ // config del DESTINO; sin ella el orden no da ningún allow por cortado (lo conservador). Cierre 4: también la retención.
224
+ const cuts = toConfig
225
+ ? (objectType, denied, allowed) => {
226
+ try {
227
+ return relationsUp(toConfig, objectType, allowed).includes(denied);
228
+ }
229
+ catch {
230
+ return false;
231
+ }
232
+ }
233
+ : undefined;
234
+ // R1 (decisión del dueño 2026-10-17 (5); 🔴 C-1): el techo de anidación del DESTINO — el calibrado en `openfga`, de
235
+ // solo lectura, así que también en `--dry-run` (mismo 500 si la config promete más de lo que el servidor resuelve) —.
236
+ // Un driver sin el método no tiene techo para el plan.
237
+ // Solo si hay alguna arista de anidación en el origen o en el destino de las particiones pedidas: sin ninguna, la cadena
238
+ // final es 0 y no hay nada que preguntar (ni calibración que pagar, ni la puerta del modelo de la calibración por
239
+ // delante de la del deny).
240
+ const anyNesting = [...enumeratedByKey.values(), ...destByKeyAll.values()].some((tuples) => tuples.some(isNestingTuple));
241
+ const groupDepthLimit = anyNesting && typeof to.groupDepthLimit === 'function' ? await to.groupDepthLimit() : null;
110
242
  const plans = [];
111
243
  for (const partition of partitions) {
112
244
  const enumerated = enumeratedByKey.get(scopeKey(partition)) ?? [];
113
245
  const destTuples = destByKeyAll.get(scopeKey(partition)) ?? [];
114
- plans.push(planPartition(partition, enumerated, destTuples, { now, prune, toConfig }));
246
+ plans.push(planPartition(partition, enumerated, destTuples, {
247
+ now,
248
+ prune,
249
+ toConfig,
250
+ atomicRenewal: to.capabilities?.atomicRenewal === true,
251
+ cuts,
252
+ groupDepthLimit,
253
+ }));
115
254
  }
116
255
  const report = {
117
256
  dryRun,
@@ -126,7 +265,14 @@ export async function reconcileRelations(options) {
126
265
  allowMassDelete,
127
266
  skipped: { expired: 0, undeclared: 0, invalid: 0, usersetUnsupported: 0 },
128
267
  details: [],
268
+ denies: { written: 0, updated: 0, deleted: 0, unchanged: 0, extra: 0 },
269
+ partitions: plans.map((plan) => emptyPartitionReport(plan.partition)),
270
+ orderResidue: [],
271
+ partitionsRefused: [],
272
+ tooDeep: [],
129
273
  };
274
+ if (options.denyReach !== undefined)
275
+ report.denyReach = detectDenyReach(options.denyReach, enumeratedByKey, destByKeyAll);
130
276
  for (const plan of plans) {
131
277
  // **El seguro de borrado masivo** (R-17, paridad con `authz:reconcile` de
132
278
  // roles / AA2). `prune` + un ORIGEN sin una sola tupla utilizable + un destino
@@ -134,15 +280,17 @@ export async function reconcileRelations(options) {
134
280
  // vacío: vaciar el destino entero se rechaza con 500 antes de tocar nada,
135
281
  // salvo `allowMassDelete`. En `--dry-run` no lanza, lo marca. «Utilizable» =
136
282
  // una tupla VIVA del origen que cabe en el destino (las que `skipped`
137
- // recoge no migran, así que no cuentan como respaldo).
283
+ // recoge no migran, así que no cuentan como respaldo) — **denies incluidos** (D2).
138
284
  if (plan.massDelete && !dryRun && !allowMassDelete) {
139
- throw new MassReconcileRefusedError(`authz:relations:reconcile --prune borraría ${plan.toDelete.length} tupla(s) de relación del destino` +
285
+ throw new MassReconcileRefusedError(`authz:relations:reconcile --prune borraría ${plan.allowDelete.length + plan.denyDelete.length} tupla(s) de relación del destino` +
140
286
  `${partitions.length > 1 ? ` (partición ${scopeKey(plan.partition)})` : ''} y el ` +
141
287
  `ORIGEN no ha aportado NI UNA utilizable (${plan.enumerated} leídas${describeSkipped(plan.skipped)}). Eso es la firma de un --from ` +
142
288
  `equivocado o de un store vacío, no de un destino que sobra: esta pasada dejaría la partición sin ` +
143
289
  `una sola relación. Comprueba el --from y el store; si de verdad quieres vaciarlo, --allow-mass-delete.` +
144
290
  `${partitions.length > 1 ? ' No se ha escrito nada en ninguna partición.' : ''}`);
145
291
  }
292
+ if (plan.tooDeep)
293
+ report.tooDeep.push({ partition: scopeKey(plan.partition), ...plan.tooDeep });
146
294
  report.massDelete ||= plan.massDelete;
147
295
  report.skipped.expired += plan.skipped.expired;
148
296
  report.skipped.undeclared += plan.skipped.undeclared;
@@ -152,104 +300,328 @@ export async function reconcileRelations(options) {
152
300
  if (!report.modelDrift.includes(type))
153
301
  report.modelDrift.push(type);
154
302
  }
155
- for (const plan of plans) {
156
- let written = plan.toWrite.length;
157
- let updated = plan.toUpdate.length;
158
- if (!dryRun) {
159
- // La caducidad viaja EXPLÍCITA (`Date` la fija, `null` la quita): un
160
- // `relate` sin opciones PRESERVARÍA la del destino (invariante 10).
161
- // L-0: el DRIVER del destino aplica F-05 (`assertRelationDeclared`), así que
162
- // sin `toConfig` —o con una que no coincida con la del driver— es SU 422 el
163
- // que embuda la tupla no declarada: se cuenta en `skipped.undeclared` y se
164
- // descuenta de `written`/`updated`. Nunca un throw a mitad de pasada por
165
- // una tupla que el reporte puede contar, y nunca un silencio.
166
- for (const tuple of plan.toWrite) {
167
- if (await relateOrSkip(to, tuple))
168
- continue;
169
- report.skipped.undeclared += 1;
170
- written -= 1;
171
- }
172
- for (const tuple of plan.toUpdate) {
173
- if (await relateOrSkip(to, tuple))
174
- continue;
175
- report.skipped.undeclared += 1;
176
- updated -= 1;
177
- }
303
+ // **La puerta del deny, por el DRIVER DESTINO y en la fase de PLAN** (lote D · D2; S6, R2-5): antes de la primera
304
+ // escritura en CUALQUIER partición. Un deny que no se puede escribir (o que el destino no honraría) ⇒ 500 con CERO
305
+ // escrituras; jamás `skipped` (escribir los allows sin él concedería lo que el origen niega). `--dry-run` lo marca.
306
+ const refusals = await denyGate(to, plans);
307
+ if (refusals.length > 0) {
308
+ if (!dryRun)
309
+ throw denyRefusedError(refusals, partitions.length);
310
+ report.denyRefused = refusals;
311
+ }
312
+ // Lo escrito en TODA la pasada: decide la marca de escritura parcial si algo revienta (invariante 13).
313
+ const progress = { writes: 0 };
314
+ for (let i = 0; i < plans.length; i++) {
315
+ const plan = plans[i];
316
+ const row = report.partitions[i];
317
+ // Cierre 5 de D2 (opción A): la partición NEGADA por el plan no se toca —cero escrituras y cero borrados—; las demás
318
+ // siguen. Mismo resultado en `--dry-run` y en la pasada real.
319
+ if (plan.refusal) {
320
+ refusePartition(plan, row, report, prune);
321
+ continue;
322
+ }
323
+ try {
324
+ await applyPartition(plan, row, report, { to, dryRun, prune, progress, cuts });
325
+ row.status = 'done';
326
+ }
327
+ catch (error) {
328
+ row.status = 'failed';
329
+ throw partialReconcileError(error, report, progress.writes);
330
+ }
331
+ addPartition(report, row);
332
+ }
333
+ return report;
334
+ }
335
+ /**
336
+ * **Una partición NEGADA** (cierre 5 de D2, opción A): cero escrituras, cero borrados. El reporte dice lo que había
337
+ * (`unchanged`), lo que sobra y se queda (`extra`, también con `--prune`: no se borra nada) y, en `partitionsRefused`, por
338
+ * qué, la receta y la deriva pendiente. Los residuos de la partición (`details`) se listan igual que en una aplicada.
339
+ */
340
+ function refusePartition(plan, row, report, prune) {
341
+ const refusal = plan.refusal;
342
+ const PRUNABLE = new Set(['invalid-identity', 'userset-unsupported', 'undeclared', 'invalid-condition']);
343
+ for (const detail of plan.details) {
344
+ report.details.push(prune && detail.side === 'destino' && PRUNABLE.has(detail.reason) ? { ...detail, pruned: false } : detail);
345
+ }
346
+ row.status = 'refused';
347
+ row.unchanged = plan.unchanged + plan.denyUnchanged;
348
+ row.denies.unchanged = plan.denyUnchanged;
349
+ row.denies.extra = plan.denyDelete.length + plan.denyConditional;
350
+ row.extra = plan.allowDelete.length + plan.denyDelete.length + plan.destResidue + plan.denyConditional;
351
+ report.partitionsRefused.push(refusal);
352
+ addPartition(report, row);
353
+ }
354
+ /** Un reporte de partición a cero, `pending` hasta que se aplica. */
355
+ function emptyPartitionReport(partition) {
356
+ return {
357
+ partition: scopeKey(partition),
358
+ status: 'pending',
359
+ written: 0,
360
+ updated: 0,
361
+ deleted: 0,
362
+ unchanged: 0,
363
+ extra: 0,
364
+ denies: { written: 0, updated: 0, deleted: 0, unchanged: 0, extra: 0 },
365
+ };
366
+ }
367
+ /** Suma una partición al total del reporte. */
368
+ function addPartition(report, row) {
369
+ report.written += row.written;
370
+ report.updated += row.updated;
371
+ report.deleted += row.deleted;
372
+ report.unchanged += row.unchanged;
373
+ report.extra += row.extra;
374
+ for (const k of ['written', 'updated', 'deleted', 'unchanged', 'extra'])
375
+ report.denies[k] += row.denies[k];
376
+ }
377
+ /**
378
+ * **El reporte PARCIAL viaja en el error** (roadmap 2.5): las particiones `done` y lo que la `failed` llegó a escribir,
379
+ * sumados; `partial: true` si escribió algo (⚪ 10: `false` con cero escrituras); y la marca de escritura parcial (`markPartialWrite`, invariante 13) si la pasada escribió
380
+ * algo. El error, su clase y su `code` no cambian (un 503 sigue siendo un 503).
381
+ */
382
+ function partialReconcileError(error, report, writes) {
383
+ const failed = report.partitions.find((p) => p.status === 'failed');
384
+ if (failed)
385
+ addPartition(report, failed);
386
+ // ⚪ 10 (cierre 1 de D2): «parcial» solo si la pasada YA escribió algo; con cero escrituras el reporte viaja igual
387
+ // (`partial: false`) y el comando dice que no se escribió nada — la marca `markPartialWrite` y la letra, de acuerdo.
388
+ // Cierre 2 de D2 · 🟠 N1: o lo dice el DRIVER — un `relate` de renovación de `openfga` (delete + write) cuyo 2.º
389
+ // `Write` falla ya BORRÓ: el error llega marcado (`isPartialWrite`) aunque la pasada no hubiera completado ninguna
390
+ // escritura, y «sin escribir nada» sería falso.
391
+ const partial = writes > 0 || isPartialWrite(error);
392
+ report.partial = partial;
393
+ const out = partial && error && typeof error === 'object' ? markPartialWrite(error) : error;
394
+ try {
395
+ if (out && typeof out === 'object' && Object.isExtensible(out))
396
+ out.report = report;
397
+ }
398
+ catch {
399
+ /* un error congelado no lleva el reporte; el original no se sustituye */
400
+ }
401
+ return out;
402
+ }
403
+ /**
404
+ * **Las fases de una partición** (lote D · D2; analista §4.7, juez ronda 2 · R2-5, tester S4/S5), en el ORDEN del punto
405
+ * de paso ÚNICO `relationTransitionOrder` (`src/relations/tuple_order.ts`, compartido con las purgas de `openfga`). Desde
406
+ * el cierre 2 de D2 (🟡 N3) el orden es el de la TRANSICIÓN entera —escrituras, renovaciones y poda en UNA secuencia—:
407
+ * (1) denies nuevos y rehechos (`to.deny`), (2) con `prune` allows sobrantes (`unrelate`), (3) membresías
408
+ * `group:*#member` que entran y que salen por el grafo de precedencias, (4) allows nuevos y renovaciones (`relate`), (5)
409
+ * con `prune` denies sobrantes (`to.removeDeny`), los ÚLTIMOS. Hasta el cierre 2 TODAS las escrituras iban antes que
410
+ * TODA la poda, y un ex-miembro componía con un allow nuevo (U ∈ g sobra y `g#member viewer X` es nuevo: cortada tras la
411
+ * primera escritura, U concedía sobre X). Cortada en cualquier punto, ningún estado concede lo que no concedían ni el
412
+ * estado inicial ni el final, salvo el RESIDUO que `relationTransitionOrder` devuelve (un ciclo del grafo de precedencias;
413
+ * se lista en `report.orderResidue`). **Ningún deny pasa por `relateOrSkip`**: un error al escribir un deny se propaga
414
+ * (la puerta del plan ya decidió que se puede escribir; si el driver dice otra cosa, es una contradicción que aborta).
415
+ * Son las propiedades `relations.reconcile.order` y `relations.order.exhaustive` de la guardia de superficie (L10).
416
+ */
417
+ async function applyPartition(plan, row, report, context) {
418
+ const { to, dryRun, prune, progress, cuts } = context;
419
+ // Cierre 4 de D2 · 🟡 C3-2: el deny condicional que el destino no puede rehacer se decide en el PLAN (dry == real).
420
+ const conditional = plan.denyConditional;
421
+ row.unchanged = plan.unchanged + plan.denyUnchanged;
422
+ row.denies.unchanged = plan.denyUnchanged;
423
+ // ⚪ R2 + cierres 1, 2 y 3 del lote B (una regla por FILA): el residuo del destino y lo enumerado fuera de la
424
+ // gramática se LISTA siempre, en los dos lados; con `prune` el del destino lleva `pruned: false` desde el plan. El
425
+ // `renewal-unsafe` (cierre 2 de D2 · 🟠 N1) no es un borrado: no lleva `pruned`.
426
+ // Cierre 4 de D2: `deny-conditional` (ya del PLAN) lleva `pruned: false` siempre.
427
+ const PRUNABLE = new Set(['invalid-identity', 'userset-unsupported', 'undeclared', 'invalid-condition']);
428
+ for (const detail of plan.details) {
429
+ report.details.push(prune && detail.side === 'destino' && PRUNABLE.has(detail.reason) ? { ...detail, pruned: false } : detail);
430
+ }
431
+ // Cierre 2 de D2 · 🟡 N3: UNA secuencia para la transición entera (el mismo cálculo en `--dry-run` y en la real).
432
+ // Cierre 3 de D2 · 🟡 C2-2: las sobrantes VENCIDAS no conectan nada: se borran PRIMERO y FUERA del orden. Dentro, un deny
433
+ // vencido que se poda contaba en el grafo unión como un deny que «ya niega» y quitaba una precedencia (medido por la
434
+ // fuerza bruta con vigencia: U ∈ h entraba antes de que h ⊂ g saliera).
435
+ const liveDeletes = [...plan.allowDelete, ...plan.denyDelete].filter((tuple) => !plan.expiredDelete.has(tuple));
436
+ const transition = relationTransitionOrder({
437
+ writes: [...plan.denyWrite, ...plan.toWrite],
438
+ deletes: prune ? liveDeletes : [],
439
+ renewals: [...plan.denyUpdate, ...plan.toUpdate],
440
+ // Cierre 4 de D2 · 🟡 horizonte: las renovaciones que ACORTAN salen en su caducidad nueva (y las que alargan entran
441
+ // en la vieja): el orden las trata así para que un corte que dure hasta entonces no conceda.
442
+ shortening: plan.shortening,
443
+ context: plan.orderContext,
444
+ }, orderView, cuts ? { cuts } : {});
445
+ for (const tuple of transition.residue)
446
+ report.orderResidue.push(describeTuple(tuple));
447
+ let refused = 0;
448
+ if (dryRun) {
449
+ row.written = plan.toWrite.length + plan.denyWrite.length;
450
+ row.updated = plan.toUpdate.length + plan.denyUpdate.length;
451
+ row.denies.written = plan.denyWrite.length;
452
+ row.denies.updated = plan.denyUpdate.length;
453
+ if (prune) {
454
+ row.deleted = plan.allowDelete.length + plan.denyDelete.length;
455
+ row.denies.deleted = plan.denyDelete.length;
178
456
  }
179
- // ⚪ R2 + cierre 1 y cierre 2 del lote B (🟡 N3, una regla, no dos): lo
180
- // enumerado fuera de la gramática (`invalid-identity`) y el residuo pre-2.5
181
- // de B3 (`userset-unsupported`) se LISTAN siempre, en los dos lados; con
182
- // `prune` el del destino lleva `pruned: false` desde el plan — también en
183
- // `--dry-run`—, porque NADIE lo borra: ni `toDelete` lo contiene ni el driver
184
- // lo ve (hasta el cierre 2 `invalid-identity` se le pedía al driver y los dos
185
- // del paquete lo rechazaban por el puerto, R-16). Cuenta en `extra` (abajo)
186
- // y es deriva en los cuatro modos.
187
- for (const invalid of plan.invalid) {
188
- const detail = {
189
- side: invalid.side,
190
- reason: 'invalid-identity',
191
- tuple: invalid.tuple,
192
- message: invalid.message,
193
- };
194
- if (invalid.side === 'destino' && prune)
195
- detail.pruned = false;
196
- report.details.push(detail);
197
- }
198
- for (const residue of plan.usersetResidue) {
199
- const detail = {
200
- side: residue.side,
201
- reason: 'userset-unsupported',
202
- tuple: residue.tuple,
203
- message: residue.message,
204
- };
205
- if (residue.side === 'destino' && prune)
206
- detail.pruned = false;
207
- report.details.push(detail);
208
- }
209
- // Cierre 3 del lote B · 🟠 N9, capa 1: la fila del DESTINO cuya relación o
210
- // tipo `toConfig` no declara (una relación RETIRADA con filas vivas) se
211
- // clasifica en el PLAN con la MISMA regla que los otros dos motivos: `extra`
212
- // + `details`, `pruned: false` bajo `prune` (también en `--dry-run`), nunca
213
- // en `toDelete` — `unrelate` la rechazaría por F-05 y hasta el cierre 3 ese
214
- // 422 tumbaba la pasada a mitad, después de escribir.
215
- for (const residue of plan.undeclaredResidue) {
216
- const detail = { side: 'destino', reason: 'undeclared', tuple: residue.tuple, message: residue.message };
217
- if (prune)
218
- detail.pruned = false;
219
- report.details.push(detail);
220
- }
221
- let deleted = prune ? plan.toDelete.length : 0;
222
- let refused = 0;
223
- if (!dryRun && prune) {
224
- // Todo lo que hay en `toDelete` pasó la gramática Y, con `toConfig`, F-05
225
- // del destino: un 422 del driver aquí es el driver diciendo otra cosa que
226
- // el plan — o el plan sin `toConfig`, que no pudo anticipar F-05 (🟠 N9,
227
- // capa 2). **La poda NUNCA lanza por un 422 de la familia de relaciones**:
228
- // se clasifica por `code` con la MISMA regla (`extra` + `details`,
229
- // `pruned: false`, deriva), se sigue con las demás filas y el comando sale
230
- // ≠ 0 con la línea «sobra (<motivo>) destino:». Un 422 de OTRA familia o
231
- // cualquier otro error sigue propagándose: eso sí es una contradicción que
232
- // el reporte no sabe contar (R2-5: el plan clasifica, el driver no absorbe
233
- // lo que no es suyo).
234
- for (const tuple of plan.toDelete) {
235
- const refusal = await unrelateOrClassify(to, tuple);
236
- if (refusal === null)
457
+ }
458
+ else {
459
+ // La caducidad de un allow viaja EXPLÍCITA (`Date` la fija, `null` la quita): un `relate` sin opciones PRESERVARÍA
460
+ // la del destino (invariante 10). L-0: el DRIVER del destino aplica F-05 (`assertRelationDeclared`), así que sin
461
+ // `toConfig` —o con una que no coincida con la del driver— es SU 422 el que embuda la tupla no declarada: se cuenta en
462
+ // `skipped.undeclared` y se descuenta de `written`/`updated` (capa 2 del registro `RELATION_REJECTIONS`). La poda
463
+ // NUNCA lanza por un 422 de la familia de relaciones (🟠 N9, capa 2): se clasifica por `code` y se sigue.
464
+ const steps = [
465
+ ...(prune ? [...plan.expiredDelete].map((item) => ({ item, op: 'delete' })) : []),
466
+ ...transition.steps,
467
+ ];
468
+ for (const step of steps) {
469
+ const tuple = step.item;
470
+ if (step.op === 'delete') {
471
+ const op = kindOf(tuple) === 'deny' ? 'removeDeny' : 'unrelate';
472
+ const refusal = await deleteOrClassify(to, tuple, op);
473
+ if (refusal === null) {
474
+ progress.writes++;
475
+ row.deleted += 1;
476
+ if (op === 'removeDeny')
477
+ row.denies.deleted += 1;
237
478
  continue;
479
+ }
238
480
  report.details.push({ side: 'destino', reason: refusal.reason, tuple: describeTuple(tuple), message: refusal.message, pruned: false });
239
481
  refused += 1;
240
- deleted -= 1;
482
+ if (op === 'removeDeny')
483
+ row.denies.extra += 1;
484
+ continue;
485
+ }
486
+ const counter = step.op === 'write' ? 'written' : 'updated';
487
+ if (kindOf(tuple) === 'deny') {
488
+ // Cierre 4 de D2 · 🟡 C3-2: el deny condicional que el destino no puede rehacer ya lo apartó el PLAN (sin
489
+ // `atomicRenewal`): desde el cierre 5 NIEGA su partición entera, que no llega aquí. Si el driver lo dice igual (500 `denyConditional` de un
490
+ // driver que declara `atomicRenewal`), es una contradicción con su capacidad: se propaga (hasta el cierre 4 la
491
+ // pasada lo listaba y SEGUÍA escribiendo los allows que ese deny cortaba).
492
+ await to.deny(tuple.subject, tuple.relation, tuple.object, tuple.partition);
493
+ progress.writes++;
494
+ row[counter] += 1;
495
+ row.denies[counter] += 1;
496
+ continue;
241
497
  }
498
+ if (await relateOrSkip(to, tuple)) {
499
+ progress.writes++;
500
+ row[counter] += 1;
501
+ continue;
502
+ }
503
+ report.skipped.undeclared += 1;
242
504
  }
243
- report.written += written;
244
- report.updated += updated;
245
- report.deleted += deleted;
246
- report.unchanged += plan.unchanged;
247
- // El residuo del destino (los tres motivos) sobra SIEMPRE, con o sin `prune`:
248
- // no está en `toDelete`, así que se suma aparte — más lo que el driver
249
- // rechazó con un 422 de la familia en la pasada real (capa 2 de N9).
250
- report.extra += (prune ? 0 : plan.toDelete.length) + plan.destResidue + refused;
251
505
  }
252
- return report;
506
+ if (!prune)
507
+ row.denies.extra += plan.denyDelete.length;
508
+ row.denies.extra += conditional;
509
+ // El residuo del destino (los tres motivos) sobra SIEMPRE, con o sin `prune`: no está en `toDelete`, así que se suma
510
+ // aparte — más lo que el driver rechazó con un 422 de la familia en la pasada real (capa 2 de N9). (Desde el cierre 5
511
+ // una partición con `deny-conditional` está NEGADA y no llega aquí: `conditional` es 0 en una partición aplicada.)
512
+ row.extra = (prune ? 0 : plan.allowDelete.length + plan.denyDelete.length) + plan.destResidue + refused + conditional;
513
+ }
514
+ /**
515
+ * **La puerta del deny** (lote D · D2): las negativas de TODAS las particiones, decididas por el DRIVER DESTINO
516
+ * (`capabilities.relationDenies` + `denyPhase`, que en `openfga` comprueba además el modelo fijado, D-31) y por lo que
517
+ * el plan ya vio (denies sin `kind`, F-05 con `toConfig`, identidad/sujeto que el destino no admite). Un par
518
+ * tipo/relación se pregunta UNA vez. Para ESCRIBIR un deny el tipo tiene que estar en `'write'`; para uno que el
519
+ * destino YA tiene basta con que lo honre (opt-in en cualquier fase). Nunca escribe.
520
+ */
521
+ async function denyGate(to, plans) {
522
+ const refusals = [];
523
+ const seen = new Set();
524
+ const push = (refusal) => {
525
+ const id = `${refusal.objectType}#${refusal.relation}:${refusal.reason}`;
526
+ if (seen.has(id))
527
+ return;
528
+ seen.add(id);
529
+ refusals.push(refusal);
530
+ };
531
+ for (const plan of plans)
532
+ for (const refusal of plan.denyRefusals)
533
+ push(refusal);
534
+ // Qué fase hace falta por par: 'write' si hay algo que escribir; 'honored' si solo hay denies que ya están.
535
+ const needed = new Map();
536
+ for (const plan of plans) {
537
+ // Cierre 5 de D2: una partición NEGADA por el plan no escribe nada — sus denies no piden fase.
538
+ if (plan.refusal)
539
+ continue;
540
+ for (const [list, write] of [
541
+ [[...plan.denyWrite, ...plan.denyUpdate], true],
542
+ [plan.denyPresent, false],
543
+ ]) {
544
+ for (const tuple of list) {
545
+ const id = `${tuple.object.type}#${tuple.relation}`;
546
+ const prev = needed.get(id);
547
+ needed.set(id, { objectType: tuple.object.type, relation: tuple.relation, write: (prev?.write ?? false) || write });
548
+ }
549
+ }
550
+ }
551
+ if (needed.size === 0)
552
+ return refusals;
553
+ const capable = to.capabilities?.relationDenies === true && typeof to.deny === 'function' && typeof to.removeDeny === 'function';
554
+ for (const { objectType, relation, write } of needed.values()) {
555
+ if (!capable) {
556
+ push({
557
+ objectType,
558
+ relation,
559
+ reason: 'no-capability',
560
+ message: `el destino no declara relationDenies (o no trae deny/removeDeny): no puede escribir ni honrar el deny de ` +
561
+ `${objectType}#${relation}`,
562
+ recipe: denyRefusalRecipe('no-capability', objectType, relation),
563
+ });
564
+ continue;
565
+ }
566
+ if (typeof to.denyPhase !== 'function') {
567
+ push({
568
+ objectType,
569
+ relation,
570
+ reason: 'no-deny-phase',
571
+ message: `el destino declara relationDenies pero no trae denyPhase: no puede decir si ${objectType} admite deny (opt-in por tipo)`,
572
+ recipe: denyRefusalRecipe('no-deny-phase', objectType, relation),
573
+ });
574
+ continue;
575
+ }
576
+ let phase;
577
+ try {
578
+ phase = await to.denyPhase({ type: objectType }, relation);
579
+ }
580
+ catch (error) {
581
+ const rejection = error?.status === 422 ? RELATION_REJECTIONS[String(error?.code)] : undefined;
582
+ if (rejection?.kind === 'classified' && rejection.reason === 'undeclared') {
583
+ push({ objectType, relation, reason: 'undeclared', message: String(error?.message ?? error), recipe: denyRefusalRecipe('undeclared', objectType, relation) });
584
+ continue;
585
+ }
586
+ // Cierre de R3 (⚪ R3-A7): «el modelo fijado no declara denied_*» es SOLO el 500 de la puerta del modelo (sin
587
+ // respuesta HTTP en la cadena de `cause`). Un 500 E_AUTHZ_CONFIG que trae la respuesta del servidor (401/403/404,
588
+ // store inexistente, un 4xx desconocido: R3) es del DESPLIEGUE, no del modelo: se propaga con su letra en vez de
589
+ // reportarse como negativa `reason: 'model'` con la receta del modelo (fail-closed igual: nada se escribe).
590
+ if (error?.status === 500 && error?.code === AuthorizationConfigError.code && !carriesHttpResponse(error)) {
591
+ push({ objectType, relation, reason: 'model', message: String(error?.message ?? error), recipe: denyRefusalRecipe('model', objectType, relation) });
592
+ continue;
593
+ }
594
+ throw error;
595
+ }
596
+ if (write ? phase !== 'write' : phase === null) {
597
+ push({
598
+ objectType,
599
+ relation,
600
+ reason: 'phase',
601
+ message: `en el DESTINO el tipo '${objectType}' está ${phase === null ? 'SIN opt-in de deny' : `en la fase '${phase}'`} y ` +
602
+ `${write ? "escribir un deny exige deny: 'write' (en TODO destino)" : 'un deny que ya está exige que el destino lo honre (opt-in)'} ` +
603
+ `(su config, no la spec persistida)` +
604
+ // ⚪ 11 (cierre 1 de D2): por qué se niega aunque un destino `database` honre la fila igual (D8).
605
+ (write
606
+ ? ''
607
+ : '. Un destino database honra un denied_* existente también sin opt-in (D8), pero la pasada no puede saberlo por ' +
608
+ 'el puerto: denyPhase = null significa «sin opt-in», y certificar un deny que el destino no declara honrar ' +
609
+ 'convertiría un detalle de implementación en la garantía'),
610
+ recipe: denyRefusalRecipe('phase', objectType, relation, !write),
611
+ });
612
+ }
613
+ }
614
+ return refusals;
615
+ }
616
+ /** El 500 de la puerta (S6/R2-5): la letra empieza por el prefijo ESTABLE «deny NO migrable:» (punto 7). */
617
+ function denyRefusedError(refusals, partitions) {
618
+ const error = new DenyMigrationRefusedError(`deny NO migrable: authz:relations:reconcile se NIEGA — ${refusals.length} caso(s): ` +
619
+ refusals.map((r) => `(${r.reason}) ${r.objectType}#${r.relation}: ${r.message}`).join('; ') +
620
+ `. Perder un deny no es una pérdida que se pueda declarar (escribir los allows sin él concedería lo que el origen niega). ` +
621
+ `No se ha escrito nada${partitions > 1 ? ' en ninguna partición' : ''}. Recetas (una por motivo): ` +
622
+ refusals.map((r) => `(${r.reason}) ${r.recipe}`).join(' '));
623
+ error.refusals = Object.freeze(refusals.map((r) => Object.freeze({ ...r })));
624
+ return error;
253
625
  }
254
626
  /**
255
627
  * ⚪ N8 (cierre 2 del lote B): el desglose del seguro R-17, cada motivo POR SU
@@ -275,12 +647,12 @@ function describeTuple(tuple) {
275
647
  const subject = isRelUserset(s) ? `${s.object?.type}:${s.object?.id}#${s.relation}` : `${s?.type}:${s?.uuid}`;
276
648
  const p = tuple?.partition;
277
649
  const partition = p?.type === 'app' ? 'app' : `${p?.type}|${p?.uuid}`;
278
- return `${partition} ${tuple?.object?.type}:${tuple?.object?.id}#${tuple?.relation}@${subject}`;
650
+ return `${tuple?.kind === 'deny' ? '[deny] ' : ''}${partition} ${tuple?.object?.type}:${tuple?.object?.id}#${tuple?.relation}@${subject}`;
279
651
  }
280
652
  /** `tupleKey` de una tupla ENUMERADA: la clave, o el motivo por el que su identidad no entra en la gramática (⚪ R2). */
281
653
  function enumeratedKey(tuple) {
282
654
  try {
283
- return { key: tupleKey({ ...tuple, kind: 'allow' }) };
655
+ return { key: tupleKey({ ...tuple, kind: kindOf(tuple) }) };
284
656
  }
285
657
  catch (error) {
286
658
  if (error instanceof InvalidIdentityError)
@@ -356,7 +728,7 @@ function partitionsOf(options) {
356
728
  });
357
729
  }
358
730
  function planPartition(partition, enumerated, destTuples, context) {
359
- const { now, prune, toConfig } = context;
731
+ const { now, prune, toConfig, atomicRenewal, cuts, groupDepthLimit = null } = context;
360
732
  // R-15: la caducada LLEGA del origen (el puerto lo exige) y aquí se DESCARTA
361
733
  // contándola: no se migra ni respalda nada en el destino. Decidido con UN
362
734
  // `now` para toda la pasada.
@@ -365,23 +737,63 @@ function planPartition(partition, enumerated, destTuples, context) {
365
737
  // pasada real dan los mismos números— y tampoco respalda nada en el destino
366
738
  // (una que ya estuviera allí es `extra`, y `--prune` la barre). El tipo se
367
739
  // reporta además en `modelDrift` (deriva del modelo fusionado).
740
+ // Lote D · D2: un DENY nunca se descarta así — lo que impide escribirlo es una NEGATIVA de la pasada (500), jamás
741
+ // `skipped` (perder un deny no es una pérdida que se pueda declarar). Solo la caducidad lo descarta (no niega nada).
368
742
  const skipped = { expired: 0, undeclared: 0, invalid: 0, usersetUnsupported: 0 };
369
743
  const modelDrift = [];
370
744
  const sourceTuples = [];
371
745
  const invalid = [];
372
746
  const usersetResidue = [];
747
+ const undeclaredResidue = [];
748
+ const denyRefusals = [];
749
+ /** Cierre de L11 (🟡 A2): las membresías (origen o destino) con una condición que el paquete no escribe ni sabe leer. */
750
+ const invalidCarriers = [];
751
+ const refuse = (tuple, reason, message) => {
752
+ const objectType = String(tuple?.object?.type);
753
+ const relation = String(tuple?.relation);
754
+ denyRefusals.push({ objectType, relation, reason, message, recipe: denyRefusalRecipe(reason, objectType, relation) });
755
+ };
373
756
  for (const tuple of enumerated) {
757
+ // L11 (injerto 3 del juez): una tupla del ORIGEN cuya condición el paquete no escribe o no sabe leer
758
+ // (`invalidCondition`, `fgaTupleExpiry`) NO se migra —su `expiresAt` null no es «permanente»—; se lista y es deriva.
759
+ // Un deny así NIEGA la PASADA (500 `E_AUTHZ_DENY_MIGRATION_REFUSED`: perder un deny no es una pérdida declarable); una
760
+ // MEMBRESÍA así en una partición con algún deny niega SU partición (cierre de L11, abajo).
761
+ if (typeof tuple?.invalidCondition === 'string') {
762
+ if (kindOf(tuple) === 'deny') {
763
+ refuse(tuple, 'invalid', `condición que el paquete no escribe: ${tuple.invalidCondition}`);
764
+ continue;
765
+ }
766
+ skipped.invalid += 1;
767
+ invalid.push({ side: 'origen', reason: 'invalid-condition', tuple: describeTuple(tuple), message: tuple.invalidCondition });
768
+ // Cierre de L11 (🟡 A2): una MEMBRESÍA así puede ser una portadora VIVA — se decide abajo si niega la partición.
769
+ if (tuple?.object?.type === RELATION_USERSET_TYPE)
770
+ invalidCarriers.push(tuple);
771
+ continue;
772
+ }
374
773
  if (!isActiveExpiry(tuple.expiresAt ?? null, now)) {
375
774
  skipped.expired += 1;
376
775
  continue;
377
776
  }
777
+ const kind = kindOf(tuple);
778
+ // Lote D · D2: una tupla con la relación ALMACENADA de un deny (`denied_*`, prefijo reservado) y sin `kind: 'deny'`
779
+ // es un deny que el driver de ORIGEN no marcó: migrarla como allow escribiría `relate(denied_viewer)` (422) o la
780
+ // contaría como no declarada — y el deny se perdería. Negativa (500), nunca `skipped`.
781
+ if (kind === 'allow' && typeof tuple.relation === 'string' && tuple.relation.startsWith(RELATION_DENY_PREFIX)) {
782
+ refuse(tuple, 'unmarked', `el ORIGEN enumera ${describeTuple(tuple)} sin kind: 'deny' (un driver que declara relationDenies tiene que marcar kind; ` +
783
+ `un proceso < 2.5.0-alpha.3 no lo hace)`);
784
+ continue;
785
+ }
378
786
  // ⚪ R2: una identidad fuera de la gramática en el ORIGEN no es una pregunta
379
787
  // del llamante (invariante 5): no se migra —la API del destino no podría
380
788
  // escribirla—, se lista y se cuenta. Nunca un 422 por un dato del store.
381
789
  const keyed = enumeratedKey(tuple);
382
790
  if ('message' in keyed) {
791
+ if (kind === 'deny') {
792
+ refuse(tuple, 'invalid', `identidad fuera de la gramática: ${keyed.message}`);
793
+ continue;
794
+ }
383
795
  skipped.invalid += 1;
384
- invalid.push({ side: 'origen', tuple: describeTuple(tuple), message: keyed.message });
796
+ invalid.push({ side: 'origen', reason: 'invalid-identity', tuple: describeTuple(tuple), message: keyed.message });
385
797
  continue;
386
798
  }
387
799
  // Cierre 1 del lote B · 🟠 2: el residuo pre-2.5 de B3 en el ORIGEN se
@@ -391,21 +803,37 @@ function planPartition(partition, enumerated, destTuples, context) {
391
803
  // tragaba como `undeclared` (consejo falso; `--dry-run` decía `written`).
392
804
  const subject = classifySubject(tuple, toConfig);
393
805
  if (subject.kind === 'userset-unsupported') {
806
+ if (kind === 'deny') {
807
+ refuse(tuple, 'invalid', `sujeto que el destino no admite: ${subject.message}`);
808
+ continue;
809
+ }
394
810
  skipped.usersetUnsupported += 1;
395
- usersetResidue.push({ side: 'origen', tuple: describeTuple(tuple), message: subject.message });
811
+ usersetResidue.push({ side: 'origen', reason: 'userset-unsupported', tuple: describeTuple(tuple), message: subject.message });
396
812
  continue;
397
813
  }
398
814
  if (subject.kind === 'undeclared') {
815
+ if (kind === 'deny') {
816
+ refuse(tuple, 'undeclared', `el sujeto userset de ${describeTuple(tuple)} no está declarado en el destino`);
817
+ continue;
818
+ }
399
819
  skipped.undeclared += 1;
400
820
  continue;
401
821
  }
402
822
  if (toConfig && !declaredIn(toConfig, tuple.object.type)) {
823
+ if (kind === 'deny') {
824
+ refuse(tuple, 'undeclared', `el destino no declara el tipo '${tuple.object.type}'`);
825
+ continue;
826
+ }
403
827
  skipped.undeclared += 1;
404
828
  if (!modelDrift.includes(tuple.object.type))
405
829
  modelDrift.push(tuple.object.type);
406
830
  continue;
407
831
  }
408
832
  if (toConfig && !toConfig.isDeclared(tuple.object.type, tuple.relation)) {
833
+ if (kind === 'deny') {
834
+ refuse(tuple, 'undeclared', `el destino no declara la relación '${tuple.relation}' en '${tuple.object.type}'`);
835
+ continue;
836
+ }
409
837
  skipped.undeclared += 1;
410
838
  continue;
411
839
  }
@@ -413,7 +841,7 @@ function planPartition(partition, enumerated, destTuples, context) {
413
841
  }
414
842
  const sourceByKey = new Map();
415
843
  for (const tuple of sourceTuples)
416
- sourceByKey.set(tupleKey({ ...tuple, kind: 'allow' }), tuple);
844
+ sourceByKey.set(tupleKey({ ...tuple, kind: kindOf(tuple) }), tuple);
417
845
  // ⚪ R2 + cierre 2 · N3: una tupla del DESTINO fuera de la gramática es
418
846
  // `extra` con motivo (`invalid-identity`): nadie del origen puede respaldarla
419
847
  // (la clave no se puede ni componer) y NADIE la borra — ni `toDelete` (los dos
@@ -421,51 +849,144 @@ function planPartition(partition, enumerated, destTuples, context) {
421
849
  // pedía igual) ni un DELETE de plataforma—: se lista con `pruned: false` y es
422
850
  // deriva en los cuatro modos, exactamente como el residuo de B3 de abajo.
423
851
  const destByKey = new Map();
424
- const destInvalid = new Set();
425
- const destResidue = new Set();
426
- const undeclaredResidue = [];
852
+ /** Cierre 4 de D2 · 🟡 C3-2: los denies del destino VENCIDOS (no cuentan como presentes, pero el destino los tiene). */
853
+ const expiredDenyByKey = new Map();
854
+ const residue = new Set();
855
+ /** L11: las claves del destino con una condición que el paquete no escribe: el origen no las reescribe. */
856
+ const blockedKeys = new Set();
427
857
  for (const tuple of destTuples) {
858
+ // ⚪ 8 (cierre 1 de D2): un deny del DESTINO sin `kind` (un driver de terceros que no marca) es la MISMA negativa
859
+ // `unmarked` que en el origen, decidida en el PLAN: planificarlo como allow lo podaría con `unrelate(denied_*)` (que
860
+ // D-04 prohíbe y F-05 rechaza con un motivo equivocado) o lo contaría como allow que sobra.
861
+ if (kindOf(tuple) === 'allow' && typeof tuple?.relation === 'string' && tuple.relation.startsWith(RELATION_DENY_PREFIX)) {
862
+ residue.add(tuple);
863
+ refuse(tuple, 'unmarked', `el DESTINO enumera ${describeTuple(tuple)} sin kind: 'deny' (un driver que declara relationDenies tiene que marcar kind)`);
864
+ continue;
865
+ }
428
866
  const keyed = enumeratedKey(tuple);
429
867
  if ('message' in keyed) {
430
- destInvalid.add(tuple);
431
- invalid.push({ side: 'destino', tuple: describeTuple(tuple), message: keyed.message });
868
+ residue.add(tuple);
869
+ invalid.push({ side: 'destino', reason: 'invalid-identity', tuple: describeTuple(tuple), message: keyed.message });
432
870
  continue;
433
871
  }
434
872
  // Cierre 1 del lote B · 🟠 1: el residuo pre-2.5 de B3 en el DESTINO sobra
435
873
  // (nadie del origen puede respaldarlo: el origen lo descarta arriba) pero
436
- // NO entra en `toDelete`: la pasada no se lo pide al driver ni con `--prune`
874
+ // NO entra en un borrado: la pasada no se lo pide al driver ni con `--prune`
437
875
  // —`unrelate` lo rechazaría con 422 y hasta el cierre eso ABORTABA la pasada
438
876
  // a mitad, con los `toWrite` ya escritos—. Se lista con `pruned: false` y
439
- // la receta, cuenta en `extra` y es deriva. Solo la forma del userset: la
440
- // F-05 del par del SUJETO no decide aquí (un userset que pasa la forma es
441
- // `group#member`, declarado en toda config).
877
+ // la receta, cuenta en `extra` y es deriva.
442
878
  const subject = classifySubject(tuple, undefined);
443
879
  if (subject.kind === 'userset-unsupported') {
444
- destResidue.add(tuple);
445
- usersetResidue.push({ side: 'destino', tuple: describeTuple(tuple), message: subject.message });
880
+ residue.add(tuple);
881
+ usersetResidue.push({ side: 'destino', reason: 'userset-unsupported', tuple: describeTuple(tuple), message: subject.message });
446
882
  continue;
447
883
  }
448
884
  // Cierre 3 del lote B · 🟠 N9 (capa 1): la fila cuya RELACIÓN o TIPO el
449
885
  // destino (`toConfig`) ya no declara — una relación retirada de la config con
450
- // filas vivas (`#enumerateRows`/`#sweep` filtran por tipo, no por relación) o
451
- // un driver con la config vieja — es la tercera fila que la API no admite:
452
- // `unrelate` la rechaza por F-05, así que lleva la MISMA regla que las dos de
453
- // arriba (nunca en `toDelete`, `pruned: false`, deriva, receta). La regla es
454
- // por la FILA, no por el driver (N3): hasta el cierre 3 un driver viejo la
455
- // borraba y el driver nuevo tumbaba la pasada a mitad con un stack crudo.
886
+ // filas vivas — es la tercera fila que la API no admite: `unrelate`/`removeDeny`
887
+ // la rechazan por F-05, así que lleva la MISMA regla que las dos de arriba.
456
888
  const undeclared = toConfig ? classifyDestinationRow(toConfig, tuple) : null;
457
889
  if (undeclared !== null) {
458
- destResidue.add(tuple);
459
- undeclaredResidue.push({ tuple: describeTuple(tuple), message: undeclared });
890
+ residue.add(tuple);
891
+ undeclaredResidue.push({ side: 'destino', reason: 'undeclared', tuple: describeTuple(tuple), message: undeclared });
892
+ continue;
893
+ }
894
+ // L11 · H8: una tupla del DESTINO con una condición que el paquete no escribe es PRESENTE e INTOCABLE: `extra` con
895
+ // motivo y nunca `toDelete` (ni con `--prune`), y su CLAVE no se reescribe desde el origen (un `relate` sobre ella es
896
+ // 409 a mitad de pasada). No entra en el contexto del orden, y NO porque el servidor la ignore —un `not_expired` con
897
+ // `current_time` congelado está VIVO (medido, H2)—, sino porque si es una MEMBRESÍA (la única forma de transportar un
898
+ // deny) en una partición con algún deny, la partición se NIEGA (cierre de L11, 🟡 A2: cero escrituras, el orden no se
899
+ // usa); sin ningún deny en la partición no transporta nada que el orden tenga que ordenar.
900
+ if (typeof tuple?.invalidCondition === 'string') {
901
+ residue.add(tuple);
902
+ invalid.push({ side: 'destino', reason: 'invalid-condition', tuple: describeTuple(tuple), message: tuple.invalidCondition });
903
+ blockedKeys.add(keyed.key);
904
+ if (tuple?.object?.type === RELATION_USERSET_TYPE)
905
+ invalidCarriers.push(tuple);
906
+ continue;
907
+ }
908
+ // Cierre 3 de D2 · 🟠 C2-1 / 🟡 C2-2: una tupla del destino VENCIDA en `now` (el MISMO `now` con el que el origen
909
+ // descarta las suyas) no conecta nada —ni en `database` ni en FGA— y el plan la trata como AUSENTE: su reescritura es
910
+ // una escritura normal (nunca `toUpdate`, nunca `renewal-unsafe`) y no entra en el contexto del orden (ni portadora,
911
+ // ni arista del auto-corte, ni «renovación» de la fase 4). Hasta el cierre 3 contaba como viva: una membresía
912
+ // portadora caducada se clasificaba `renewal-unsafe` mientras el allow nuevo SÍ se escribía, y el destino concedía a
913
+ // un denegado para siempre (R4 del auditor). Sin `--prune` sigue en el destino (sobra, vencida) y con él se barre.
914
+ if (!liveInDestination(tuple, now)) {
915
+ if (kindOf(tuple) === 'deny')
916
+ expiredDenyByKey.set(keyed.key, tuple);
460
917
  continue;
461
918
  }
462
919
  destByKey.set(keyed.key, tuple);
463
920
  }
921
+ // L11 · H8: una clave que el destino tiene con una condición que el paquete no escribe NO se reescribe desde el origen
922
+ // (sería un `relate` que el driver rechaza con 409 a mitad de pasada): la tupla del origen se lista y es deriva; un deny
923
+ // NIEGA la partición. Sale también del contexto del orden (no se va a escribir).
924
+ for (const key of blockedKeys) {
925
+ const tuple = sourceByKey.get(key);
926
+ if (!tuple)
927
+ continue;
928
+ sourceByKey.delete(key);
929
+ sourceTuples.splice(sourceTuples.indexOf(tuple), 1);
930
+ if (kindOf(tuple) === 'deny') {
931
+ refuse(tuple, 'invalid', `el DESTINO tiene ${describeTuple(tuple)} con una condición que el paquete no escribe`);
932
+ continue;
933
+ }
934
+ skipped.invalid += 1;
935
+ invalid.push({
936
+ side: 'origen',
937
+ reason: 'invalid-condition',
938
+ tuple: describeTuple(tuple),
939
+ message: 'la clave existe en el DESTINO con una condición que el paquete no escribe: no se reescribe (bórrala allí a mano y repite)',
940
+ });
941
+ }
464
942
  const toWrite = [];
465
943
  const toUpdate = [];
944
+ const denyWrite = [];
945
+ const denyUpdate = [];
946
+ const denyPresent = [];
947
+ const denyConditional = [];
466
948
  let unchanged = 0;
949
+ let denyUnchanged = 0;
467
950
  for (const [key, tuple] of sourceByKey) {
468
951
  const present = destByKey.get(key);
952
+ if (kindOf(tuple) === 'deny') {
953
+ // Cierre 4 de D2 · 🟡 C3-2: un deny del destino CON caducidad —vivo con otra, o ya VENCIDO— que el origen tiene, en
954
+ // un destino que no declara `atomicRenewal` (`openfga`: no reemplaza la misma clave en un `Write`, M5, y lo dice con
955
+ // 500 `conditionalDeny`), no se puede rehacer permanente. Se decide AQUÍ (mismos números en `--dry-run` y real;
956
+ // hasta el cierre 4 lo decidía el driver en la pasada real, y la pasada escribía los allows que ese deny corta):
957
+ // residuo `deny-conditional` y, desde el cierre 5 (opción A), su PARTICIÓN queda NEGADA (cero escrituras en ella).
958
+ // Para la puerta cuenta como un deny que el destino tiene (tiene que honrarlo).
959
+ const stale = present ?? expiredDenyByKey.get(key);
960
+ if (!atomicRenewal && stale && stale.expiresAt != null && !sameInstant(stale.expiresAt, tuple.expiresAt ?? null)) {
961
+ const expired = !present;
962
+ denyPresent.push(tuple);
963
+ denyConditional.push({
964
+ side: 'destino',
965
+ reason: 'deny-conditional',
966
+ tuple: describeTuple(stale),
967
+ message: `el deny del destino tiene caducidad (${stale.expiresAt.toISOString()}, ${expired ? 'YA VENCIDO: no niega nada' : 'vivo: niega hasta entonces'}) ` +
968
+ `y el origen lo tiene ${tuple.expiresAt ? `hasta ${tuple.expiresAt.toISOString()}` : 'permanente'}; el destino no declara atomicRenewal ` +
969
+ `(openfga no reemplaza la misma clave en un Write, M5) y no puede rehacerlo en una escritura. No se reescribe y la ` +
970
+ `PARTICIÓN ${scopeKey(partition)} queda NEGADA: cero escrituras y cero borrados en ella (su destino queda como estaba, ` +
971
+ `con su deriva a la vista); el resto de particiones migra. Receta: ${partitionRefusalRecipe(['deny-conditional'])}`,
972
+ pruned: false,
973
+ });
974
+ continue;
975
+ }
976
+ // D3: un deny no caduca. Uno del destino SIN caducidad es el mismo deny (el del origen, con o sin una caducidad
977
+ // escrita a mano, deniega lo mismo o menos tiempo). Uno del destino CON caducidad distinta se rehace permanente
978
+ // (`to.deny`): dejarlo caducar concedería lo que el origen niega. Nunca delete+write (R2-8: no hay `toUpdate` de
979
+ // denies por `relate`).
980
+ if (!present)
981
+ denyWrite.push(tuple);
982
+ else if (present.expiresAt != null && !sameInstant(present.expiresAt, tuple.expiresAt ?? null))
983
+ denyUpdate.push(tuple);
984
+ else {
985
+ denyUnchanged += 1;
986
+ denyPresent.push(tuple);
987
+ }
988
+ continue;
989
+ }
469
990
  if (!present)
470
991
  toWrite.push(tuple);
471
992
  // Misma clave, OTRA caducidad ⇒ se reescribe con la del origen (R-15).
@@ -474,24 +995,273 @@ function planPartition(partition, enumerated, destTuples, context) {
474
995
  else
475
996
  unchanged += 1;
476
997
  }
477
- const toDelete = destTuples.filter((tuple) => !destResidue.has(tuple) && !destInvalid.has(tuple) && !sourceByKey.has(tupleKey({ ...tuple, kind: 'allow' })));
478
- const massDelete = prune && toDelete.length > 0 && sourceTuples.length === 0;
998
+ const allowDelete = [];
999
+ const denyDelete = [];
1000
+ const expiredDelete = new Set();
1001
+ for (const tuple of destTuples) {
1002
+ if (residue.has(tuple))
1003
+ continue;
1004
+ if (sourceByKey.has(tupleKey({ ...tuple, kind: kindOf(tuple) })))
1005
+ continue;
1006
+ if (kindOf(tuple) === 'deny')
1007
+ denyDelete.push(tuple);
1008
+ else
1009
+ allowDelete.push(tuple);
1010
+ if (!liveInDestination(tuple, now))
1011
+ expiredDelete.add(tuple);
1012
+ }
1013
+ const massDelete = prune && allowDelete.length + denyDelete.length > 0 && sourceTuples.length === 0;
1014
+ const orderContext = [...sourceTuples, ...destByKey.values()].map(orderView);
1015
+ // Cierre 2 de D2 · 🟠 N1: la RENOVACIÓN de una membresía que transporta un deny (en el grafo de los DOS estados: los
1016
+ // denies del destino siguen vivos hasta el final de la pasada y los del origen se escriben primero) en un destino que
1017
+ // renueva con delete + write (sin `atomicRenewal`) dejaría al sujeto SIN el deny entre los dos `Write`. El plan no la
1018
+ // renueva: la lista (`renewal-unsafe`, con su receta) y es deriva. Los mismos números en `--dry-run` y en la real.
1019
+ const renewalUnsafe = [];
1020
+ const evolves = [];
1021
+ /** La caducidad que el DESTINO tiene de una tupla de `toUpdate` (viva). */
1022
+ const destExpiry = (tuple) => destByKey.get(tupleKey({ ...tuple, kind: kindOf(tuple) }))?.expiresAt ?? null;
1023
+ /** ¿La renovación ALARGA la caducidad (la del origen es posterior o `null`)? */
1024
+ const extends_ = (tuple) => {
1025
+ const old = destExpiry(tuple);
1026
+ return tuple.expiresAt == null || (old != null && tuple.expiresAt.getTime() > old.getTime());
1027
+ };
1028
+ if (!atomicRenewal && toUpdate.length > 0) {
1029
+ const carriers = denyCarrierGroups(orderContext);
1030
+ for (let i = toUpdate.length - 1; i >= 0; i--) {
1031
+ const tuple = toUpdate[i];
1032
+ if (tuple.object.type !== RELATION_USERSET_TYPE || !carriers.has(`${tuple.object.type}:${tuple.object.id}`))
1033
+ continue;
1034
+ toUpdate.splice(i, 1);
1035
+ const old = destExpiry(tuple);
1036
+ const newer = tuple.expiresAt ? tuple.expiresAt.toISOString() : 'sin caducidad';
1037
+ // Cierre 6 de D2 · ⚪ D5-3 (E3): «como estaba» NO es estático. Si la renovación ALARGA, desde la caducidad vieja el
1038
+ // destino ya no transporta los denies del grupo por esta membresía y CONCEDE lo que cortan (si otro camino lo da),
1039
+ // cosa que el origen sigue negando: la línea lo dice con fecha y con qué, en vez de dejar que el operador lo deduzca.
1040
+ const member = orderView(tuple).user;
1041
+ const cut = carriedDenies([...destByKey.values()].map(orderView), `${tuple.object.type}:${tuple.object.id}`).map((v) => `${v.relation} sobre ${v.object}`);
1042
+ if (extends_(tuple) && old !== null && cut.length > 0)
1043
+ evolves.unshift({ at: old.toISOString(), member, cut });
1044
+ const grantsLater = old !== null && cut.length > 0
1045
+ ? ` A partir de ${old.toISOString()} el destino CONCEDERÁ a ${member} lo que cortan esos denies (${cut.join(', ')}) si ` +
1046
+ `otro camino se lo da, y el origen lo sigue negando: negar la partición NO congela el tiempo.`
1047
+ : '';
1048
+ renewalUnsafe.unshift({
1049
+ side: 'destino',
1050
+ reason: 'renewal-unsafe',
1051
+ tuple: describeTuple(tuple),
1052
+ message: `la membresía cambia de caducidad (destino ${old ? old.toISOString() : 'sin caducidad'}, origen ${newer}) y su grupo ` +
1053
+ `TRANSPORTA un deny vivo; el destino renueva con delete + write (no declara atomicRenewal) y entre los dos el deny no ` +
1054
+ `alcanzaría al sujeto. No se renueva y la PARTICIÓN ${scopeKey(partition)} queda NEGADA: cero escrituras y cero ` +
1055
+ `borrados en ella (su destino queda como estaba, con su deriva a la vista); el resto de particiones migra. ` +
1056
+ `${extends_(tuple) ? `Desde ${old ? old.toISOString() : '—'} el destino ya no transporta ese deny por esta membresía.${grantsLater}` : `Hasta ${old ? old.toISOString() : '—'} el destino la conserva más que el origen (${newer}).`} ` +
1057
+ `Receta: ${partitionRefusalRecipe(['renewal-unsafe'])}`,
1058
+ });
1059
+ }
1060
+ }
1061
+ // **Cierre 5 de D2 (decisión del dueño §2026-10-16 (10), opción A)**: una partición con ≥ 1 `renewal-unsafe` o
1062
+ // `deny-conditional` —lo que el destino no puede escribir en una escritura— recibe CERO escrituras y CERO borrados
1063
+ // (también con `--prune` y las vencidas): queda EXACTAMENTE como estaba, que es lo único certificable; el resto migra.
1064
+ // Sustituye a la retención del cierre 4 (`relationWithheldWrites`, `withheld-by-unsafe-deny`, `carrier-conflict`): retener
1065
+ // tupla a tupla no cubría lo que el destino YA concedía (D4-1) y su negativa era global (D4-3).
1066
+ const causes = [...denyConditional, ...renewalUnsafe].map((detail) => ({
1067
+ reason: detail.reason,
1068
+ tuple: detail.tuple,
1069
+ }));
1070
+ // **Cierre de L11 (🟡 A2 del auditor; criterio del dueño: ante la duda, la opción que nunca concede de más)**: una
1071
+ // membresía `foreign`/`unparseable` (origen o destino) en una partición con ALGÚN deny (origen o destino, vivo o no,
1072
+ // marcado o no) NIEGA la partición. El servidor puede estar evaluándola VIVA y transportando ese deny; migrarla sin ella
1073
+ // —o escribir en el destino alrededor de ella— fabricaba un `check = true` que ningún estado del origen concedía (medido:
1074
+ // `.claude/reproducciones/loteRC-L11-auditor-carrier.ts`). Mismo mecanismo que la opción A: cero escrituras en ella.
1075
+ const anyDeny = [...enumerated, ...destTuples].some((tuple) => kindOf(tuple) === 'deny' || String(tuple?.relation ?? '').startsWith(RELATION_DENY_PREFIX));
1076
+ if (anyDeny)
1077
+ for (const tuple of invalidCarriers)
1078
+ causes.push({ reason: 'invalid-condition', tuple: describeTuple(tuple) });
1079
+ // **R1 (decisión del dueño 2026-10-17 (5); 🔴 C-1 del auditor)**: la cadena de grupos anidados que dejaría la pasada
1080
+ // —el estado FINAL (T-5 del tester): las aristas vivas del origen, más las del destino que se QUEDAN (sin `--prune`,
1081
+ // todas las vivas; con él y sin él, TODO el residuo intocable que siga vivo —`invalid-identity`, `undeclared`,
1082
+ // `invalid-condition`, …: nadie lo borra y el servidor lo evalúa; cierre de R1 · 🟡 3 del auditor, hasta entonces
1083
+ // solo `invalid-condition`)—, y las de condición ilegible cuentan como vivas— no puede pasar
1084
+ // del techo del destino. Si pasa, la partición se NIEGA (cero escrituras): saltar la arista que excede quitaría los
1085
+ // denies que transporta y el destino concedería lo que el origen niega. Un ciclo NO niega por sí solo (OpenFGA lo
1086
+ // evalúa bien): cuenta, de forma conservadora, como k − 1 saltos de su recorrido.
1087
+ let tooDeep;
1088
+ if (groupDepthLimit !== null) {
1089
+ const edges = [];
1090
+ const push = (tuple) => {
1091
+ if (isNestingTuple(tuple))
1092
+ edges.push({ child: tuple.subject.object.id, parent: tuple.object.id });
1093
+ };
1094
+ for (const tuple of sourceTuples)
1095
+ push(tuple);
1096
+ if (!prune)
1097
+ for (const tuple of destByKey.values())
1098
+ push(tuple);
1099
+ for (const tuple of destTuples)
1100
+ if (residue.has(tuple) && residueAlive(tuple, now))
1101
+ push(tuple);
1102
+ const { depth, chain } = groupChainDepth(edges);
1103
+ if (depth > groupDepthLimit) {
1104
+ tooDeep = { depth, limit: groupDepthLimit, chain };
1105
+ causes.push({
1106
+ reason: 'too-deep',
1107
+ tuple: `cadena de ${depth} saltos de grupos anidados > techo ${groupDepthLimit} del destino: ${chain.slice(0, 8).join(' ⊂ ')}${chain.length > 8 ? ' ⊂ …' : ''}`,
1108
+ });
1109
+ }
1110
+ }
1111
+ const refusal = causes.length > 0
1112
+ ? {
1113
+ partition: scopeKey(partition),
1114
+ causes,
1115
+ pending: {
1116
+ write: toWrite.length + denyWrite.length,
1117
+ update: toUpdate.length + denyUpdate.length + denyConditional.length + renewalUnsafe.length,
1118
+ delete: prune ? allowDelete.length + denyDelete.length : 0,
1119
+ },
1120
+ recipe: partitionRefusalRecipe(causes.map((cause) => cause.reason)),
1121
+ ...(evolves.length > 0 ? { evolves } : {}),
1122
+ }
1123
+ : undefined;
1124
+ const shortening = toUpdate.filter((tuple) => !extends_(tuple));
479
1125
  return {
480
1126
  partition,
481
1127
  enumerated: enumerated.length,
482
1128
  toWrite,
483
1129
  toUpdate,
484
- toDelete,
1130
+ allowDelete,
485
1131
  unchanged,
1132
+ denyWrite,
1133
+ denyUpdate,
1134
+ denyPresent,
1135
+ denyUnchanged,
1136
+ denyDelete,
1137
+ expiredDelete,
1138
+ denyRefusals,
486
1139
  skipped,
487
1140
  modelDrift,
488
- massDelete,
489
- invalid,
490
- usersetResidue,
491
- undeclaredResidue,
492
- destResidue: destResidue.size + destInvalid.size,
1141
+ // Una partición NEGADA no borra nada: el seguro de borrado masivo no aplica.
1142
+ massDelete: massDelete && !refusal,
1143
+ details: [...invalid, ...usersetResidue, ...undeclaredResidue, ...denyConditional, ...renewalUnsafe],
1144
+ destResidue: residue.size,
1145
+ orderContext,
1146
+ shortening,
1147
+ denyConditional: denyConditional.length,
1148
+ refusal,
1149
+ ...(tooDeep ? { tooDeep } : {}),
493
1150
  };
494
1151
  }
1152
+ /**
1153
+ * R1: ¿es una arista de anidación `group:P#member@group:C#member` (allow)? La misma forma que `isGroupNesting`, y con
1154
+ * ids de TEXTO (el residuo `invalid-identity` del destino puede traer cualquier cosa: con un id que no es texto no hay
1155
+ * arista que contar).
1156
+ */
1157
+ function isNestingTuple(tuple) {
1158
+ return (kindOf(tuple) === 'allow' &&
1159
+ tuple?.object?.type === RELATION_USERSET_TYPE &&
1160
+ typeof tuple.object.id === 'string' &&
1161
+ tuple.relation === 'member' &&
1162
+ isRelUserset(tuple.subject) &&
1163
+ tuple.subject.object?.type === RELATION_USERSET_TYPE &&
1164
+ typeof tuple.subject.object.id === 'string' &&
1165
+ tuple.subject.relation === 'member');
1166
+ }
1167
+ /**
1168
+ * R1 · 🟡 3: ¿sigue VIVA en el servidor una tupla del RESIDUO del destino? Una con condición ilegible, sí (no se sabe
1169
+ * leer: cuenta, más negativas nunca menos); una con caducidad legible, si no ha vencido en `now`; una caducidad que no
1170
+ * se entiende, también viva (conservador).
1171
+ */
1172
+ function residueAlive(tuple, now) {
1173
+ if (typeof tuple?.invalidCondition === 'string')
1174
+ return true;
1175
+ try {
1176
+ return liveInDestination(tuple, now);
1177
+ }
1178
+ catch {
1179
+ return true;
1180
+ }
1181
+ }
1182
+ /**
1183
+ * **¿Cuenta una tupla del DESTINO en el plan?** (cierre 3 de D2 · 🟠 C2-1 / 🟡 C2-2): sí si está VIVA en `now` (el mismo
1184
+ * `now` de toda la pasada, con el que el origen descarta sus caducadas); una VENCIDA es AUSENTE — no conecta nada en
1185
+ * ningún driver—, así que ni se «renueva» ni entra en el contexto del orden. Punto de paso ÚNICO de la regla: lo usa
1186
+ * `planPartition` y la fuerza bruta del orden (`tests/helpers/order_bruteforce.ts`, universo con vigencia) deriva con
1187
+ * ÉL qué es escritura, renovación y contexto — un mutante que ignore la vigencia aquí pone rojos los dos.
1188
+ */
1189
+ export function liveInDestination(tuple, now) {
1190
+ return isActiveExpiry(tuple.expiresAt ?? null, now);
1191
+ }
1192
+ /**
1193
+ * La vista de ORDEN de una tupla ya planificada (cierre 1 de D2 · 🟠 1): objeto `<tipo>:<id>` y sujeto `<tipo>:<uuid>` o
1194
+ * `<tipo>:<id>#<rel>` — sin partición: el plan es de UNA partición.
1195
+ */
1196
+ function orderView(tuple) {
1197
+ const s = tuple.subject;
1198
+ return {
1199
+ deny: kindOf(tuple) === 'deny',
1200
+ object: `${tuple.object.type}:${tuple.object.id}`,
1201
+ user: isRelUserset(s) ? `${s.object.type}:${s.object.id}#${s.relation}` : `${s.type}:${s.uuid}`,
1202
+ relation: tuple.relation,
1203
+ };
1204
+ }
1205
+ /** El `kind` de una tupla enumerada: `deny` solo si el driver lo marca; ausente = `allow` (tipo exportado opcional). */
1206
+ function kindOf(tuple) {
1207
+ return tuple?.kind === 'deny' ? 'deny' : 'allow';
1208
+ }
1209
+ /**
1210
+ * **El detector de (8) sobre lo ya enumerado**: agrupa los denies de cada lado por tipo y relación pública —contando CLAVES
1211
+ * DE TUPLA distintas (`relationTupleKey`): un deny espejado en origen y destino es uno, firma (8 · `count`)— y les pasa
1212
+ * `denyReachCheck` (la MISMA función que la guarda de `database` y el detector de un driver), en las dos direcciones. Un deny
1213
+ * es una tupla con `kind: 'deny'` (o, sin marcar, con la relación almacenada `denied_*`: la cuenta igual — la puerta del
1214
+ * deny ya la niega aparte como `unmarked`). LÍMITE: lo que no se enumera no se ve (un TIPO retirado de la config del
1215
+ * driver no se enumera; la guarda de `database` y `checkRelationsConfigDenyReach` sí leen la tabla entera).
1216
+ */
1217
+ function detectDenyReach(options, source, destination) {
1218
+ const sides = new Map();
1219
+ const keys = new Map();
1220
+ for (const [side, byKey] of [['origen', source], ['destino', destination]]) {
1221
+ for (const tuples of byKey.values()) {
1222
+ for (const tuple of tuples) {
1223
+ const stored = typeof tuple?.relation === 'string' ? tuple.relation : '';
1224
+ const relation = kindOf(tuple) === 'deny' ? stored : stored.startsWith(RELATION_DENY_PREFIX) ? stored.slice(RELATION_DENY_PREFIX.length) : null;
1225
+ const objectType = tuple?.object?.type;
1226
+ if (!relation || typeof objectType !== 'string')
1227
+ continue;
1228
+ const group = `${objectType}#${relation}`;
1229
+ let identity;
1230
+ try {
1231
+ identity = relationTupleKey({ ...tuple, kind: 'deny', relation });
1232
+ }
1233
+ catch {
1234
+ identity = JSON.stringify([scopeKeyOrNull(tuple?.partition), objectType, tuple?.object?.id, relation, tuple?.subject]);
1235
+ }
1236
+ keys.set(group, (keys.get(group) ?? new Set()).add(identity));
1237
+ sides.set(group, (sides.get(group) ?? new Set()).add(side));
1238
+ }
1239
+ }
1240
+ }
1241
+ const groups = [...keys].map(([group, identities]) => {
1242
+ const [objectType, relation] = group.split('#');
1243
+ return { objectType, relation, count: identities.size };
1244
+ });
1245
+ const check = denyReachCheck(options.baseline, options.current, groups);
1246
+ const withSides = (finding) => ({
1247
+ ...finding,
1248
+ sides: [...(sides.get(`${finding.objectType}#${finding.relation}`) ?? [])].sort(),
1249
+ });
1250
+ return {
1251
+ baseline: check.baseline,
1252
+ findings: check.findings.filter((finding) => finding.reason !== 'no-baseline').map(withSides),
1253
+ stale: check.staleBaseline.map(withSides),
1254
+ unjudged: check.findings.filter((finding) => finding.reason === 'no-baseline').reduce((total, finding) => total + finding.count, 0),
1255
+ };
1256
+ }
1257
+ function scopeKeyOrNull(partition) {
1258
+ try {
1259
+ return scopeKey(partition);
1260
+ }
1261
+ catch {
1262
+ return null;
1263
+ }
1264
+ }
495
1265
  /**
496
1266
  * **F-05 del destino sobre una fila ENUMERADA del destino** (cierre 3 del lote B ·
497
1267
  * 🟠 N9, capa 1): la MISMA `assertRelationDeclared` que corre en la primera línea
@@ -512,39 +1282,103 @@ function classifyDestinationRow(toConfig, tuple) {
512
1282
  throw error;
513
1283
  }
514
1284
  }
1285
+ export const RELATION_REJECTIONS = Object.freeze({
1286
+ [RelationTypeUnknownError.code]: {
1287
+ kind: 'classified',
1288
+ reason: 'undeclared',
1289
+ where: 'planPartition (toConfig: skipped.undeclared en el origen) · classifyDestinationRow (destino) · denyGate (denyPhase ⇒ refusal undeclared)',
1290
+ },
1291
+ [RelationUnknownError.code]: {
1292
+ kind: 'classified',
1293
+ reason: 'undeclared',
1294
+ where: 'planPartition (toConfig) · classifyDestinationRow · denyGate (denyPhase ⇒ refusal undeclared)',
1295
+ },
1296
+ [RelationUsersetUnsupportedError.code]: { kind: 'classified', reason: 'userset-unsupported', where: 'classifySubject (origen y destino, con o sin toConfig)' },
1297
+ [InvalidIdentityError.code]: { kind: 'classified', reason: 'invalid-identity', where: 'enumeratedKey (origen y destino)' },
1298
+ [RelationNotDenyableError.code]: { kind: 'refused', refusal: 'phase', where: "denyGate: denyPhase del DESTINO ≠ 'write' ⇒ 500 antes de escribir" },
1299
+ [RelationDenyExpiryError.code]: { kind: 'unreachable', why: 'el reconcile nunca pasa expiresAt a deny/removeDeny (D3: el deny no caduca)' },
1300
+ [CheckManyBatchError.code]: { kind: 'unreachable', why: 'el reconcile no llama a checkMany ni a decideMany' },
1301
+ // Lote «antes de RC» · R1: el gate de anidación de grupos de los drivers del paquete. La profundidad la decide el PLAN
1302
+ // (partición NEGADA, causa `too-deep`, sobre el estado FINAL) y la pasada escribe con la marca del plan (`plannedRelateOptions`),
1303
+ // que esos drivers respetan (un ciclo preexistente se migra; un estado intermedio no se certifica). Un driver de
1304
+ // terceros que lo lance igual se PROPAGA (fail-closed: la pasada se corta con su marca de parcial).
1305
+ [GroupDepthLimitError.code]: {
1306
+ kind: 'unreachable',
1307
+ why: 'planPartition decide la profundidad (partitionsRefused too-deep) y relateOrSkip escribe con plannedRelateOptions (marca no falsificable atada a la arista): el gate de los drivers del paquete no se consulta en la pasada',
1308
+ },
1309
+ // Lote «antes de RC» · 7.4: la página de `enumerate*`. El reconcile no pasa `limit` y como `after` solo el cursor que el
1310
+ // ORIGEN acaba de emitir (`enumerateAllMany`); un 422 de página ahí es un driver que rechaza su propio cursor: se propaga.
1311
+ [InvalidRelationPageError.code]: {
1312
+ kind: 'unreachable',
1313
+ why: 'enumerateAllMany no pasa limit y solo reenvía como after el cursor que el ORIGEN acaba de emitir: un 422 de página sería un driver que rechaza su propio cursor (se propaga, la pasada no empieza a escribir)',
1314
+ },
1315
+ [RelationConfigError.code]: { kind: 'unreachable', why: 'se lanza al DEFINIR la config; cuando el reconcile corre los drivers ya están construidos' },
1316
+ [RelationConfigUnknownKeyError.code]: { kind: 'unreachable', why: 'se lanza al DEFINIR la config (clave desconocida de la spec, B2)' },
1317
+ [InvalidSlugError.code]: { kind: 'unreachable', why: 'gramática de SLUGS del catálogo de roles: ninguna escritura de relaciones la invoca' },
1318
+ [ActorRequiredError.code]: { kind: 'unreachable', why: 'solo el RelationsManager exige actor; el reconcile habla con los drivers' },
1319
+ // ⚪ 7 (cierre 1 de D2): los 422 que nombra `src/shared/` (que importan los drivers de relaciones).
1320
+ [UnknownScopeError.code]: {
1321
+ kind: 'unreachable',
1322
+ why: 'src/shared/backend_guard.ts: assertKnownScope/canonicalScopeTargets son del árbol de ROLES; los drivers de relaciones solo importan de ahí withDeadline/guardSql/isServerWriteLimit/isTimeoutLike/isDeadlock/isUniqueViolation',
1323
+ },
1324
+ [NoScopeResolverError.code]: {
1325
+ kind: 'unreachable',
1326
+ why: 'src/shared/backend_guard.ts: rootOnlyResolver/resolveChain son del árbol de ROLES; ningún driver de relaciones resuelve cadenas de scopes',
1327
+ },
1328
+ });
515
1329
  /**
516
- * **Los 422 de la FAMILIA de relaciones, clasificados por `code`** (cierre 3 del
517
- * lote B · 🟠 N9, capa 2): lo que el bucle de poda absorbe cuando el driver del
518
- * destino rechaza un `unrelate` que el plan dio por bueno — sin `toConfig` el plan
519
- * no puede anticipar F-05, y un driver de terceros puede decir cualquiera de los
520
- * cuatro—. Por `code` y `status`, no por clase: un driver de terceros lanza su
521
- * propia clase con el mismo código. Fuera de esta tabla NADA se absorbe.
1330
+ * **Los 422 de la FAMILIA de relaciones, clasificados por `code`** (cierre 3 del lote B · 🟠 N9, capa 2) — DERIVADA del
1331
+ * registro L9: las entradas `classified`. Por `code` y `status`, no por clase: un driver de terceros lanza su propia
1332
+ * clase con el mismo código. Fuera de esta tabla NADA se absorbe.
522
1333
  */
523
- const RELATION_422_REASONS = Object.freeze({
524
- [RelationTypeUnknownError.code]: 'undeclared',
525
- [RelationUnknownError.code]: 'undeclared',
526
- [RelationUsersetUnsupportedError.code]: 'userset-unsupported',
527
- [InvalidIdentityError.code]: 'invalid-identity',
1334
+ export const RELATION_422_REASONS = Object.freeze(Object.fromEntries(Object.entries(RELATION_REJECTIONS).flatMap(([code, entry]) => (entry.kind === 'classified' ? [[code, entry.reason]] : []))));
1335
+ /**
1336
+ * **El registro de los RESIDUOS de la pasada, por motivo** (cierre 2 de D2 · ⚪ N6; linaje L9): cada motivo de
1337
+ * `RelationsReconcileDetail` dice DÓNDE se clasifica —en el PLAN (antes del driver: los mismos números en `--dry-run` y
1338
+ * en la pasada real, punto 2 de L9) o en la PASADA (solo el driver lo sabe: EXCEPCIÓN declarada al punto 2, con su
1339
+ * porqué)—. `tests/relations_reconcile_l9.spec.ts` cruza las claves con `RELATIONS_RECONCILE_DETAIL_REASONS` (un motivo
1340
+ * nuevo sin entrada nace ROJO) y ejecuta un caso por entrada. Hasta el cierre 2 `deny-conditional` (un `catch` del 500
1341
+ * marcado en la pasada) quedaba fuera de la guardia, que solo enumeraba 422.
1342
+ */
1343
+ export const RELATION_RESIDUES = Object.freeze({
1344
+ 'invalid-identity': { where: 'plan', how: 'enumeratedKey (origen y destino); capa 2 por RELATION_REJECTIONS' },
1345
+ 'invalid-condition': {
1346
+ where: 'plan',
1347
+ how: 'planPartition (L11): una tupla con RelationTuple.invalidCondition — origen: no se migra (skipped.invalid; un deny NIEGA la pasada); destino: presente e intocable (extra, nunca toDelete) y su clave no se reescribe desde el origen; una membresía de group así en una partición con algún deny NIEGA la partición (partitionsRefused, cierre de L11)',
1348
+ },
1349
+ 'userset-unsupported': { where: 'plan', how: 'classifySubject (origen y destino, con o sin toConfig); capa 2 por RELATION_REJECTIONS' },
1350
+ 'undeclared': { where: 'plan', how: 'classifyDestinationRow (destino, con toConfig); sin toConfig, capa 2 por RELATION_REJECTIONS' },
1351
+ 'renewal-unsafe': {
1352
+ where: 'plan',
1353
+ how: 'planPartition: una membresía de toUpdate cuyo grupo transporta un deny (denyCarrierGroups) y un destino sin capabilities.atomicRenewal — sale de toUpdate y NIEGA su partición (cierre 5: cero escrituras en ella)',
1354
+ },
1355
+ 'deny-conditional': {
1356
+ where: 'plan',
1357
+ how: 'planPartition (cierre 4 de D2 · C3-2): un deny del destino CON caducidad —vivo con otra o VENCIDO— que el origen tiene, en un destino sin capabilities.atomicRenewal — no se reescribe y NIEGA su partición (cierre 5: cero escrituras en ella)',
1358
+ },
528
1359
  });
1360
+ /** El motivo de la capa 2 para un error del driver, o `undefined` si no es de la familia (por `status` Y `code` PROPIO de la tabla). */
1361
+ function classifiedReason(error) {
1362
+ // `Object.hasOwn` (repaso del cierre 3 · ⚪ N13): un 422 con un `code` heredado del prototipo no es de la familia.
1363
+ const code = String(error?.code);
1364
+ return error?.status === 422 && Object.hasOwn(RELATION_422_REASONS, code) ? RELATION_422_REASONS[code] : undefined;
1365
+ }
529
1366
  /**
530
- * Borra la tupla del destino; devuelve `null` si se borró (o ya no estaba) y el
531
- * motivo + porqué si el DRIVER la rechazó con un 422 de la familia de relaciones
532
- * (`RELATION_422_REASONS`): la pasada lo clasifica como residuo del destino con
533
- * `pruned: false` y SIGUE con las demás filas (🟠 N9: nunca un throw a mitad de
534
- * pasada por una fila que el reporte puede contar). Cualquier otro error —otro
535
- * 422, un 503, un `Error`— se propaga tal cual: eso sí es el driver
536
- * contradiciendo al plan por algo que la pasada no sabe contar.
1367
+ * Borra la tupla del destino (`unrelate` un allow, `removeDeny` un deny — jamás al revés); devuelve `null` si se borró
1368
+ * (o ya no estaba) y el motivo + porqué si el DRIVER la rechazó con un 422 de la familia (`RELATION_422_REASONS`): la
1369
+ * pasada lo clasifica como residuo del destino con `pruned: false` y SIGUE con las demás filas (🟠 N9: nunca un throw a
1370
+ * mitad de pasada por una fila que el reporte puede contar). Cualquier otro error se propaga tal cual.
537
1371
  */
538
- async function unrelateOrClassify(to, tuple) {
1372
+ async function deleteOrClassify(to, tuple, op) {
539
1373
  try {
540
- await to.unrelate(tuple.subject, tuple.relation, tuple.object, tuple.partition);
1374
+ if (op === 'removeDeny')
1375
+ await to.removeDeny(tuple.subject, tuple.relation, tuple.object, tuple.partition);
1376
+ else
1377
+ await to.unrelate(tuple.subject, tuple.relation, tuple.object, tuple.partition);
541
1378
  return null;
542
1379
  }
543
1380
  catch (error) {
544
- // Por `status` Y por `code` PROPIO de la tabla (`Object.hasOwn`, repaso del cierre 3 · ⚪ N13): un 422 con un
545
- // `code` heredado del prototipo (`constructor`, `toString`, `__proto__`) no es de la familia y se propaga.
546
- const code = String(error?.code);
547
- const reason = error?.status === 422 && Object.hasOwn(RELATION_422_REASONS, code) ? RELATION_422_REASONS[code] : undefined;
1381
+ const reason = classifiedReason(error);
548
1382
  if (reason === undefined)
549
1383
  throw error;
550
1384
  return { reason, message: String(error?.message ?? error) };
@@ -566,17 +1400,27 @@ function declaredIn(config, objectType) {
566
1400
  */
567
1401
  async function relateOrSkip(to, tuple) {
568
1402
  try {
569
- await to.relate(tuple.subject, tuple.relation, tuple.object, tuple.partition, {
570
- expiresAt: tuple.expiresAt ?? null,
571
- });
1403
+ // R1: la profundidad de los grupos ya la decidió el PLAN sobre el estado final (y negó la partición si excedía); el
1404
+ // gate del driver no se repite aquí (`plannedRelateOptions`: un ciclo preexistente se migra, y un estado intermedio
1405
+ // de la pasada no es el que se certifica). La marca es la IDENTIDAD del objeto, atada a ESTA arista.
1406
+ await to.relate(tuple.subject, tuple.relation, tuple.object, tuple.partition, plannedRelateOptions({ expiresAt: tuple.expiresAt ?? null }, tuple));
572
1407
  return true;
573
1408
  }
574
1409
  catch (error) {
575
- if (error instanceof RelationUsersetUnsupportedError)
576
- throw error;
577
- if (error instanceof RelationTypeUnknownError || error instanceof RelationUnknownError)
1410
+ // L9: por el registro (capa 2) — SOLO `undeclared` se cuenta aquí (`skipped.undeclared`); el resto de la familia lo
1411
+ // clasifica el plan antes y, si el driver lo lanza igual, es una contradicción que se propaga.
1412
+ if (classifiedReason(error) === 'undeclared')
578
1413
  return false;
579
1414
  throw error;
580
1415
  }
581
1416
  }
582
- //# sourceMappingURL=reconcile.js.map
1417
+ /** ¿Trae la cadena de `cause` una respuesta HTTP del backend (`statusCode` numérico, la forma del SDK de OpenFGA)? */
1418
+ function carriesHttpResponse(error) {
1419
+ let current = error;
1420
+ for (let depth = 0; current && depth < 6; depth++) {
1421
+ if (typeof current.statusCode === 'number')
1422
+ return true;
1423
+ current = current.cause;
1424
+ }
1425
+ return false;
1426
+ }