@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
@@ -29,23 +29,42 @@
29
29
  * Pureza: vive detrás del subpath `@jantstack/adonis-authz/openfga`
30
30
  * (`src/openfga.ts`), como el driver `facts` de roles: es lo único que puede
31
31
  * tocar `@openfga/sdk` (peer opcional). La abstracción no filtra: ningún error
32
- * del SDK escapa (todo va por `#guard`, que clasifica a 503/timeout).
32
+ * del SDK escapa (todo va por `#guard`, que clasifica con `classifyFgaFailure`, R3).
33
33
  */
34
34
  import { OpenFgaClient, ConsistencyPreference, ClientWriteRequestOnMissingDeletes, ClientWriteRequestOnDuplicateWrites, } from '@openfga/sdk';
35
- import { AuthorizationBackendError, AuthorizationBackendTimeoutError, AuthorizationConfigError, InvalidIdentityError, PurgeIncompleteError, UnsupportedOperationError, markPartialWrite, } from '../errors.js';
35
+ import { AuthorizationBackendError, AuthorizationBackendTimeoutError, AuthorizationConfigError, InvalidIdentityError, PurgeIncompleteError, RenewalRacedError, RenewalUnsafeError, ResolutionLimitError, UnsupportedOperationError, markDeploymentFailure, markPartialWrite, } from '../errors.js';
36
36
  import { assertPartition, assertSubject, assertRelationId, assertExpiresAt, partitionKey } from '../identity.js';
37
37
  import { isClock, systemClock } from '../clock.js';
38
- import { resolveGrantExpiry, sameInstant, toExpiryDate } from '../expiry.js';
38
+ import { fgaTupleExpiry, isActiveExpiry, resolveGrantExpiry, sameInstant } from '../expiry.js';
39
39
  import { isRelUserset } from '../types.js';
40
40
  import { alignBatchResults } from './openfga_driver.js';
41
- import { FGA_MAX_BATCH_CHECK } from './openfga_facts.js';
41
+ import { FGA_MAX_BATCH_CHECK, assertExperimentalOptIn, assertPinnedModel, factsModelDifferences, factsModelDriftError } from './openfga_facts.js';
42
42
  import { assertCheckManyObjects, distinctObjects, expandByPosition } from '../relations/check_many.js';
43
+ import { assertEnumeratePage, assertNoListPage } from '../relations/page.js';
43
44
  import { assertFgaObjectId, FACTS_EXPIRY_CONDITION, FACTS_GROUP_TYPE, } from './openfga_facts.js';
44
- import { assertRelationDeclared, assertObjectTypeDeclared, assertUsersetSubject } from '../relations/define_relations_config.js';
45
- import { isTimeoutLike, withDeadline } from '../shared/backend_guard.js';
45
+ import { assertRelationDeclared, assertObjectTypeDeclared, assertUsersetSubject, assertRelationDenyable, assertDenySubject, assertDenyNoExpiry, denyPhaseOf, deniedRelationOf, storedRelationKind, relationsDown, RELATION_DENY_PREFIX, RELATION_USERSET_RELATION, RELATION_USERSET_TYPE, } from '../relations/define_relations_config.js';
46
+ import { relationDeleteOrder, relationOrderClass } from '../relations/tuple_order.js';
47
+ import { GROUP_DEPTH_CALIBRATION_BACKOFF_MS, GROUP_DEPTH_RECALIBRATION_MIN_INTERVAL_MS, GroupDepthBudget, assertGroupDepthSettings, calibrateGroupDepth, groupNestingError, groupNestingVerdict, isGroupNesting, plannedByReconcile, } from '../relations/group_depth.js';
48
+ import { fgaFailureError, isServerWriteLimit, withDeadline } from '../shared/backend_guard.js';
46
49
  export const DEFAULT_TIMEOUT_MS = 5_000;
47
- /** Tope de operaciones por `Write` en FGA (`exceeded_entity_limit` a partir de 100). */
48
- const WRITE_BATCH_SIZE = 100;
50
+ /**
51
+ * **Tope de operaciones por `Write` en FGA** — `--max-tuples-per-write` del servidor, default 100. MEDIDO en v1.19.0
52
+ * (`openfga-dev`, lote D · cierre 1 de D1): 100 deletes en un `Write` entran; 101 —o 50 writes + 51 deletes— es 400
53
+ * «The number of write operations exceeds the allowed limit of 100» y NO se aplica nada (el `Write` es transaccional).
54
+ * Es también la frontera de la purga atómica PARA EL ESCRITOR (`#purgeByFilters`): una purga que cabe aquí es UN
55
+ * `Write`, sin PREFIJO que cortar —un `Check` concurrente SÍ puede observar ese `Write` a medias: lector rasgado, fila
56
+ * «torn read» de Limits, decisión (6a) del dueño—; con más membresías de grupo que esto, el residuo es una fila DECLARADA
57
+ * (README, «Deny on relations»).
58
+ * **Requisito del despliegue: `--max-tuples-per-write` ≥ 100** (README, «Limits»). Un servidor con el tope MÁS BAJO
59
+ * responde 400 `exceeded_entity_limit` al primer `Write` que lo pase, y ése puede no ser el primero de la purga (con
60
+ * tope 50 y `[3 allows][60 membresías][40 denies]` el `Write` de 3 ya se aplicó): el driver lo dice como **500
61
+ * `E_AUTHZ_CONFIG`** nombrando el requisito —no como un 503 que parece transitorio: repetida, la purga vuelve a pedir
62
+ * un `Write` de más de lo que el servidor admite y no termina nunca— y, si ya borró algo, **marcado como escritura
63
+ * parcial** (`indeterminate: true`, invariante 13). Cierre 2 de D1 · ⚪ N3 del auditor (la letra anterior, «503 sin
64
+ * haber borrado nada», era falsa con varios `Write`). Uno con el tope más alto no cambia nada (el driver sigue partiendo
65
+ * en 100). Sin opción para bajarlo: la cota es del servidor y el remedio es su arranque.
66
+ */
67
+ export const RELATIONS_PURGE_WRITE_MAX = 100;
49
68
  /** Tamaño de página de `Read` (máximo del servidor). */
50
69
  const READ_PAGE_SIZE = 100;
51
70
  /**
@@ -112,6 +131,42 @@ const IGNORE_DUPLICATE_WRITES = {
112
131
  const IGNORE_MISSING_DELETES = {
113
132
  conflict: { onMissingDeletes: ClientWriteRequestOnMissingDeletes.Ignore },
114
133
  };
134
+ /** La condición de una tupla leída, en la forma de `StoredRelationTuple` (el ÚNICO paso por `fgaTupleExpiry`). */
135
+ function storedCondition(condition) {
136
+ const expiry = fgaTupleExpiry(condition);
137
+ if (expiry.kind === 'expiry')
138
+ return { validUntil: expiry.at };
139
+ if (expiry.kind === 'none')
140
+ return { validUntil: null };
141
+ return { validUntil: null, invalid: `${expiry.kind}: ${expiry.detail}` };
142
+ }
143
+ /**
144
+ * **El error de una llamada DENTRO de la espera tras una calibración fallida** (cierre-3 de R1 · R-5): una instancia NUEVA
145
+ * por llamada, con `cause` = el error memorizado. Misma clase, `status`, `code` y `details` que el memorizado, salvo un
146
+ * TIMEOUT (`AuthorizationBackendTimeoutError`), que pasa a 503 `AuthorizationBackendError` no-timeout: lo que venció fue
147
+ * la sonda de OTRA llamada; esta no tocó la red y su escritura no ocurrió, así que el manager no debe notificar
148
+ * `indeterminate` (invariante 13).
149
+ */
150
+ function backoffError(memorized, remainingMs) {
151
+ const suffix = ` (la calibración del techo de anidación falló hace poco; en espera ${Math.max(0, Math.ceil(remainingMs))} ms más, sin sondear)`;
152
+ if (memorized instanceof AuthorizationBackendTimeoutError) {
153
+ const error = new AuthorizationBackendError('openfga-relations', 'groupDepthLimit', memorized);
154
+ error.message = `${memorized.message}${suffix}`;
155
+ return error;
156
+ }
157
+ if (!(memorized instanceof Error))
158
+ return memorized;
159
+ const copy = Object.create(Object.getPrototypeOf(memorized));
160
+ for (const key of Reflect.ownKeys(memorized)) {
161
+ if (key === 'partialWrite' || key === 'stack')
162
+ continue;
163
+ Object.defineProperty(copy, key, Object.getOwnPropertyDescriptor(memorized, key));
164
+ }
165
+ Object.defineProperty(copy, 'message', { value: `${memorized.message}${suffix}`, writable: true, configurable: true, enumerable: false });
166
+ Object.defineProperty(copy, 'cause', { value: memorized, writable: true, configurable: true, enumerable: false });
167
+ Error.captureStackTrace?.(copy, backoffError);
168
+ return copy;
169
+ }
115
170
  export class OpenFgaRelationsDriver {
116
171
  /**
117
172
  * Lo que declara (cada valor tiene su cara en `runRelationsDriverContract`):
@@ -146,6 +201,13 @@ export class OpenFgaRelationsDriver {
146
201
  enumerateRelations: true,
147
202
  listObjectsTruncation: true,
148
203
  injectableClock: true,
204
+ // Lote D · D1: el deny es la relación `denied_<rel>` del modelo fusionado (`X = … but not denied_X`); el corte lo
205
+ // evalúa el SERVIDOR en `check`/`batchCheck`/`ListObjects` (una lista cortada es un subconjunto: fail-closed).
206
+ relationDenies: true,
207
+ // Cierre 2 de D2 · 🟠 N1: `false` EXPLÍCITO — FGA no reemplaza la misma clave en un `Write` (M5): `relate` renueva
208
+ // con DOS (delete, write). Por eso NIEGA renovar una membresía que transporta un deny (409
209
+ // `E_AUTHZ_RENEWAL_UNSAFE`, cero escrituras) y marca parcial la renovación permitida cuyo 2.º `Write` falla.
210
+ atomicRenewal: false,
149
211
  // L-2/L-5 (panel `{trx}`, (C)): `false` EXPLÍCITO, y no puede ser otra
150
212
  // cosa — una tupla no entra en una transacción SQL: el store es otro
151
213
  // servicio y no hay 2PC, así que «los dos o ninguno» con la transacción
@@ -161,6 +223,8 @@ export class OpenFgaRelationsDriver {
161
223
  // ajusta (usa `database` para lo que tenga que ir en su transacción, o
162
224
  // `requireTransactionalWrites` para fallar al arrancar).
163
225
  transactionalWrites: false,
226
+ // Estrategia 2.5.0 · firma 4 (regla D15: opcional, normalizada a `false`): EXPERIMENTAL en 2.5.x, como el de roles.
227
+ experimental: true,
164
228
  });
165
229
  client;
166
230
  #config;
@@ -174,8 +238,30 @@ export class OpenFgaRelationsDriver {
174
238
  #listObjectsDeadlineMs;
175
239
  #monotonic;
176
240
  #logger;
241
+ /**
242
+ * **La comparación modelo ↔ config de L11** (`#modelGate`): se lee UNA vez por proceso (`readAuthorizationModel` del
243
+ * `modelId` fijado, obligatorio) y se comparte con las vistas `withClock` (objeto que se muta). Un fallo transitorio
244
+ * al leerla no se memoriza (503 y se reintenta en la siguiente).
245
+ */
246
+ #modelMemo = {};
247
+ /**
248
+ * **La calibración del techo de anidación de grupos** (R1, decisión 5 + 5-bis): UNA por proceso, compartida con las
249
+ * vistas `withClock` (objeto que se muta, como `#modelMemo`). Se memoriza SOLO el éxito: un fallo (503, deadline, el
250
+ * 500 de una sonda ciega) no se queda — la siguiente operación vuelve a calibrar (C13a), pero no antes de
251
+ * `GROUP_DEPTH_CALIBRATION_BACKOFF_MS` (`failure`: hasta entonces se relanza ese mismo error sin sondear; cierre-2 de
252
+ * R1 · D5). Y un 503 `E_AUTHZ_RESOLUTION_LIMIT` de una lectura invalida el éxito memorizado, como mucho una vez cada
253
+ * `GROUP_DEPTH_RECALIBRATION_MIN_INTERVAL_MS` (`invalidatedAt`; P2 del juez): el límite BAJADO en el servidor se ve
254
+ * sin reiniciar la app.
255
+ */
256
+ #groupDepthMemo = {};
177
257
  diagnostics = { unparseableTuples: 0 };
178
258
  constructor(config, options) {
259
+ // Estrategia 2.5.0 · firma 4: `openfga` es EXPERIMENTAL en 2.5.x. PRIMERA línea: sin el token, 500 sin crear el cliente
260
+ // (la vista `withClock` reconstruye con las MISMAS opciones, así que hereda el token y vuelve a pasar por aquí).
261
+ assertExperimentalOptIn('OpenFgaRelationsDriver', options?.experimental);
262
+ // Cierre de R1 · 🟡 4 del auditor: el driver RE-VALIDA el techo de la config (una config extendida con spread no pasó
263
+ // por `defineRelationsConfig`; `NaN` apagaba el gate en silencio). 500 al construir.
264
+ assertGroupDepthSettings(config.maxGroupDepth, config.enforceGroupDepth, 'OpenFgaRelationsDriver');
179
265
  this.#config = config;
180
266
  this.#options = options;
181
267
  this.#now = options.now ?? systemClock;
@@ -228,6 +314,10 @@ export class OpenFgaRelationsDriver {
228
314
  }
229
315
  this.#monotonic = monotonic ?? (() => performance.now());
230
316
  this.#logger = options.logger ?? console;
317
+ // **L11 · `modelId` OBLIGATORIO, siempre** (decisión del dueño 2026-10-17 (2); sustituye a D14 —«solo con deny»— y a
318
+ // `#assertPinnedForDecideMany`, P5/P6): sin el pin, la puerta L11 compara el modelo del arranque y el servidor
319
+ // evalúa con el último de AHORA, así que la igualdad no protegería nada.
320
+ assertPinnedModel('OpenFgaRelationsDriver', options.modelId);
231
321
  this.client = new OpenFgaClient({
232
322
  apiUrl: options.apiUrl,
233
323
  storeId: options.storeId,
@@ -249,6 +339,9 @@ export class OpenFgaRelationsDriver {
249
339
  }
250
340
  const view = new OpenFgaRelationsDriver(this.#config, { ...this.#options, now });
251
341
  view.client = this.client;
342
+ // La puerta L11 es del PROCESO: la vista comparte la lectura del modelo (el MISMO objeto, que se muta).
343
+ view.#modelMemo = this.#modelMemo;
344
+ view.#groupDepthMemo = this.#groupDepthMemo;
252
345
  return view;
253
346
  }
254
347
  /** El `context` de todo `Check`/`ListObjects`: la condición `not_expired` se evalúa contra este instante (un `now` por operación). */
@@ -267,21 +360,28 @@ export class OpenFgaRelationsDriver {
267
360
  * que dice «venció el deadline» —503 `E_AUTHZ_BACKEND_TIMEOUT`, indeterminado—,
268
361
  * y para lo que el cliente lanza por su cuenta el `isTimeoutLike` ESTRICTO
269
362
  * de `backend_guard.ts` (`ECONNABORTED`/`ETIMEDOUT`/`.timeout` numérico/
270
- * `KnexTimeoutError`, por la cadena de `cause`). Hasta el cierre-2 este
363
+ * `KnexTimeoutError` y, desde R3, el `FgaError` sin respuesta, por la cadena de `cause`); el RESTO lo clasifica
364
+ * `classifyFgaFailure` (R3: transitorio 503, `E_AUTHZ_RESOLUTION_LIMIT` 503, despliegue 500, carrera 409, 4xx
365
+ * desconocido 500). Hasta el cierre-2 este
271
366
  * driver duplicaba la función con una rama por SUBSTRING del mensaje
272
367
  * (`/timeout/i`): un error que decía «no es timeout» salía como timeout con
273
368
  * `indeterminate: true` sin haber borrado nada, y el docblock afirmaba en
274
369
  * falso «mismo criterio que roles».
275
370
  */
276
- async #guard(operation, fn) {
371
+ async #guard(operation, fn, write = false) {
277
372
  const fail = (error) => {
278
373
  // Un error del paquete (422/500 ya clasificado) se propaga tal cual.
279
374
  if (error instanceof InvalidIdentityError || error instanceof PurgeIncompleteError)
280
375
  return error;
281
- if (isTimeoutLike(error)) {
282
- return new AuthorizationBackendTimeoutError('openfga-relations', operation, this.#timeoutMs, error);
283
- }
284
- return new AuthorizationBackendError('openfga-relations', operation, error);
376
+ // R3 (decisión del dueño 2026-10-17 (3)): la clase la decide `classifyFgaFailure`, el MISMO punto de paso que el
377
+ // driver de roles (por `statusCode` + `apiErrorCode`, jamás por el mensaje).
378
+ // Cierre de R3 (🟡 R3-A2): `write` ⇒ el 504 / `deadline_exceeded` del `Write` es indeterminado (timeout).
379
+ const failure = fgaFailureError('openfga-relations', operation, error, { timeoutMs: this.#timeoutMs, write });
380
+ // Cierre-2 de R1 · D5 (P2 del juez): un 503 de profundidad FUERA de la calibración dice que el servidor ya no
381
+ // resuelve lo que el techo memorizado prometía (límite bajado sin reiniciar la app) ⇒ re-calibrar, acotado.
382
+ if (failure instanceof ResolutionLimitError && operation !== 'groupDepthLimit')
383
+ this.#invalidateGroupDepth();
384
+ return failure;
285
385
  };
286
386
  let pending;
287
387
  try {
@@ -294,6 +394,69 @@ export class OpenFgaRelationsDriver {
294
394
  throw fail(error);
295
395
  }), this.#timeoutMs, () => new AuthorizationBackendTimeoutError('openfga-relations', operation, this.#timeoutMs));
296
396
  }
397
+ /* ── L11: el modelo FIJADO es el que genera la config (una puerta, por tipo) ─────────────────────────── */
398
+ /**
399
+ * **La lectura del modelo, UNA por proceso** (L11; memo compartido con las vistas `withClock` — es un OBJETO que se
400
+ * MUTA, nunca se reasigna en la vista, H11 b): `readAuthorizationModel` del `modelId` FIJADO (obligatorio desde L11) y
401
+ * `factsModelDifferences` contra la config de este proceso, con el MISMO mapa de holders que usa `openfga:provision`
402
+ * (H11 a). Se memoriza el ÉXITO de la lectura —también si hay diferencias: el modelo fijado no cambia (C13 b)— y un
403
+ * generador que no puede generar (500, H11 d); **un fallo al LEER el modelo NO se memoriza, sea de la clase que sea**
404
+ * (C13 a; cierre de L11): la siguiente operación vuelve a leer.
405
+ */
406
+ #modelGate() {
407
+ const memo = this.#modelMemo;
408
+ if (memo.gate)
409
+ return memo.gate;
410
+ // Cierre de L11 (🟡-6 del tester): «comparación hecha ⇒ memo» y «fallo al leer el modelo ⇒ no memo», por la FASE y no
411
+ // por la clase del error — con R3 un 401/403 o un modelo inexistente serán 500 de despliegue y no pueden quedarse
412
+ // memorizados de por vida en el proceso.
413
+ let compared = false;
414
+ const gate = this.#guard('readAuthorizationModel', async () => {
415
+ const response = await this.client.readAuthorizationModel();
416
+ const served = response?.authorization_model;
417
+ if (!Array.isArray(served?.type_definitions)) {
418
+ throw new Error('la respuesta de ReadAuthorizationModel no trae type_definitions; no se toma por un modelo igual');
419
+ }
420
+ return served;
421
+ }).then((served) => {
422
+ compared = true;
423
+ return factsModelDifferences(served, { holderTypes: this.#holderTypes, relations: this.#config, slice: 'relations' });
424
+ });
425
+ const pending = gate.catch((error) => {
426
+ if (!compared && memo.gate === pending)
427
+ memo.gate = undefined;
428
+ throw error;
429
+ });
430
+ memo.gate = pending;
431
+ pending.catch(() => { });
432
+ return pending;
433
+ }
434
+ /**
435
+ * **La puerta L11** (lote «antes de RC», decisión del dueño 2026-10-17 (1); sustituye a P1–P4 —D-31, R2, la excepción
436
+ * D8 por relación y `#modelDenied`— y a P6 —`#assertPinnedForDecideMany`—): antes de la PRIMERA llamada al servidor
437
+ * de la operación, el modelo que sirve el driver tiene que ser IGUAL al que genera la config en los tipos que la
438
+ * operación toca, más `group` (un `group#member` distinto cambia quién es miembro y, con ello, qué concede un `check`
439
+ * de cualquier tipo), más los holders y las CONDICIONES (deciden la vigencia de toda tupla). Si no, 500
440
+ * `E_AUTHZ_CONFIG` nombrando las diferencias por ruta y CERO llamadas que evalúen o escriban.
441
+ *
442
+ * Sin exenciones (injerto 4 del juez): las purgas y los `list*` pasan la puerta ENTERA —el orden de la purga (L10) se
443
+ * demostró sobre la semántica de la CONFIG y la vigencia la decide la condición del modelo—; `enumerateRelations(Many)`,
444
+ * que es el ORIGEN de una migración y tiene que poder rescatar un store con deriva, pasa SOLO la parte `conditions`
445
+ * (`part: 'conditions'`). El orden en cada operación: la puerta de `{ transaction }`, F-05 y la gramática (422, cero
446
+ * llamadas) van ANTES; la puerta va justo antes de la primera llamada (H1-5).
447
+ */
448
+ async #assertModel(operation, types, part = 'full') {
449
+ const comparison = await this.#modelGate();
450
+ const touched = [...types];
451
+ // Una operación sobre `group` (una membresía) cambia lo que concede o niega CUALQUIER tipo —y su renovación lee los
452
+ // `denied_*` de los tipos con deny (`#carriedDeny`)—: compara todos los tipos de la config.
453
+ const groupWide = touched.includes(RELATION_USERSET_TYPE) ? this.#config.objectTypes.map((t) => t.type) : [];
454
+ const relevant = new Set([...touched, ...groupWide, RELATION_USERSET_TYPE, ...Object.values(this.#holderTypes)]);
455
+ const hits = comparison.differences.filter((d) => part === 'conditions' ? d.type === null && d.path.startsWith('conditions.') : d.type === null || relevant.has(d.type));
456
+ if (hits.length > 0)
457
+ throw factsModelDriftError('OpenFgaRelationsDriver', operation, this.#options.modelId, hits);
458
+ return comparison;
459
+ }
297
460
  /* ── Composición y parseo de ids (⚪5: partición por string en el id) ────── */
298
461
  /** `<type>:<scopeKey(partition)>|<objectUuid>`. */
299
462
  #objectId(objectType, partitionKey, objectUuid) {
@@ -380,8 +543,12 @@ export class OpenFgaRelationsDriver {
380
543
  * - presente con la caducidad que toca ⇒ no-op (idempotente, invariante 6);
381
544
  * - presente con OTRA ⇒ delete + write en DOS llamadas (FGA no admite
382
545
  * delete+write de la misma clave en un `Write`, ni reescribir su
383
- * condición): entre ambas hay un instante en el que `check` responde
384
- * `false` —fail-closed, la misma ventana que el `grant` de roles—.
546
+ * condición): entre ambas la tupla NO está. Para un allow eso solo
547
+ * quita (fail-closed, la misma ventana que el `grant` de roles); para una
548
+ * membresía `group:*#member` que TRANSPORTA un deny, CONCEDERÍA — desde el
549
+ * cierre 2 de D2 (🟠 N1) esa renovación es 409 `E_AUTHZ_RENEWAL_UNSAFE`
550
+ * con cero escrituras (`#carriedDeny`), y si el 2.º `Write` de una
551
+ * renovación permitida falla el error va marcado PARCIAL (`markPartialWrite`).
385
552
  */
386
553
  /**
387
554
  * **`{ transaction }` se rechaza también AQUÍ** (L-5, defensa en
@@ -413,18 +580,303 @@ export class OpenFgaRelationsDriver {
413
580
  const user = this.#subjectUser(subject, partitionKey);
414
581
  const key = { user, relation, object: objectId };
415
582
  const now = this.#now();
583
+ await this.#assertModel('relate', [object.type]);
416
584
  const current = await this.#readTuple('relate', key);
585
+ // L11 · H8: la clave YA existe con una condición que el paquete no escribe (`foreign`) o no sabe leer
586
+ // (`unparseable`). Tratarla como «sin caducidad» haría de este `relate` un no-op mudo (y la tupla inerte para el
587
+ // servidor, M-a); como «vencida», una renovación en dos `Write` sin mirar portadoras. 409, cero escrituras, receta.
588
+ if (current?.invalid !== undefined) {
589
+ throw RenewalUnsafeError.foreignCondition('relate', `${objectId}#${relation}@${user}`, current.invalid);
590
+ }
417
591
  const previous = current ? current.validUntil : null;
418
592
  const expiresAt = resolveGrantExpiry(previous, options?.expiresAt, now);
419
593
  if (current && sameInstant(previous, expiresAt))
420
594
  return;
595
+ // R1 (decisión del dueño 2026-10-17 (5)): una arista de anidación que queda VIVA —nueva, renovada o revivida— pasa el
596
+ // gate del techo (calibrado, o el `maxGroupDepth` menor de la config) ANTES de cualquier `Write`: 422 y cero escrituras.
597
+ // El no-op idéntico de ARRIBA va ANTES (invariante 6: un `relate` repetido de una arista preexistente no se vuelve 422).
598
+ if (isGroupNesting(subject, relation, object) &&
599
+ isActiveExpiry(expiresAt, now) &&
600
+ !plannedByReconcile(options, partition, { child: subject.object.id, parent: object.id })) {
601
+ const limit = await this.groupDepthLimit();
602
+ // El presupuesto (y su deadline TOTAL) empieza DESPUÉS de la calibración: el primer `relate` de un proceso en frío
603
+ // paga la calibración aparte y no puede dar un 422 espurio por ella (cierre de R1, panel).
604
+ const budget = new GroupDepthBudget();
605
+ if (limit !== null)
606
+ await this.#assertGroupNesting(partitionKey, { child: subject.object.id, parent: object.id }, now, limit, budget);
607
+ }
421
608
  const tuple = expiresAt
422
609
  ? { ...key, condition: { name: FACTS_EXPIRY_CONDITION, context: { valid_until: expiresAt.toISOString() } } }
423
610
  : key;
424
611
  if (current) {
425
- await this.#guard('relate', () => this.client.write({ deletes: [key] }, IGNORE_MISSING_DELETES));
612
+ // Cierre 2 de D2 · 🟠 N1 (decisión del dueño 2026-10-15 (9)): una membresía que TRANSPORTA un deny no se renueva
613
+ // con dos `Write` — entre el delete y el write el sujeto quedaría sin el deny y concedería. 409, CERO escrituras.
614
+ // Cierre 3 de D2 · ⚪ C2-4: una membresía YA VENCIDA no conecta nada (el deny ya no alcanza al sujeto por ella), así
615
+ // que no hay ventana que proteger: se reescribe sin comprobar portadora (hasta el cierre 3 era un 409 espurio).
616
+ const guarded = object.type === RELATION_USERSET_TYPE && isActiveExpiry(previous, now);
617
+ const membership = `${objectId}#${relation}@${user}`;
618
+ if (guarded) {
619
+ const carried = await this.#carriedDeny(objectId, partitionKey, now);
620
+ if (carried !== null)
621
+ throw RenewalUnsafeError.carrier(membership, carried);
622
+ }
623
+ await this.#guard('relate', () => this.client.write({ deletes: [key] }, IGNORE_MISSING_DELETES), true);
624
+ // Cierre 2 de D2 · 🟠 N1 (b): el delete YA entró — si el write falla, la escritura es PARCIAL (invariante 13: el
625
+ // manager notifica `indeterminate: true`); hasta el cierre era un 503 «sin escribir» con la tupla borrada.
626
+ try {
627
+ await this.#guard('relate', () => this.client.write({ writes: [tuple] }, IGNORE_DUPLICATE_WRITES), true);
628
+ }
629
+ catch (error) {
630
+ // Cierre 3 de D2 · 🟡 C2-3: la membresía quedó BORRADA. Si entre la comprobación y el delete entró un deny en el
631
+ // grupo (TOCTOU), ese deny ya no alcanza al sujeto —y no lo alcanzará hasta que la membresía se reescriba—: el
632
+ // error parcial lo NOMBRA (`carriedDeny`) con la receta. Mejor esfuerzo: si la re-lectura también falla, se dice.
633
+ if (guarded && error !== null && typeof error === 'object') {
634
+ annotateRacedDeny(error, membership, await this.#recheckCarried(objectId, partitionKey, now));
635
+ }
636
+ throw error !== null && typeof error === 'object' ? markPartialWrite(error) : error;
637
+ }
638
+ // Cierre 3 de D2 · 🟡 C2-3: la RE-COMPROBACIÓN. Un deny que entró entre `#carriedDeny` y los dos `Write` no alcanzó
639
+ // al sujeto durante la ventana; la renovación se aplicó (el estado final es correcto) y el error lo dice, marcado
640
+ // parcial (el manager notifica `indeterminate: true`). La ventana NO se cierra (FGA no tiene una primitiva para
641
+ // serializar `deny` contra la renovación): deja de ser muda. README «Every other renewal stays two `Write`s».
642
+ if (guarded) {
643
+ const recheck = await this.#recheckCarried(objectId, partitionKey, now);
644
+ if (recheck.deny !== null)
645
+ throw markPartialWrite(RenewalRacedError.raced(membership, recheck.deny));
646
+ if (recheck.error !== undefined) {
647
+ const error = recheck.error;
648
+ throw error !== null && typeof error === 'object'
649
+ ? markPartialWrite(annotateRacedDeny(error, membership, recheck, true))
650
+ : error;
651
+ }
652
+ }
653
+ return;
654
+ }
655
+ await this.#guard('relate', () => this.client.write({ writes: [tuple] }, IGNORE_DUPLICATE_WRITES), true);
656
+ }
657
+ /**
658
+ * **El techo de anidación de grupos de ESTE driver** (lote «antes de RC» · R1, decisiones del dueño 2026-10-17 (5) y
659
+ * (5-bis), opción (a)): ACTIVO por defecto. Se CALIBRA contra el servidor (`#calibratedGroupDepth`) y es el mínimo de
660
+ * la sonda positiva y la negativa (22 en OpenFGA v1.19 con `--resolve-node-limit` por defecto, medido en F0 · M-c); el
661
+ * `maxGroupDepth` de la config solo puede BAJARLO — uno mayor que el calibrado es 500 `E_AUTHZ_CONFIG` con las dos
662
+ * cifras (el despliegue promete un techo que el servidor no resuelve) —, y `false` lo apaga de forma explícita (sin
663
+ * calibrar). Solo lectura: lo usa `relate` antes de escribir una arista `group#member` y el plan de
664
+ * `reconcileRelations` sobre el destino.
665
+ */
666
+ async groupDepthLimit() {
667
+ const configured = assertGroupDepthSettings(this.#config.maxGroupDepth, this.#config.enforceGroupDepth, 'OpenFgaRelationsDriver').maxGroupDepth;
668
+ if (configured === false)
669
+ return null;
670
+ const calibrated = await this.#calibratedGroupDepth();
671
+ if (configured !== undefined && configured > calibrated) {
672
+ throw Object.assign(new AuthorizationConfigError(`OpenFgaRelationsDriver: maxGroupDepth = ${configured} en la config de relaciones y el servidor solo resuelve ` +
673
+ `${calibrated} saltos de grupos anidados (calibrado con tuplas contextuales: el mínimo de un miembro y de un ` +
674
+ 'no-miembro con membresía). Un techo por encima del calibrado admitiría cadenas que darán 503 ' +
675
+ `E_AUTHZ_RESOLUTION_LIMIT a los no-miembros. Baja maxGroupDepth a ≤ ${calibrated}, o sube ` +
676
+ 'OPENFGA_RESOLVE_NODE_LIMIT (--resolve-node-limit) en el servidor y reinicia la app: SUBIR el límite no lo ' +
677
+ 'detecta nada (la calibración se memoriza por proceso); BAJARLO sí, sin reinicio — el 503 de profundidad de ' +
678
+ 'una lectura la re-calibra, como mucho una vez por minuto.'), { details: { maxGroupDepth: configured, calibrated } });
679
+ }
680
+ return configured ?? calibrated;
681
+ }
682
+ /**
683
+ * La calibración memorizada (solo el éxito; una en vuelo se comparte). Tras un fallo, durante
684
+ * `GROUP_DEPTH_CALIBRATION_BACKOFF_MS` se relanza la MISMA CLASE de error sin sondear (cierre-2 de R1 · D5: el backoff);
685
+ * después se vuelve a calibrar. Cada llamada de la ventana recibe un error NUEVO con `cause` = el memorizado (cierre-3 ·
686
+ * R-5: compartir la instancia compartía cualquier anotación de un llamador), y un TIMEOUT de la calibración se relanza
687
+ * como 503 no-timeout: la escritura de la ventana no tocó la red, así que no es indeterminada (invariante 13).
688
+ */
689
+ #calibratedGroupDepth() {
690
+ const memo = this.#groupDepthMemo;
691
+ if (memo.calibrated)
692
+ return memo.calibrated;
693
+ if (memo.failure && this.#monotonic() - memo.failure.at < GROUP_DEPTH_CALIBRATION_BACKOFF_MS) {
694
+ return Promise.reject(backoffError(memo.failure.error, GROUP_DEPTH_CALIBRATION_BACKOFF_MS - (this.#monotonic() - memo.failure.at)));
695
+ }
696
+ const pending = this.#calibrate().then((calibrated) => {
697
+ if (memo.calibrated === pending)
698
+ memo.failure = undefined;
699
+ return calibrated;
700
+ }, (error) => {
701
+ if (memo.calibrated === pending) {
702
+ memo.calibrated = undefined;
703
+ memo.failure = { at: this.#monotonic(), error };
704
+ }
705
+ throw error;
706
+ });
707
+ memo.calibrated = pending;
708
+ pending.catch(() => { });
709
+ return pending;
710
+ }
711
+ /**
712
+ * **Re-calibración tras un 503 de profundidad** (cierre-2 de R1 · D5 (a), P2 del juez): olvida el techo memorizado para
713
+ * que la siguiente escritura de anidación lo vuelva a medir, como mucho UNA vez cada
714
+ * `GROUP_DEPTH_RECALIBRATION_MIN_INTERVAL_MS` por proceso (reloj monótono). Es lo que hace honesta (5-bis) —«nadie
715
+ * recibe 503 dentro del límite»— cuando el operador BAJA el límite del servidor sin reiniciar; subirlo no da 503 y
716
+ * sigue pidiendo reinicio. Sin techo memorizado (nunca calibró, o falló) no hay nada que olvidar.
717
+ */
718
+ #invalidateGroupDepth() {
719
+ const memo = this.#groupDepthMemo;
720
+ if (memo.calibrated === undefined)
721
+ return;
722
+ const now = this.#monotonic();
723
+ if (memo.invalidatedAt !== undefined && now - memo.invalidatedAt < GROUP_DEPTH_RECALIBRATION_MIN_INTERVAL_MS)
724
+ return;
725
+ memo.calibrated = undefined;
726
+ memo.invalidatedAt = now;
727
+ }
728
+ /**
729
+ * **La calibración** (decisión 5: «con tuplas contextuales, solo lectura, forma de dominio»; 5-bis: el MÍNIMO de las
730
+ * dos sondas): un `Check` con TODO el grafo en `contextual_tuples` sobre ids que no existen en el store —
731
+ * `<holder>:authz-calibration-member ∈ g0 ⊂ … ⊂ gK` y la relación de DOMINIO del primer tipo declarado sobre
732
+ * `gK#member` (positiva: T), y `<holder>:authz-calibration-outsider ∈ h0 ⊂ h1` (negativa CON membresía: F; sin
733
+ * membresías la sonda es CIEGA, M-c) —, siempre con `HIGHER_CONSISTENCY`. Pasa antes la puerta L11 (el modelo fijado es
734
+ * el de la config). Sin ningún tipo con relación (que acepte `group#member`) calibra con `group#member` y resta 1
735
+ * (T-4: la forma `member` resuelve un salto más que la de dominio).
736
+ */
737
+ async #calibrate() {
738
+ const domainType = this.#config.objectTypes.find((t) => t.relations.length > 0);
739
+ const domain = domainType ? { type: domainType.type, relation: domainType.relations[0].name } : null;
740
+ await this.#assertModel('groupDepthLimit', [domain?.type ?? RELATION_USERSET_TYPE]);
741
+ const holder = Object.values(this.#holderTypes)[0];
742
+ if (!holder) {
743
+ throw new AuthorizationConfigError('OpenFgaRelationsDriver: sin holderTypes no se puede calibrar el techo de anidación de grupos (la sonda necesita un holder).');
744
+ }
745
+ const ns = 'authz-calibration';
746
+ const group = (name) => `${RELATION_USERSET_TYPE}:${ns}|${name}`;
747
+ const member = `${holder}:${ns}-member`;
748
+ const outsider = `${holder}:${ns}-outsider`;
749
+ const context = this.#checkContext(this.#now());
750
+ const probe = async (depth, side) => {
751
+ const tuples = [
752
+ { user: member, relation: RELATION_USERSET_RELATION, object: group('g0') },
753
+ ];
754
+ for (let i = 0; i < depth; i++) {
755
+ tuples.push({ user: `${group(`g${i}`)}#${RELATION_USERSET_RELATION}`, relation: RELATION_USERSET_RELATION, object: group(`g${i + 1}`) });
756
+ }
757
+ tuples.push({ user: outsider, relation: RELATION_USERSET_RELATION, object: group('h0') });
758
+ tuples.push({ user: `${group('h0')}#${RELATION_USERSET_RELATION}`, relation: RELATION_USERSET_RELATION, object: group('h1') });
759
+ const top = `${group(`g${depth}`)}#${RELATION_USERSET_RELATION}`;
760
+ const target = domain
761
+ ? { relation: domain.relation, object: `${domain.type}:${ns}|x` }
762
+ : { relation: RELATION_USERSET_RELATION, object: group(`g${depth}`) };
763
+ if (domain)
764
+ tuples.push({ user: top, relation: domain.relation, object: target.object });
765
+ try {
766
+ const response = await this.#guard('groupDepthLimit', () => this.client.check({ user: side === 'positive' ? member : outsider, relation: target.relation, object: target.object, contextualTuples: tuples, context }, { consistency: ConsistencyPreference.HigherConsistency }));
767
+ return (response?.allowed === true) === (side === 'positive') ? 'ok' : 'blind';
768
+ }
769
+ catch (error) {
770
+ if (error instanceof ResolutionLimitError)
771
+ return 'too-complex';
772
+ throw error;
773
+ }
774
+ };
775
+ const calibrated = await calibrateGroupDepth(probe);
776
+ return domain ? calibrated : Math.max(0, calibrated - 1);
777
+ }
778
+ /**
779
+ * **El gate de R1 en `openfga`**: lee el grafo de anidación de la partición hacia ARRIBA desde el contenedor (un `Read`
780
+ * por `user` = `group:<g>#member` y tipo `group:`, por grupo) y hacia ABAJO desde el contenido (un `Read` por objeto
781
+ * `group:<g>` y relación `member`, que trae también los holders: O(miembros) en una escritura rara, declarado), con la
782
+ * vigencia del instante de la escritura (una arista vencida no conecta; una con condición `foreign`/`unparseable`
783
+ * CUENTA: más 422, nunca menos). Concurrencia: dos `relate` que juntas exceden pasan las dos (cada una lee el grafo
784
+ * sin la otra) — declarado parcial, como `scopes.maxDepth`.
785
+ *
786
+ * **Presupuesto** (cierre de R1 · 🟠 1 del auditor): cada página de `Read` pasa por `GroupDepthBudget` —requests,
787
+ * tuplas leídas (los holders que el `Read` hacia abajo no puede filtrar cuentan) y deadline TOTAL del veredicto—; al
788
+ * pasarlo, 422 `too-many-groups` sin escribir.
789
+ */
790
+ async #assertGroupNesting(partitionKey, edge, now, limit, budget) {
791
+ const pages = { before: () => budget.beforeRead(), after: (n) => budget.charge(n) };
792
+ const prefix = `${RELATION_USERSET_TYPE}:${partitionKey}|`;
793
+ const live = (t) => t.invalid !== undefined || isActiveExpiry(t.validUntil ?? null, now);
794
+ const idOf = (fullObject) => {
795
+ const parsed = this.#parseObjectId(fullObject);
796
+ return parsed && parsed.type === RELATION_USERSET_TYPE && parsed.partitionKey === partitionKey ? parsed.objectUuid : null;
797
+ };
798
+ const verdict = await groupNestingVerdict(edge, limit, {
799
+ parentsOf: async (groups) => {
800
+ const out = [];
801
+ for (const child of groups) {
802
+ const user = `${prefix}${child}#${RELATION_USERSET_RELATION}`;
803
+ for (const t of await this.#readAll('relate', { user, relation: RELATION_USERSET_RELATION, object: `${RELATION_USERSET_TYPE}:` }, pages)) {
804
+ const parent = t.user === user && t.relation === RELATION_USERSET_RELATION && live(t) ? idOf(t.object) : null;
805
+ if (parent !== null)
806
+ out.push({ child, parent });
807
+ }
808
+ }
809
+ return out;
810
+ },
811
+ childrenOf: async (groups) => {
812
+ const out = [];
813
+ for (const parent of groups) {
814
+ const object = `${prefix}${parent}`;
815
+ for (const t of await this.#readAll('relate', { object, relation: RELATION_USERSET_RELATION }, pages)) {
816
+ const suffix = `#${RELATION_USERSET_RELATION}`;
817
+ if (t.object !== object || t.relation !== RELATION_USERSET_RELATION || !t.user.endsWith(suffix) || !live(t))
818
+ continue;
819
+ const child = idOf(t.user.slice(0, -suffix.length));
820
+ if (child !== null)
821
+ out.push({ child, parent });
822
+ }
823
+ }
824
+ return out;
825
+ },
826
+ });
827
+ if (verdict.kind !== 'ok') {
828
+ throw groupNestingError('openfga-relations', verdict, `group:${edge.parent}#member@group:${edge.child}#member`, limit);
829
+ }
830
+ }
831
+ /**
832
+ * **¿Transporta el grupo un deny vivo?** (cierre 2 de D2 · 🟠 N1): el `#member` de `group` —o el de un grupo que lo
833
+ * contiene, transitivamente (`group:G#member@group:H#member`: los miembros de H heredan los denies de G)— es sujeto de
834
+ * una tupla `denied_*` de la partición, en algún tipo con opt-in del deny. Devuelve la primera que encuentra (para la
835
+ * letra) o `null`. Coste: por cada grupo del cierre hacia arriba, un `Read` por `user` + tipo (el filtro que FGA admite:
836
+ * `user` y `object` tipo) por cada tipo con deny, y uno de sus padres. Solo corre en una RENOVACIÓN de membresía.
837
+ */
838
+ async #carriedDeny(groupObjectId, partitionKey, now) {
839
+ const denyTypes = this.#config.objectTypes.filter((t) => denyPhaseOf(this.#config, t.type) !== undefined).map((t) => t.type);
840
+ if (denyTypes.length === 0)
841
+ return null;
842
+ const seen = new Set([groupObjectId]);
843
+ const queue = [groupObjectId];
844
+ while (queue.length > 0) {
845
+ const userset = `${queue.shift()}#${RELATION_USERSET_RELATION}`;
846
+ for (const type of denyTypes) {
847
+ for (const t of await this.#readAll('relate', { user: userset, object: `${type}:` })) {
848
+ // Cierre 6 de D2 · 🟡 D5-1: un deny VENCIDO no transporta nada (la MISMA regla que C2-4 para la arista y que el
849
+ // plan para toda tupla del destino: vencida = ausente). Contarlo hacía de la pasada REAL un 409 que el dry-run
850
+ // no anunciaba, y las particiones siguientes no migraban.
851
+ if (t.relation.startsWith(RELATION_DENY_PREFIX) &&
852
+ t.object.startsWith(`${type}:${partitionKey}|`) &&
853
+ // L11: un deny con una condición `foreign`/`unparseable` cuenta como VIVO (fail-closed: más 409, nunca menos).
854
+ (t.invalid !== undefined || isActiveExpiry(t.validUntil ?? null, now))) {
855
+ return `${t.object}#${t.relation}@${t.user}`;
856
+ }
857
+ }
858
+ }
859
+ for (const parent of await this.#readAll('relate', { user: userset, relation: RELATION_USERSET_RELATION, object: `${RELATION_USERSET_TYPE}:` })) {
860
+ // Cierre 3 de D2 · ⚪ C2-4: una arista de anidación VENCIDA no transporta nada (no conecta en FGA).
861
+ // L11: una arista con condición `foreign`/`unparseable` se sigue (más grupos que mirar: más 409, nunca menos).
862
+ if (parent.invalid === undefined && !isActiveExpiry(parent.validUntil ?? null, now))
863
+ continue;
864
+ if (!seen.has(parent.object)) {
865
+ seen.add(parent.object);
866
+ queue.push(parent.object);
867
+ }
868
+ }
869
+ }
870
+ return null;
871
+ }
872
+ /** `#carriedDeny` tras los dos `Write` (🟡 C2-3): nunca lanza; un fallo de la re-lectura viaja en `error`. */
873
+ async #recheckCarried(groupObjectId, partitionKey, now) {
874
+ try {
875
+ return { deny: await this.#carriedDeny(groupObjectId, partitionKey, now) };
876
+ }
877
+ catch (error) {
878
+ return { deny: null, error };
426
879
  }
427
- await this.#guard('relate', () => this.client.write({ writes: [tuple] }, IGNORE_DUPLICATE_WRITES));
428
880
  }
429
881
  /**
430
882
  * La tupla EXACTA de una clave (un `Read` por clave completa), con su caducidad si la lleva.
@@ -440,7 +892,7 @@ export class OpenFgaRelationsDriver {
440
892
  const found = response.tuples.map((t) => t.key).find((k) => k?.user === key.user && k?.relation === key.relation && k?.object === key.object);
441
893
  if (!found)
442
894
  return null;
443
- return { ...key, validUntil: toExpiryDate(found.condition?.context?.valid_until) };
895
+ return { ...key, ...storedCondition(found.condition) };
444
896
  }
445
897
  async unrelate(subject, relation, object, partition, options) {
446
898
  this.#rejectTransaction('unrelate', options);
@@ -451,9 +903,67 @@ export class OpenFgaRelationsDriver {
451
903
  const partitionKey = this.#keyOf(partition);
452
904
  const objectId = this.#objectId(object.type, partitionKey, object.id);
453
905
  const user = this.#subjectUser(subject, partitionKey);
906
+ await this.#assertModel('unrelate', [object.type]);
454
907
  await this.#guard('unrelate', () =>
455
908
  // Re-unrelate no-op seguro: borrar una tupla ausente se ignora.
456
- this.client.write({ deletes: [{ user, relation, object: objectId }] }, IGNORE_MISSING_DELETES));
909
+ this.client.write({ deletes: [{ user, relation, object: objectId }] }, IGNORE_MISSING_DELETES), true);
910
+ }
911
+ /**
912
+ * **`deny` = UN `Write` de la tupla `denied_<rel>`** (lote D · D1): sin caducidad (D3), así que no hay renovación
913
+ * ni ventana —un re-deny idéntico lo ignora el servidor (`on_duplicate: ignore`)—; una tupla de deny escrita a mano
914
+ * CON condición devuelve 409 y, desde el cierre 1 de D2 (🟡 4), sale como 500 `E_AUTHZ_CONFIG` con la receta
915
+ * (`AuthorizationConfigError.conditionalDeny`; hasta entonces era un 503 que se repetía para siempre). Antes del `Write`: la
916
+ * puerta de `{ transaction }` (L-5), F-05 sobre el nombre PÚBLICO, la puerta del opt-in (fase `'write'`), sin
917
+ * caducidad, la partición, `*`, el sujeto y la puerta del modelo (L11). CERO llamadas si algo de eso falla.
918
+ */
919
+ async deny(subject, relation, object, partition, options) {
920
+ this.#rejectTransaction('deny', options);
921
+ assertRelationDeclared(this.#config, object, relation);
922
+ assertRelationDenyable(this.#config, object, relation);
923
+ assertDenyNoExpiry(options);
924
+ assertPartition(partition);
925
+ assertDenySubject(subject);
926
+ const partitionKey = this.#keyOf(partition);
927
+ const objectId = this.#objectId(object.type, partitionKey, object.id);
928
+ const user = this.#subjectUser(subject, partitionKey);
929
+ await this.#assertModel('deny', [object.type]);
930
+ const key = { user, relation: deniedRelationOf(relation), object: objectId };
931
+ try {
932
+ await this.#guard('deny', () => this.client.write({ writes: [key] }, IGNORE_DUPLICATE_WRITES), true);
933
+ }
934
+ catch (error) {
935
+ // Cierre 1 de D2 · 🟡 4: el 409 de una clave que ya existe CON condición (medido en v1.19.0: «already exists with
936
+ // a different condition») no es una caída. Se confirma RELEYENDO la tupla (no por el texto del mensaje): si está y
937
+ // lleva condición, 500 con la receta; si no, el 409 de la carrera (R3; hasta R3, 503).
938
+ // R3: el 409 es la clase «carrera» (409 `E_AUTHZ_WRITE_CONFLICT`, antes 503); se mira la CAUSA del SDK.
939
+ if (error?.cause?.statusCode === 409) {
940
+ const found = await this.#guard('deny', () => this.client.read(key, { consistency: this.#consistency }));
941
+ if (Array.isArray(found?.tuples) && found.tuples.some((t) => t?.key?.condition)) {
942
+ throw AuthorizationConfigError.conditionalDeny('deny', `${key.object}#${key.relation}@${key.user}`);
943
+ }
944
+ }
945
+ throw error;
946
+ }
947
+ }
948
+ /**
949
+ * **`removeDeny` = UN delete de `denied_<rel>`** (no-op si no está). Sobre CUALQUIER tipo declarado (D8/R2-4 b):
950
+ * `Write` con delete no valida la relación contra el modelo (medido en v1.19.0), así que un deny vivo de un tipo
951
+ * que retiró el opt-in se puede borrar. Ojo: quitar un deny CONCEDE (filas declaradas S8–S10).
952
+ * **La puerta del modelo (L11, antes D-31) SÍ corre aquí** (letra corregida en el cierre 1 de D1, ⚪ 5 del auditor:
953
+ * la decisión 4 del informe decía que no): con el opt-in vivo y un modelo fijado sin `denied_*` ⇒ 500 antes del
954
+ * delete. Con el opt-in RETIRADO no hay guarda (el tipo no está en la config con `deny`) y el delete borra de verdad
955
+ * (medido por el auditor: `listDenies` de 1 a 0 y `check` vuelve a `true` con el modelo viejo) — retirar es reversible.
956
+ */
957
+ async removeDeny(subject, relation, object, partition, options) {
958
+ this.#rejectTransaction('removeDeny', options);
959
+ assertRelationDeclared(this.#config, object, relation);
960
+ assertDenyNoExpiry(options);
961
+ assertPartition(partition);
962
+ const partitionKey = this.#keyOf(partition);
963
+ const objectId = this.#objectId(object.type, partitionKey, object.id);
964
+ const user = this.#subjectUser(subject, partitionKey);
965
+ await this.#assertModel('removeDeny', [object.type]);
966
+ await this.#guard('removeDeny', () => this.client.write({ deletes: [{ user, relation: deniedRelationOf(relation), object: objectId }] }, IGNORE_MISSING_DELETES), true);
457
967
  }
458
968
  /* ── Lecturas ───────────────────────────────────────────────────────────── */
459
969
  /**
@@ -477,6 +987,7 @@ export class OpenFgaRelationsDriver {
477
987
  const objectId = this.#objectId(object.type, partitionKey, object.id);
478
988
  const user = this.#subjectUser(subject, partitionKey);
479
989
  const context = this.#checkContext(this.#now());
990
+ await this.#assertModel('check', [object.type]);
480
991
  const response = await this.#guard('check', () => this.client.check({ user, relation, object: objectId, context }, { consistency: this.#consistency }));
481
992
  return response.allowed === true;
482
993
  }
@@ -500,6 +1011,7 @@ export class OpenFgaRelationsDriver {
500
1011
  const user = this.#subjectUser(subject, partitionKey);
501
1012
  const { distinct, index } = distinctObjects(objects);
502
1013
  const context = this.#checkContext(this.#now());
1014
+ await this.#assertModel('checkMany', distinct.map((object) => object.type));
503
1015
  const checks = distinct.map((object, i) => ({
504
1016
  user,
505
1017
  relation,
@@ -519,6 +1031,105 @@ export class OpenFgaRelationsDriver {
519
1031
  }
520
1032
  return expandByPosition(index, answers);
521
1033
  }
1034
+ /**
1035
+ * **`decideMany` = un `batchCheck` con `R` y los `denied_<m>` de las relaciones FUENTE de `down*(R)`** (lote D ·
1036
+ * D9, juez ronda 2 §4): por el cierre ascendente del modelo (`denied_X = this ∨ denied_P`), `denied_<fuente>`
1037
+ * cubre todo `up*(down*(R))` — en el retículo del contrato, 2 ítems por objeto (`R` y `denied_owner`). Mismo
1038
+ * `current_time` en TODOS los ítems (un `now` por lote), alineado por `correlationId` (`alignBatchResults`, la
1039
+ * misma función que `checkMany` y roles), troceado por `FGA_MAX_BATCH_CHECK`, y un `error` en UN ítem ⇒ 503 para
1040
+ * todo el lote (nunca `none`: S15). La decisión sale de `decideFrom`, la regla ÚNICA (la misma que `database` y el
1041
+ * doble). En un tipo sin opt-in se pregunta el deny solo si el modelo que sirve el driver lo declara (opt-in
1042
+ * retirado con el modelo aún publicado, D8); si no, cada posición es `allow` o `none` (no hay deny posible).
1043
+ */
1044
+ async decideMany(subject, relation, objects, partition) {
1045
+ assertCheckManyObjects(objects, 'decideMany');
1046
+ for (const object of objects)
1047
+ assertRelationDeclared(this.#config, object, relation);
1048
+ assertPartition(partition);
1049
+ const partitionKey = this.#keyOf(partition);
1050
+ const user = this.#subjectUser(subject, partitionKey);
1051
+ const { distinct, index } = distinctObjects(objects);
1052
+ const context = this.#checkContext(this.#now());
1053
+ // L11: con la puerta, el modelo ES el que genera la config (con D8 por TIPO ENTERO): un tipo en forma `deny` declara
1054
+ // `denied_<rel>` para TODAS sus relaciones, uno en forma `plain` para ninguna.
1055
+ const { denyForm } = await this.#assertModel('decideMany', distinct.map((object) => object.type));
1056
+ const checks = [];
1057
+ const plan = [];
1058
+ distinct.forEach((object, i) => {
1059
+ const objectId = this.#objectId(object.type, partitionKey, object.id);
1060
+ const down = relationsDown(this.#config, object.type, relation);
1061
+ const type = this.#config.objectTypes.find((t) => t.type === object.type);
1062
+ const sources = down.filter((name) => {
1063
+ const includes = type?.relations.find((r) => r.name === name)?.includes ?? [];
1064
+ return includes.length === 0 && denyForm.get(object.type) === 'deny';
1065
+ });
1066
+ plan.push({ object, sources });
1067
+ // `correlationId` del servidor: `^[\w\d-]{1,36}$` (sin `:`): `a<i>` para R, `d<i>-<k>` para la fuente k.
1068
+ checks.push({ user, relation, object: objectId, context, correlationId: `a${i}` });
1069
+ sources.forEach((source, k) => {
1070
+ checks.push({ user, relation: deniedRelationOf(source), object: objectId, context, correlationId: `d${i}-${k}` });
1071
+ });
1072
+ });
1073
+ const allowed = new Map();
1074
+ for (let start = 0; start < checks.length; start += FGA_MAX_BATCH_CHECK) {
1075
+ const chunk = checks.slice(start, start + FGA_MAX_BATCH_CHECK);
1076
+ const response = await this.#guard('decideMany', () => this.client.batchCheck({ checks: chunk }, { consistency: this.#consistency }));
1077
+ const results = alignBatchResults('openfga-relations', 'decideMany', chunk, response.result);
1078
+ results.forEach((r, i) => allowed.set(chunk[i].correlationId, r.allowed === true));
1079
+ }
1080
+ // D9, por posición: `allow` si el `Check` de R concede (el servidor ya aplicó el corte); si no, `deny` si alguno
1081
+ // de los `denied_<fuente>` alcanza al sujeto (su cierre cubre up*(down*(R))); si no, `none`. Es `decideFrom` con el
1082
+ // corte ya evaluado en el servidor (los dos drivers y el doble responden la misma tabla: casos D9 de la COMÚN).
1083
+ const answers = plan.map(({ sources }, i) => {
1084
+ if (allowed.get(`a${i}`))
1085
+ return 'allow';
1086
+ return sources.some((_, k) => allowed.get(`d${i}-${k}`)) ? 'deny' : 'none';
1087
+ });
1088
+ return index.map((at) => answers[at]);
1089
+ }
1090
+ /**
1091
+ * **`denyPhase` — ¿admite este driver escrituras de deny en el tipo?** (lote D · D2, R2-5). La fase del opt-in EN
1092
+ * LA CONFIG DE ESTE DRIVER y, con opt-in, el modelo FIJADO comprobado con la MISMA puerta L11 que `deny` (500 si
1093
+ * no declara `denied_<rel>`). F-05 estricto del par, antes de nada (422, cero llamadas). La llama
1094
+ * `reconcileRelations` en la fase de plan cuando el origen trae denies: un destino `openfga` sin `'write'` en el
1095
+ * tipo, o con un pin a un modelo sin el deny, NIEGA la pasada antes de la primera escritura (jamás `skipped`).
1096
+ */
1097
+ async denyPhase(object, relation) {
1098
+ assertRelationDeclared(this.#config, object, relation);
1099
+ const phase = denyPhaseOf(this.#config, object.type);
1100
+ if (phase === undefined)
1101
+ return null;
1102
+ await this.#assertModel('denyPhase', [object.type]);
1103
+ return phase;
1104
+ }
1105
+ /** `listDenies` = `Read` por `(object, denied_<rel>)`: los denies DIRECTOS (hechos). Cualquier tipo declarado (D8). */
1106
+ async listDenies(relation, object, partition) {
1107
+ assertRelationDeclared(this.#config, object, relation);
1108
+ assertPartition(partition);
1109
+ // 7.4: sin página (≠ undefined ⇒ 500 con cero llamadas al cliente), después de F-05 y la partición.
1110
+ assertNoListPage('listDenies', arguments[3], 'openfga');
1111
+ const partitionKey = this.#keyOf(partition);
1112
+ const objectId = this.#objectId(object.type, partitionKey, object.id);
1113
+ const now = this.#now();
1114
+ await this.#assertModel('listDenies', [object.type]);
1115
+ const tuples = await this.#readAll('listDenies', { object: objectId, relation: deniedRelationOf(relation) });
1116
+ const subjects = [];
1117
+ for (const t of tuples) {
1118
+ // L11: una condición `foreign`/`unparseable` no se lista (se avisa y se cuenta): el paquete no sabe si está viva.
1119
+ if (t.invalid !== undefined) {
1120
+ this.#warnUnparseable('listDenies', `${t.object}#${t.relation}@${t.user} (${t.invalid})`);
1121
+ continue;
1122
+ }
1123
+ if (t.validUntil && t.validUntil.getTime() <= now.getTime())
1124
+ continue;
1125
+ const subject = this.#userToSubject(t.user);
1126
+ if (subject)
1127
+ subjects.push(subject);
1128
+ else
1129
+ this.#warnUnparseable('listDenies', t.user);
1130
+ }
1131
+ return { subjects };
1132
+ }
522
1133
  /**
523
1134
  * **El envoltorio ÚNICO de `ListObjects`** (plan 2.5 · lote B · B4; linaje L8,
524
1135
  * 5.ª iteración ⇒ M8: propiedad universal «toda enumeración que se puede
@@ -562,12 +1173,14 @@ export class OpenFgaRelationsDriver {
562
1173
  * se consume entre todas — otra razón para señalar el truncado y no callar
563
1174
  * (documentado en el README).
564
1175
  */
565
- async listObjects(subject, relation, objectType, partition, _page) {
1176
+ async listObjects(subject, relation, objectType, partition) {
566
1177
  assertRelationDeclared(this.#config, { type: objectType }, relation);
567
1178
  assertPartition(partition);
1179
+ assertNoListPage('listObjects', arguments[4], 'openfga');
568
1180
  const partitionKey = this.#keyOf(partition);
569
1181
  const user = this.#subjectUser(subject, partitionKey);
570
1182
  const context = this.#checkContext(this.#now());
1183
+ await this.#assertModel('listObjects', [objectType]);
571
1184
  const { raw, truncated } = await this.#listObjectsPage({ user, relation, type: objectType, context });
572
1185
  const objects = [];
573
1186
  for (const full of raw) {
@@ -589,18 +1202,26 @@ export class OpenFgaRelationsDriver {
589
1202
  * derivado por includes. `usersetSubjects: true` ⇒ los usersets
590
1203
  * (`group#member`) salen junto con los holders.
591
1204
  */
592
- async listSubjects(relation, object, partition, _page) {
1205
+ async listSubjects(relation, object, partition) {
593
1206
  assertRelationDeclared(this.#config, object, relation);
594
1207
  assertPartition(partition);
1208
+ assertNoListPage('listSubjects', arguments[3], 'openfga');
595
1209
  const partitionKey = this.#keyOf(partition);
596
1210
  const objectId = this.#objectId(object.type, partitionKey, object.id);
597
1211
  // `Read` devuelve tuplas ESCRITAS, no evaluadas: la caducidad se filtra en
598
1212
  // cliente con el MISMO reloj del driver (R-15, estricta: la que vence en
599
1213
  // `now` ya no cuenta), como las enumeraciones del driver de roles.
600
1214
  const now = this.#now();
1215
+ await this.#assertModel('listSubjects', [object.type]);
601
1216
  const tuples = await this.#readAll('listSubjects', { object: objectId, relation });
602
1217
  const subjects = [];
603
1218
  for (const t of tuples) {
1219
+ // L11: una condición `foreign`/`unparseable` NO es un hecho vigente que listar (sería «permanente» por defecto);
1220
+ // se avisa y se cuenta en `diagnostics.unparseableTuples`.
1221
+ if (t.invalid !== undefined) {
1222
+ this.#warnUnparseable('listSubjects', `${t.object}#${t.relation}@${t.user} (${t.invalid})`);
1223
+ continue;
1224
+ }
604
1225
  if (t.validUntil && t.validUntil.getTime() <= now.getTime())
605
1226
  continue;
606
1227
  const subject = this.#userToSubject(t.user);
@@ -640,13 +1261,26 @@ export class OpenFgaRelationsDriver {
640
1261
  * la CADUCADA también llega, con su `expiresAt` (R-15), para que el destino
641
1262
  * la cuente en `skipped`.
642
1263
  *
1264
+ * **Una tupla con condición AJENA llega marcada** (`invalidCondition`, L11) y
1265
+ * con `expiresAt` = la ÉPOCA. Eso es conservador SOLO para los allows: un
1266
+ * lector que ignore la marca lee el deny (o la membresía de `group` que lo
1267
+ * transporta) como VENCIDO y deja de negar —fail-OPEN— (cierre-2 · ⚪ C3).
1268
+ * Ninguna fecha sirve para los dos tipos de tupla: el lector tiene que mirar
1269
+ * `invalidCondition` ANTES que `expiresAt` (el plan del paquete lo hace).
1270
+ *
643
1271
  * **Coste: O(tuplas del store) por PASADA** (2.4.0-alpha.6): es un barrido
644
1272
  * entero por llamada, sea cual sea el tamaño de la partición (medido: 5 000
645
1273
  * tuplas ⇒ 50 páginas ≈ 170 ms; 15 000 ⇒ 151 páginas ≈ 0,5 s). Para K
646
1274
  * particiones usa `enumerateRelationsMany`: UN barrido en vez de K.
647
1275
  */
648
- async enumerateRelations(partition, _page) {
1276
+ async enumerateRelations(partition, page) {
649
1277
  assertPartition(partition);
1278
+ // 7.4 (injerto 4): limit basura ⇒ 422; after ⇒ 500 (este driver no emite cursor: devuelve TODO en una página).
1279
+ // Un limit válido se IGNORA (una página, O(store)): declarado en «Limits».
1280
+ assertEnumeratePage('enumerateRelations', page, 'none', 'openfga');
1281
+ // L11: el ORIGEN de una migración pasa SOLO la parte `conditions` de la puerta (tiene que poder rescatar un store
1282
+ // con deriva de reglas; la vigencia de lo que emite sí depende de la condición del modelo).
1283
+ await this.#assertModel('enumerateRelations', [], 'conditions');
650
1284
  return this.#sweep([partition], 'enumerateRelations');
651
1285
  }
652
1286
  /**
@@ -675,13 +1309,15 @@ export class OpenFgaRelationsDriver {
675
1309
  * se cuenta en `diagnostics.unparseableTuples` y se avisa. Sin particiones
676
1310
  * es 500 `E_AUTHZ_CONFIG` con cero lecturas: no hay «éxito vacío».
677
1311
  */
678
- async enumerateRelationsMany(partitions, _page) {
1312
+ async enumerateRelationsMany(partitions, page) {
679
1313
  if (!Array.isArray(partitions) || partitions.length === 0) {
680
1314
  throw new AuthorizationConfigError('enumerateRelationsMany: sin particiones no hay nada que enumerar (y un barrido del store para nadie no es un éxito vacío). ' +
681
1315
  'Pasa al menos una partición.');
682
1316
  }
683
1317
  for (const partition of partitions)
684
1318
  assertPartition(partition);
1319
+ assertEnumeratePage('enumerateRelationsMany', page, 'none', 'openfga');
1320
+ await this.#assertModel('enumerateRelationsMany', [], 'conditions');
685
1321
  return this.#sweep(partitions, 'enumerateRelationsMany');
686
1322
  }
687
1323
  /** El barrido: UN `Read({})` paginado del store entero, repartido por la clave ENTERA de partición. */
@@ -713,10 +1349,17 @@ export class OpenFgaRelationsDriver {
713
1349
  }
714
1350
  tuples.push({
715
1351
  subject,
716
- relation: key.relation,
1352
+ // Lote D · D2: `denied_<rel>` ⇒ `{ relation: <rel>, kind: 'deny' }` (`storedRelationKind`, punto de paso único).
1353
+ ...storedRelationKind(key.relation),
717
1354
  object: { type: parsed.type, id: parsed.objectUuid },
718
1355
  partition,
719
- expiresAt: key.validUntil,
1356
+ // Cierre de L11 (⚪ A3.3 del auditor): con una condición que el paquete no escribe (o no sabe leer) `expiresAt` NO
1357
+ // es `null` sino la ÉPOCA. Cierre-2 (⚪ C3): eso es fail-CLOSED para un allow que lea un tercero sin mirar
1358
+ // `invalidCondition` (lo lee VENCIDO), pero fail-OPEN para un deny o una membresía que transporta uno (deja de negar);
1359
+ // con `null` era al revés. Ninguna fecha vale para los dos: la marca es lo que manda, y VIAJA al plan, que la
1360
+ // clasifica antes de mirar la fecha (`invalid-condition`, L9): nunca se migra ni se poda como sobrante.
1361
+ expiresAt: key.invalid !== undefined ? new Date(0) : key.validUntil,
1362
+ ...(key.invalid !== undefined ? { invalidCondition: key.invalid } : {}),
720
1363
  });
721
1364
  }
722
1365
  return { tuples };
@@ -736,9 +1379,10 @@ export class OpenFgaRelationsDriver {
736
1379
  assertObjectTypeDeclared(this.#config, object);
737
1380
  assertPartition(partition);
738
1381
  const objectId = this.#objectId(object.type, this.#keyOf(partition), object.id);
1382
+ await this.#assertModel('purgeObject', [object.type]);
739
1383
  const progress = { deleted: 0 };
740
1384
  try {
741
- await this.#purgeByFilter('purgeObject', { object: objectId }, `objeto ${objectId}`, progress);
1385
+ await this.#purgeByFilters('purgeObject', [[{ object: objectId }, `objeto ${objectId}`]], progress);
742
1386
  }
743
1387
  catch (error) {
744
1388
  throw this.#partialIfDeleted(error, progress);
@@ -761,11 +1405,11 @@ export class OpenFgaRelationsDriver {
761
1405
  assertSubject(subject);
762
1406
  const user = this.#subjectUser(subject, this.#keyOf(partition));
763
1407
  const types = [FACTS_GROUP_TYPE, ...this.#config.objectTypes.map((t) => t.type)];
1408
+ // Toca TODOS los tipos declarados: la puerta los compara todos.
1409
+ await this.#assertModel('purgeSubject', types);
764
1410
  const progress = { deleted: 0 };
765
1411
  try {
766
- for (const type of types) {
767
- await this.#purgeByFilter('purgeSubject', { user, object: `${type}:` }, `sujeto ${user} en '${type}'`, progress);
768
- }
1412
+ await this.#purgeByFilters('purgeSubject', types.map((type) => [{ user, object: `${type}:` }, `sujeto ${user} en '${type}'`]), progress);
769
1413
  }
770
1414
  catch (error) {
771
1415
  throw this.#partialIfDeleted(error, progress);
@@ -775,7 +1419,7 @@ export class OpenFgaRelationsDriver {
775
1419
  * **Una purga que muere a MEDIAS es indeterminada** (invariante 13,
776
1420
  * 2.4.0-alpha.3, cierre del 🟡 6 del auditor). La purga NO es un `Write`:
777
1421
  * son N `Read`+`deleteTuples` (ortografías × tipos, y lotes de
778
- * `WRITE_BATCH_SIZE`), y un 503 que no es timeout —o el 500
1422
+ * `RELATIONS_PURGE_WRITE_MAX`), y un 503 que no es timeout —o el 500
779
1423
  * `E_AUTHZ_PURGE_INCOMPLETE` de la demostración de cero— puede llegar
780
1424
  * después de haber borrado parte. Si YA se borró al menos una tupla, el
781
1425
  * error se marca (`markPartialWrite`, `src/errors.ts`: una propiedad, la
@@ -790,22 +1434,61 @@ export class OpenFgaRelationsDriver {
790
1434
  return markPartialWrite(error);
791
1435
  return error;
792
1436
  }
793
- /** Lee por filtro, borra en lotes, y RE-LEE para demostrar cero (o lanza). `progress.deleted` cuenta lo que YA se borró (un lote que falla no borró nada: el `Write` de FGA es transaccional). */
794
- async #purgeByFilter(operation, filter, what, progress) {
795
- const keys = await this.#readAll(operation, filter);
796
- for (let i = 0; i < keys.length; i += WRITE_BATCH_SIZE) {
797
- const batch = keys.slice(i, i + WRITE_BATCH_SIZE);
798
- await this.#guard(operation, () => this.client.deleteTuples(batch.map((k) => ({ user: k.user, relation: k.relation, object: k.object })), IGNORE_MISSING_DELETES));
1437
+ /**
1438
+ * **Lee TODOS los filtros, borra en un `Write` si cabe —y si no, en ORDEN GLOBAL: allows → membresías → denies— y
1439
+ * RE-LEE cada filtro para demostrar cero (o lanza)** (lote D · D-16/D-26/S3; cierre 1 de D1, 🟠 1 del auditor; el
1440
+ * orden S6 de roles, invariante 6). La propiedad: **una purga cortada a medias no concede nada que no concedieran el
1441
+ * estado inicial o el final** —con la única excepción DECLARADA de abajo—.
1442
+ *
1443
+ * - **Cabe en `RELATIONS_PURGE_WRITE_MAX` (100) ⇒ UN solo `Write`**: FGA lo aplica de forma transaccional (medido:
1444
+ * 101 es 400 y no se aplica nada), así que NO hay PREFIJO que cortar (atómico para el ESCRITOR: un `Check`
1445
+ * concurrente puede observar el `Write` a medias —lector rasgado, fila «torn read» de Limits, decisión (6a)—).
1446
+ * - **No cabe ⇒ tres clases, cada una en sus propios `Write`, en el orden de BORRADO del punto de paso ÚNICO
1447
+ * `relationDeleteOrder`** (`src/relations/tuple_order.ts`; cierre 1 de D2 · 🟠 1: el mismo que el reconcile): (1) los allows sobre objetos que no son
1448
+ * `group` —quitar un allow solo resta (D-12, monotonía)—; (2) las MEMBRESÍAS `group:*#member@<sujeto>` —una membresía
1449
+ * TRANSPORTA allows y denies a la vez: borrarla antes que un allow directo deja ese allow sin el deny que lo cortaba
1450
+ * (🟠 1: con el orden «solo prefijo» de alpha.3-dev, `group:g#member@U` caía en el lote 1 y `doc:V#viewer@U` en el 2;
1451
+ * cortada entre los dos, U CONCEDÍA sobre V; la gemela `purgeSubject(g#member)` con la arista anidada
1452
+ * `group:h#member@group:g#member`)—, en UN `Write` PROPIO si caben (ordenadas por objeto, las portadoras de deny al
1453
+ * final: el reparto no depende del `Read`); (3) los `denied_*`, al final —quedan denies de MÁS, nunca de menos—.
1454
+ * - **Residuo DECLARADO**: con MÁS de 100 membresías del sujeto no caben en un `Write`, y una que transporta un deny
1455
+ * puede caer en un lote anterior a otra que transporta un allow (fila con caso en `relations_openfga.spec`, frase en
1456
+ * el README). No se niega la purga antes de borrar (invariante 11: una purga no espera; un 500 dejaría al sujeto de
1457
+ * baja con TODO su acceso): sale marcada como parcial (`indeterminate: true`) y se repite.
1458
+ *
1459
+ * `purgeObject` pasa por aquí igual: sobre un objeto que no es `group` no hay membresías (allows → denies); sobre
1460
+ * `group:g` todo son membresías de g, y cada miembro queda dentro o fuera de g —su estado inicial o el final—.
1461
+ * `progress.deleted` cuenta lo que YA se borró (un lote que falla no borró nada) para la marca de escritura parcial
1462
+ * (invariante 13).
1463
+ */
1464
+ async #purgeByFilters(operation, filters, progress) {
1465
+ const read = [];
1466
+ for (const [filter, what] of filters)
1467
+ read.push({ filter, what, keys: await this.#readAll(operation, filter) });
1468
+ for (const batch of purgeWrites(read.flatMap((r) => r.keys))) {
1469
+ try {
1470
+ await this.#guard(operation, () => this.client.deleteTuples(batch.map((k) => ({ user: k.user, relation: k.relation, object: k.object })), IGNORE_MISSING_DELETES), true);
1471
+ }
1472
+ catch (error) {
1473
+ // Cierre 2 de D1 · ⚪ N3: el 400 del tope de operaciones por `Write` del SERVIDOR no es una caída (503
1474
+ // reintentable): es un despliegue con `--max-tuples-per-write` < `RELATIONS_PURGE_WRITE_MAX`. 500 con la letra;
1475
+ // la marca de escritura parcial la pone `#partialIfDeleted` si ya se aplicó un `Write` anterior.
1476
+ if (isServerWriteLimit(error))
1477
+ throw purgeWriteLimitError(operation, batch.length, progress.deleted, error);
1478
+ throw error;
1479
+ }
799
1480
  progress.deleted += batch.length;
800
1481
  }
801
- if (keys.length === 0)
802
- return;
803
- // Demostración de cero: re-lee tras borrar (invariante 11).
804
- const residue = await this.#readAll(operation, filter);
805
- if (residue.length > 0) {
806
- throw new PurgeIncompleteError(`${operation}: quedan ${residue.length} tuplas de ${what} tras el borrado ` +
807
- `(${residue.slice(0, 3).map((k) => `${k.user}#${k.relation}@${k.object}`).join('; ')}). ` +
808
- `No se confirma la purga.`);
1482
+ // Demostración de cero: re-lee tras borrar (invariante 11), solo los filtros que tenían algo.
1483
+ for (const { filter, what, keys } of read) {
1484
+ if (keys.length === 0)
1485
+ continue;
1486
+ const residue = await this.#readAll(operation, filter);
1487
+ if (residue.length > 0) {
1488
+ throw new PurgeIncompleteError(`${operation}: quedan ${residue.length} tuplas de ${what} tras el borrado ` +
1489
+ `(${residue.slice(0, 3).map((k) => `${k.user}#${k.relation}@${k.object}`).join('; ')}). ` +
1490
+ `No se confirma la purga.`);
1491
+ }
809
1492
  }
810
1493
  }
811
1494
  /* ── Helpers ────────────────────────────────────────────────────────────── */
@@ -822,12 +1505,15 @@ export class OpenFgaRelationsDriver {
822
1505
  * sondea las cinco operaciones que entran por aquí con un mutante que
823
1506
  * «termina» en la página vacía.
824
1507
  */
825
- async #readAll(operation, filter) {
1508
+ async #readAll(operation, filter,
1509
+ /** R1: el presupuesto del gate de anidación (antes de cada página y con las tuplas de cada una). */
1510
+ pageHooks) {
826
1511
  const keys = [];
827
1512
  let continuationToken;
828
1513
  const seenTokens = new Set();
829
1514
  let pages = 0;
830
1515
  do {
1516
+ pageHooks?.before();
831
1517
  const response = await this.#guard(operation, () => this.client.read(filter, {
832
1518
  pageSize: READ_PAGE_SIZE,
833
1519
  continuationToken,
@@ -840,6 +1526,7 @@ export class OpenFgaRelationsDriver {
840
1526
  if (!Array.isArray(response?.tuples)) {
841
1527
  throw new AuthorizationBackendError('openfga-relations', operation, new Error(`la respuesta del Read (página ${pages}) no trae 'tuples'; no se toma por vacía`));
842
1528
  }
1529
+ pageHooks?.after(response.tuples.length);
843
1530
  for (const tuple of response.tuples) {
844
1531
  const key = tuple?.key;
845
1532
  if (!key?.user || !key?.relation || !key?.object) {
@@ -848,12 +1535,7 @@ export class OpenFgaRelationsDriver {
848
1535
  }
849
1536
  // Con su caducidad (R-15): quien la lee decide si filtra (`listSubjects`),
850
1537
  // la emite (`enumerateRelations`) o la ignora (`purge*` borra TODO).
851
- keys.push({
852
- user: key.user,
853
- relation: key.relation,
854
- object: key.object,
855
- validUntil: toExpiryDate(key.condition?.context?.valid_until),
856
- });
1538
+ keys.push({ user: key.user, relation: key.relation, object: key.object, ...storedCondition(key.condition) });
857
1539
  }
858
1540
  continuationToken = response.continuation_token || undefined;
859
1541
  if (continuationToken) {
@@ -882,4 +1564,86 @@ export class OpenFgaRelationsDriver {
882
1564
  // de modo que un 400 de validación de FGA o el cuerpo de un 502 que mencionara
883
1565
  // la palabra salía como 503 `E_AUTHZ_BACKEND_TIMEOUT` con `indeterminate: true`
884
1566
  // sin haber borrado nada (medido por el auditor: `alpha3-cierre-parcial.ts` B).
885
- //# sourceMappingURL=openfga_relations_driver.js.map
1567
+ /** La LETRA del 500 de N3 (fijada por `relations_openfga.spec`, «⚪ N3 · …»). */
1568
+ function purgeWriteLimitError(operation, size, deleted, cause) {
1569
+ const sdk = cause?.cause?.apiErrorMessage;
1570
+ // R3: es un fallo de DESPLIEGUE (transitorio para el relay), como el que ya clasificó `#guard`.
1571
+ return markDeploymentFailure(new AuthorizationConfigError(`OpenFgaRelationsDriver: ${operation}: el servidor OpenFGA rechazó un Write de ${size} tuplas por su tope de ` +
1572
+ `operaciones por Write (exceeded_entity_limit${typeof sdk === 'string' ? `: «${sdk}»` : ''}). El driver parte las ` +
1573
+ `purgas en RELATIONS_PURGE_WRITE_MAX = ${RELATIONS_PURGE_WRITE_MAX} y exige --max-tuples-per-write ≥ ` +
1574
+ `${RELATIONS_PURGE_WRITE_MAX} en el servidor (OPENFGA_MAX_TUPLES_PER_WRITE; el default es ${RELATIONS_PURGE_WRITE_MAX}). ` +
1575
+ `No es transitorio: repetida, la purga vuelve a fallar igual. ` +
1576
+ (deleted > 0
1577
+ ? `Ya se habían borrado ${deleted} tuplas en Write anteriores: la purga quedó A MEDIAS (escritura parcial).`
1578
+ : 'No se borró nada.'), { cause }));
1579
+ }
1580
+ /**
1581
+ * **Los `Write` de una purga, en orden** (lote D · cierre 1 de D1, 🟠 1; ver `#purgeByFilters`): uno solo si todo cabe
1582
+ * en `RELATIONS_PURGE_WRITE_MAX`; si no, en el orden de BORRADO del punto de paso ÚNICO `relationDeleteOrder`
1583
+ * (`src/relations/tuple_order.ts`, cierre 1 de D2 · 🟠 1: el MISMO que usa el reconcile) — allows sobre objetos que no
1584
+ * son `group`, luego las membresías `group:*#member` (las portadoras de deny al final, ordenadas por objeto y sujeto) y
1585
+ * al final los `denied_*` —, con CADA CLASE en sus PROPIOS `Write` (con ≤ 100 membresías, uno). Pura: la tabla de verdad
1586
+ * del caso la juzga contra el servidor, y la PROPIEDAD la juzga sin servidor la guardia de superficie
1587
+ * (`GS · P · openfga.purge.order`, cierre 2 de D1 · ⚪ N2), con los mutantes de orden en el corredor publicado
1588
+ * (`npm run check:guard-mutants`). Exportada solo para eso: no sale por `/openfga`.
1589
+ */
1590
+ export function purgeWrites(keys) {
1591
+ if (keys.length === 0)
1592
+ return [];
1593
+ if (keys.length <= RELATIONS_PURGE_WRITE_MAX)
1594
+ return [[...keys]];
1595
+ const view = (k) => ({ deny: k.relation.startsWith(RELATION_DENY_PREFIX), object: k.object, user: k.user });
1596
+ const ordered = relationDeleteOrder(keys, view);
1597
+ const chunks = (list) => {
1598
+ const out = [];
1599
+ for (let i = 0; i < list.length; i += RELATIONS_PURGE_WRITE_MAX)
1600
+ out.push(list.slice(i, i + RELATIONS_PURGE_WRITE_MAX));
1601
+ return out;
1602
+ };
1603
+ // Cada CLASE en sus propios `Write`: una membresía nunca comparte lote con el último allow ni con el primer deny.
1604
+ const runs = [];
1605
+ let previous;
1606
+ for (const k of ordered) {
1607
+ const cls = relationOrderClass(view(k));
1608
+ if (cls !== previous)
1609
+ runs.push([]);
1610
+ runs[runs.length - 1].push(k);
1611
+ previous = cls;
1612
+ }
1613
+ return runs.flatMap(chunks);
1614
+ }
1615
+ /**
1616
+ * **El error de una renovación en dos `Write` NOMBRA el deny que entró en la ventana** (cierre 3 de D2 · 🟡 C2-3). Sobre
1617
+ * el error que se propaga (su clase y su `code` no cambian): `carriedDeny` = el deny que la re-comprobación encontró, y la
1618
+ * letra con la receta; si la re-comprobación también falló, lo dice (`carriedDenyUnknown: true`). `applied` = la
1619
+ * renovación SÍ se aplicó y lo que falló es solo la re-comprobación. Un error congelado se propaga tal cual.
1620
+ */
1621
+ function annotateRacedDeny(error, membership, recheck, applied = false) {
1622
+ let note = null;
1623
+ const target = error;
1624
+ try {
1625
+ if (recheck.deny !== null) {
1626
+ target.carriedDeny = recheck.deny;
1627
+ note =
1628
+ ` — Cierre 3 de D2 (C2-3): la membresía ${membership} quedó BORRADA y un deny concurrente (${recheck.deny}) que ` +
1629
+ 'entró en el grupo durante la renovación ya NO alcanza al sujeto por ella, y no lo alcanzará hasta que la ' +
1630
+ 'membresía se reescriba. Receta: vuelve a escribirla (relate: ya no existe, así que es UN Write) o retira y ' +
1631
+ 'reescribe ese deny después.';
1632
+ }
1633
+ else if (recheck.error !== undefined) {
1634
+ target.carriedDenyUnknown = true;
1635
+ note = applied
1636
+ ? ` — Cierre 3 de D2 (C2-3): la renovación de ${membership} SE APLICÓ, pero la re-comprobación de un deny ` +
1637
+ 'concurrente en su grupo falló: no se sabe si un deny entró en la ventana entre los dos Write. Receta: lista los ' +
1638
+ 'denies del grupo (listDenies) y revisa las decisiones de ese sujeto en esa ventana.'
1639
+ : ` — Cierre 3 de D2 (C2-3): la membresía ${membership} quedó BORRADA y la re-comprobación de un deny concurrente ` +
1640
+ 'en su grupo también falló. Receta: vuelve a escribir la membresía (relate) y lista los denies del grupo.';
1641
+ }
1642
+ if (note !== null && typeof target.message === 'string')
1643
+ target.message += note;
1644
+ }
1645
+ catch {
1646
+ // Un error congelado no admite la nota: se propaga el original (la clase y el `code` no cambian).
1647
+ }
1648
+ return error;
1649
+ }