@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
@@ -1,14 +1,14 @@
1
1
  import db from '@adonisjs/lucid/services/db';
2
2
  import { OpenFgaClient, ClientWriteRequestOnDuplicateWrites, ClientWriteRequestOnMissingDeletes, ConsistencyPreference, } from '@openfga/sdk';
3
- import { AuthorizationBackendError, AuthorizationBackendTimeoutError, AuthorizationConfigError, AuthorizationInternalError, InvalidIdentityError, MassReconcileRefusedError, ReconcileTooLargeError, PurgeIncompleteError, ScopeCycleError, ScopeDriftUnguardedError, ScopeTreeDriftError, UnknownPermissionError, UnknownRoleError, UnsupportedOperationError, WriteConflictError, } from '../errors.js';
3
+ import { AuthorizationBackendError, AuthorizationBackendTimeoutError, AuthorizationConfigError, AuthorizationInternalError, InvalidIdentityError, ResolutionLimitError, MassReconcileRefusedError, DenyMigrationRefusedError, ReconcileTooLargeError, PurgeIncompleteError, ScopeCycleError, ScopeDriftUnguardedError, ScopeTreeDriftError, UnknownPermissionError, UnknownRoleError, UnsupportedOperationError, RenewalUnsafeError, WriteConflictError, markDeploymentFailure, markPartialWrite, } from '../errors.js';
4
4
  import { APP_SCOPE_TYPE } from '../types.js';
5
5
  import { AUTHZ_TABLES_ORIGIN, RECONCILE_MAX_DETAILS, emptyReconcilePhases as emptyPhases, reconcileBatchSize, reconcileMaxTuples, sumReconcilePhases as sumPhases, } from '../reconcile.js';
6
6
  import { assertRoleAssignableAt, declaredRoleAt, fromDbScopeUuid, hasRoleTargets, resolveRoleQuery, rolesToRevoke, visibleRoleFor, } from './database_driver.js';
7
7
  import { assertCatalogUuid, assertIdentity, assertScope, chainKeysFrom, isCatalogUuid, isValidScope, normalizeRoleQuery, scopeFromKey, scopeKey, } from '../identity.js';
8
- import { resolveGrantExpiry, sameInstant, toExpiryDate } from '../expiry.js';
9
- import { assertKnownScope, canonicalScope, canonicalScopeTargets, guardSql, isAuthzError, isTimeoutLike, resolveChain, rootOnlyResolver, withDeadline, } from '../shared/backend_guard.js';
8
+ import { fgaTupleExpiry, resolveGrantExpiry, sameInstant } from '../expiry.js';
9
+ import { assertKnownScope, canonicalScope, canonicalScopeTargets, classifyFgaFailure, fgaFailureError, guardSql, isAuthzError, isServerWriteLimit, isTimeoutLike, resolveChain, rootOnlyResolver, withDeadline, } from '../shared/backend_guard.js';
10
10
  import { CatalogCache, GLOBAL_OWNER_KEY, assertCatalogOptions, isRoleVisibleWith, withAuthzCatalogWrite } from '../catalog/catalog_cache.js';
11
- import { FACTS_ASSIGNEE_RELATION, FACTS_BINDING_RELATION, FACTS_BINDING_TYPE, FACTS_DENIED_PREFIX, FACTS_GROUP_TYPE, FACTS_MAX_RESOLVE_DEPTH, FACTS_PARENT_RELATION, FACTS_PERMITS_PREFIX, FACTS_ROLE_RELATION, FACTS_ROLE_TYPE, FACTS_ROOTED_RELATION, FACTS_SCOPE_TYPE, assertFactsModelPublishable, assertHolderTypes, factsBindingObject, factsBindingTuples, factsCatalogTuples, factsDenyTuple, factsParentTuple, factsRelationsOf, factsRootTuples, factsScopeBindingTuple, factsScopeObject, factsTupleId, openFgaFactsModel, } from './openfga_facts.js';
11
+ import { FACTS_ASSIGNEE_RELATION, FACTS_BINDING_RELATION, FACTS_BINDING_TYPE, FACTS_DENIED_PREFIX, FACTS_GROUP_TYPE, FACTS_MAX_RESOLVE_DEPTH, FACTS_PARENT_RELATION, FACTS_PERMITS_PREFIX, FACTS_ROLE_RELATION, FACTS_ROLE_TYPE, FACTS_ROOTED_RELATION, FACTS_SCOPE_TYPE, assertFactsModelPublishable, assertHolderTypes, assertPinnedModel, assertExperimentalOptIn, factsModelDifferences, factsModelDriftError, factsBindingObject, factsBindingTuples, factsCatalogTuples, factsDenyTuple, factsParentTuple, factsRelationsOf, factsRootTuples, factsScopeBindingTuple, factsScopeObject, factsTupleId, openFgaFactsModel, } from './openfga_facts.js';
12
12
  import { readCatalogProjectionSnapshot } from '../catalog/catalog.js';
13
13
  import { isSqliteDialect, sqlExpiryCodec } from '../shared/sql_expiry.js';
14
14
  import { isClock, systemClock } from '../clock.js';
@@ -131,7 +131,11 @@ export function alignBatchResults(driver, operation, checks, results) {
131
131
  const aligned = correlateBatchResults(checks, results);
132
132
  const failed = aligned.find((r) => r.error);
133
133
  if (failed) {
134
- throw new AuthorizationBackendError(driver, `${operation} (${failed.request?.relation} ${failed.request?.object})`, failed.error);
134
+ // R3 (injerto 10 del juez = H9 del auditor): el error POR ÍTEM pasa por el MISMO clasificador que la excepción —la
135
+ // clave del objeto `error` (`input_error`/`internal_error`) hace de `apiErrorCode`—, o `checkMany`/`decideMany`/
136
+ // `authorizeMany` divergirían de `check`. El primero en el orden de la PETICIÓN (alineado por `correlationId`: el
137
+ // servidor no devuelve en orden, F0 · M-d nota 2).
138
+ throw fgaFailureError(driver, `${operation} (${failed.request?.relation} ${failed.request?.object})`, failed.error);
135
139
  }
136
140
  return aligned;
137
141
  }
@@ -170,9 +174,11 @@ export async function provisionOpenFgaStore(apiUrl, name, holderTypeMap, permiss
170
174
  // en vez de 500 `E_AUTHZ_MODEL_TOO_LARGE`. Cero requests si no es publicable.
171
175
  assertFactsModelPublishable(holderTypeMap, permissions, undefined, relations);
172
176
  const model = openFgaFactsModel(holderTypeMap, permissions, relations);
173
- const client = new OpenFgaClient({ apiUrl });
177
+ // Injerto 15 del juez (H12 del auditor): sin reintentos a escondidas también aquí. Desde el SDK 0.9.6 un cliente sin
178
+ // `retryParams` reintenta 3 veces los 429/5xx, y un `createStore` reintentado tras un 5xx puede dejar un store huérfano.
179
+ const client = new OpenFgaClient({ apiUrl, retryParams: { maxRetry: 0 } });
174
180
  const store = await client.createStore({ name });
175
- const scoped = new OpenFgaClient({ apiUrl, storeId: store.id });
181
+ const scoped = new OpenFgaClient({ apiUrl, storeId: store.id, retryParams: { maxRetry: 0 } });
176
182
  const written = await scoped.writeAuthorizationModel(model);
177
183
  return { storeId: store.id, modelId: written.authorization_model_id };
178
184
  }
@@ -239,6 +245,22 @@ function withPreserveRecipe(error) {
239
245
  `escribe a ciegas: si la intención es "permanente", pasa { expiresAt: null }; si es temporal, una Date.`;
240
246
  return error;
241
247
  }
248
+ /**
249
+ * **El gemelo de roles de N3** (lote D · D2; cierre 2 de D1 · ⚪ N3 lo cerró en relaciones): un servidor arrancado con
250
+ * `--max-tuples-per-write` < 100 rechaza con un 400 `exceeded_entity_limit` los `Write` que este driver parte en
251
+ * `PURGE_BATCH_SIZE` (100) — `projectCatalog` (la proyección del catálogo), `reconcile` (`applyReconcileWrites`) y
252
+ * `purgeScope`. Hasta D2 salía como 503 `E_AUTHZ_BACKEND_UNAVAILABLE`, que parece transitorio: repetido, vuelve a
253
+ * fallar igual para siempre. Es configuración del DESPLIEGUE ⇒ 500 `E_AUTHZ_CONFIG` con la letra del requisito, en el
254
+ * PUNTO DE PASO ÚNICO de todas las llamadas del cliente (`guardBackendErrors`).
255
+ */
256
+ function serverWriteLimitError(operation, cause) {
257
+ const sdk = cause?.apiErrorMessage;
258
+ return new AuthorizationConfigError(`OpenFgaAuthorizationDriver: ${operation}: el servidor OpenFGA rechazó un Write por su tope de operaciones por Write ` +
259
+ `(exceeded_entity_limit${typeof sdk === 'string' ? `: «${sdk}»` : ''}). Este driver parte sus escrituras (proyección ` +
260
+ `del catálogo, reconcile, purgas) en lotes de ${PURGE_BATCH_SIZE} y exige --max-tuples-per-write ≥ ${PURGE_BATCH_SIZE} en ` +
261
+ `el servidor (OPENFGA_MAX_TUPLES_PER_WRITE; el default es ${PURGE_BATCH_SIZE}). No es transitorio: repetida, la operación ` +
262
+ `vuelve a fallar igual. Si ya se habían aplicado Write anteriores de la misma operación, quedó A MEDIAS.`, { cause });
263
+ }
242
264
  /**
243
265
  * Devuelve el cliente con TODOS sus métodos envueltos: un fallo de red o un
244
266
  * 5xx sale como `AuthorizationBackendError` (503) y no como el `FgaError` del
@@ -254,6 +276,8 @@ function withPreserveRecipe(error) {
254
276
  * ahí el error del SDK es la información más útil y no rompe ninguna
255
277
  * abstracción.
256
278
  */
279
+ /** Los métodos del cliente del SDK que ESCRIBEN en el store (cierre de R3, 🟡 R3-A2: su 504 es indeterminado). */
280
+ const FGA_WRITE_METHODS = new Set(['write', 'writeTuples', 'deleteTuples']);
257
281
  function guardBackendErrors(client, timeoutMs) {
258
282
  return new Proxy(client, {
259
283
  get(target, prop, receiver) {
@@ -262,9 +286,15 @@ function guardBackendErrors(client, timeoutMs) {
262
286
  return value;
263
287
  return (...args) => {
264
288
  const operation = String(prop);
265
- const fail = (cause) => isTimeoutLike(cause)
266
- ? new AuthorizationBackendTimeoutError('openfga', operation, timeoutMs, cause)
267
- : new AuthorizationBackendError('openfga', operation, cause);
289
+ // R3 (decisión del dueño 2026-10-17 (3)): la clase la decide `classifyFgaFailure` (por `statusCode` +
290
+ // `apiErrorCode`, jamás por el mensaje). El tope de `Write` del servidor es un despliegue con su letra propia.
291
+ const fail = (cause) => {
292
+ // Cierre de R3 (🟡 R3-A2): el 504 / `deadline_exceeded` de un método que ESCRIBE es indeterminado (timeout).
293
+ const failure = classifyFgaFailure(cause, { write: FGA_WRITE_METHODS.has(operation) });
294
+ if (failure.kind === 'deployment' && isServerWriteLimit(cause))
295
+ return markDeploymentFailure(serverWriteLimitError(operation, cause));
296
+ return fgaFailureError('openfga', operation, cause, { timeoutMs, failure });
297
+ };
268
298
  try {
269
299
  const result = value.apply(target, args);
270
300
  if (!(result instanceof Promise))
@@ -335,6 +365,55 @@ const TREE_WRITE_ATTEMPTS = 3;
335
365
  const PURGE_BATCH_SIZE = 100;
336
366
  /** Tamaño de página de `Read` (máximo del servidor). */
337
367
  const READ_PAGE_SIZE = 100;
368
+ /**
369
+ * **Vueltas del settle** (lote C · carreras, resolución 7 del juez; linaje L13): aplicaciones que
370
+ * `settleRoleVisibility` intenta antes de rendirse. Al agotarlas BORRA las aristas `scope#binding` de los pares (fail-CLOSED,
371
+ * intra-tenant: el churn exige `within` en los dos padres) y lanza 409 `E_AUTHZ_WRITE_CONFLICT` marcado PARCIAL
372
+ * (invariante 13: `indeterminate: true` en `onWrite`).
373
+ */
374
+ export const SETTLE_MAX_ROUNDS = 5;
375
+ /** Vueltas de «borrar y releer» de `purgeRole` antes de rendirse con 500 `E_AUTHZ_PURGE_INCOMPLETE` (lote C, hallazgo 2). */
376
+ const PURGE_ROLE_ROUNDS = 3;
377
+ /**
378
+ * **La consistencia de TODA lectura que alimenta una escritura** (lote C · carreras, resolución 8 del juez; hallazgo 3 del
379
+ * panel TREE): `HIGHER_CONSISTENCY` SIEMPRE, también con `consistency: 'minimize_latency'`. El argumento del settle (tipo
380
+ * Dekker: «escribo y después leo») cae con UNA sola lectura servida desde una caché o una réplica que no ve la escritura
381
+ * propia, y el `minimize_latency` del driver es un opt-in para DECIDIR, no para dejar estado escrito (medido en el doble: el
382
+ * atajo idempotente de `reparent` con una foto atrasada deja el árbol del store con OTRO padre sin concurrencia, y el barrido
383
+ * de `moved` lee su propia arista rancia). Lo usan `readForWrite`, `checkForWrite` y `readAssignment`; una guardia de fuente
384
+ * (`tests/visibility_settle.spec.ts`) vigila que los métodos que escriben no lean con `this.consistency`.
385
+ */
386
+ const WRITE_CONSISTENCY = ConsistencyPreference.HigherConsistency;
387
+ const settlePairId = (pair) => `${pair.scope}|${pair.role}`;
388
+ /** `scope#ancestor` = `parent or ancestor from parent` (NO reflexivo, K6): el `Check` del settle (resolución 9, M2: 26 saltos). */
389
+ const FACTS_ANCESTOR_RELATION = 'ancestor';
390
+ /**
391
+ * **El error ORIGINAL manda; el del settle va como causa** (resolución del juez, auditor 🟡 5 (iv)). El settle corre en un
392
+ * `finally` lógico de los tres escritores; si lanza, NO sustituye al error que ya llevaba la operación (un 500
393
+ * `E_AUTHZ_SCOPE_TREE_DRIFT` que aparca no puede salir como un 503 transitorio, ni perder su marca parcial). Se cuelga como
394
+ * `cause` si el original no tiene una; si ya la tiene, como `settleError` (no enumerable). Un error congelado se devuelve
395
+ * tal cual.
396
+ */
397
+ /** `markPartialWrite` sobre un `unknown` (solo marca objetos). */
398
+ function markPartialObject(error) {
399
+ return error !== null && typeof error === 'object' ? markPartialWrite(error) : error;
400
+ }
401
+ /** ¿Es el 409 de la COTA (o cualquier `E_AUTHZ_WRITE_CONFLICT`)? Ese ya decidió (borró y avisa): no se reintenta. */
402
+ function isWriteConflictLike(error) {
403
+ return error instanceof WriteConflictError || error?.code === WriteConflictError.code;
404
+ }
405
+ function withSettleCause(original, settleError) {
406
+ if (original === null || typeof original !== 'object')
407
+ return original;
408
+ try {
409
+ const property = original.cause === undefined ? 'cause' : 'settleError';
410
+ Object.defineProperty(original, property, { value: settleError, enumerable: false, configurable: true, writable: true });
411
+ }
412
+ catch {
413
+ // congelado: se propaga el original sin la causa (nunca un TypeError en su lugar)
414
+ }
415
+ return original;
416
+ }
338
417
  /**
339
418
  * El deny del modo `resolver` (≤ 2.2), que el modelo de hoy ni declara pero
340
419
  * un store de aquella versión sí tiene: `enumerateFacts` lo EMITE como deny
@@ -403,6 +482,9 @@ export class OpenFgaAuthorizationDriver {
403
482
  // `database` para lo que tenga que ir en su transacción, o
404
483
  // `requireTransactionalWrites` para fallar al arrancar).
405
484
  transactionalWrites: false,
485
+ // Estrategia 2.5.0 · firma 4 (regla D15: clave nueva OPCIONAL, normalizada a `false`): este driver es EXPERIMENTAL en
486
+ // 2.5.x — no se construye sin `experimental: 'openfga@2.5'` y sus límites declarados están en README «Limits».
487
+ experimental: true,
406
488
  });
407
489
  }
408
490
  client;
@@ -424,6 +506,12 @@ export class OpenFgaAuthorizationDriver {
424
506
  * con el que este driver escribe y responde, no el último del store.
425
507
  */
426
508
  modelId;
509
+ /**
510
+ * **La comparación modelo ↔ config de L11, UNA por proceso** (`modelGate`). Es un OBJETO que se MUTA y nunca se
511
+ * reasigna (H11 b): las vistas `withChainResolver`/`withClock` son `Object.create(this)` y lo heredan por prototipo,
512
+ * así que `forRequest()` no relee el modelo por petición.
513
+ */
514
+ modelMemo = {};
427
515
  consistency;
428
516
  /** Reloj de pared del driver (J1): el ÚNICO `now` de checks, filtros y re-grant. */
429
517
  now;
@@ -437,8 +525,13 @@ export class OpenFgaAuthorizationDriver {
437
525
  */
438
526
  catalog;
439
527
  constructor(options) {
528
+ // Estrategia 2.5.0 · firma 4: `openfga` es EXPERIMENTAL en 2.5.x. PRIMERA línea: sin el token, 500 sin crear el cliente.
529
+ assertExperimentalOptIn('OpenFgaAuthorizationDriver', options?.experimental);
440
530
  assertHolderTypes(options.holderTypes);
441
531
  assertCatalogOptions('OpenFgaAuthorizationDriver', options);
532
+ // L11 · `modelId` OBLIGATORIO, siempre (decisión del dueño 2026-10-17 (2)): sin pin, la puerta compararía el modelo
533
+ // del arranque y el servidor evaluaría con el último de AHORA.
534
+ assertPinnedModel('OpenFgaAuthorizationDriver', options.modelId);
442
535
  if (options.now !== undefined && !isClock(options.now)) {
443
536
  throw new AuthorizationConfigError(`OpenFgaAuthorizationDriver: 'now' debe ser una función () => Date (llegó ${typeof options.now})`);
444
537
  }
@@ -525,6 +618,53 @@ export class OpenFgaAuthorizationDriver {
525
618
  checkContext() {
526
619
  return checkContext(this.now());
527
620
  }
621
+ /**
622
+ * **La lectura del modelo, UNA por proceso** (L11): `readAuthorizationModel` del `modelId` FIJADO y
623
+ * `factsModelDifferences` con `slice: 'roles'` (los holders, `role`, `role_binding`, `scope` y las condiciones; la
624
+ * igualdad es «módulo el catálogo»: `P_M` sale del propio modelo). Se memoriza el ÉXITO —también con diferencias— y un
625
+ * generador que no puede generar (500); **un fallo al LEER el modelo NO se memoriza, sea 503 o 500** (cierre de L11).
626
+ */
627
+ modelGate() {
628
+ const memo = this.modelMemo;
629
+ if (memo.gate)
630
+ return memo.gate;
631
+ // Cierre de L11 (🟡-6 del tester): se memoriza lo que sale de una COMPARACIÓN HECHA —igual, distinta, o el 500 del
632
+ // generador, que es determinista con ese modelo—, NUNCA un fallo al LEER el modelo, sea de la clase que sea: hoy un
633
+ // 503 (o un 500 que el cliente ya clasifica, p. ej. el tope de Write) y, con R3, el 401/403 o el modelo inexistente
634
+ // que se clasificarán como despliegue (500): un token rotado tiene que poder recuperarse sin reiniciar el proceso.
635
+ let compared = false;
636
+ const pending = (async () => {
637
+ const response = await this.client.readAuthorizationModel();
638
+ const served = response?.authorization_model;
639
+ if (!Array.isArray(served?.type_definitions)) {
640
+ throw new AuthorizationBackendError('openfga', 'readAuthorizationModel', new Error('la respuesta de ReadAuthorizationModel no trae type_definitions; no se toma por un modelo igual'));
641
+ }
642
+ compared = true;
643
+ return factsModelDifferences(served, { holderTypes: this.holderTypes, slice: 'roles' });
644
+ })().catch((error) => {
645
+ if (!compared && memo.gate === pending)
646
+ memo.gate = undefined;
647
+ throw error;
648
+ });
649
+ memo.gate = pending;
650
+ pending.catch(() => { });
651
+ return pending;
652
+ }
653
+ /**
654
+ * **La puerta L11 del driver de ROLES** (lote «antes de RC», decisión del dueño 2026-10-17 (1); hasta L11 este driver
655
+ * no comparaba NINGUNA regla —P7: solo el conjunto de `permits_*`, «no es una barrera de seguridad»—: un `can_<P>` sin
656
+ * `but not denied_<P>`, un `rooted` cambiado o un `assignee` sin `not_expired` concedían o negaban sin error). Toda
657
+ * operación que llega al store la pasa ENTERA, justo después de validar su entrada y antes de la primera llamada
658
+ * (catálogo, resolutor y cliente incluidos); `enumerateFacts` —el ORIGEN de una migración— solo la parte
659
+ * `conditions`. Distinta ⇒ 500 `E_AUTHZ_CONFIG` con las rutas y CERO llamadas que evalúen o escriban.
660
+ */
661
+ async assertModel(operation, part = 'full') {
662
+ const comparison = await this.modelGate();
663
+ const hits = comparison.differences.filter((d) => (part === 'conditions' ? d.type === null && d.path.startsWith('conditions.') : true));
664
+ if (hits.length > 0)
665
+ throw factsModelDriftError('OpenFgaAuthorizationDriver', operation, this.modelId, hits);
666
+ return comparison;
667
+ }
528
668
  /**
529
669
  * Consulta al catálogo local clasificando su fallo. Con este driver el
530
670
  * catálogo SQL sigue siendo una dependencia dura de cada pregunta: su caída
@@ -555,6 +695,257 @@ export class OpenFgaAuthorizationDriver {
555
695
  // Antes la fase de roles y `hasRole` lo colapsaban en `false`.
556
696
  return alignBatchResults('openfga', 'batchCheck', withIds, response.result);
557
697
  }
698
+ /* ── L13 · el SETTLE de la visibilidad de los roles locales (lote C · carreras, opción C firmada 2026-10-20) ──────────
699
+ *
700
+ * En (c2) la visibilidad de un rol LOCAL es un dato DERIVADO materializado (la arista `scope#binding`, invariante 18)
701
+ * que se calcula leyendo OTRO dato mutable (las aristas `scope#parent`) y se escribe después: cualquier escritor que lea
702
+ * el árbol en t0 y escriba en t1 pierde frente a un cambio de árbol en (t0, t1) cuyo barrido ya pasó (TOCTOU entre
703
+ * escritores; R1/R2/R3/K2/K4 del panel, medidos en `loteC-asentar-fase1.md`). El remedio firmado es el SETTLE: tras toda
704
+ * escritura que puede dejar una arista equivocada, RELEER el store con HIGHER y corregir hasta un punto fijo. Por qué
705
+ * basta (tipo Dekker, sobre un store linealizable con HIGHER): cada escritor de visibilidad hace «escribo → leo el árbol»
706
+ * y cada escritor del árbol hace «escribo la arista → leo los bindings»; de dos escrituras concurrentes, al menos una de
707
+ * las dos lecturas posteriores ve la del otro, y quien la ve corrige. Lo que NO cierra (declarado, letra del paso 8): la
708
+ * ventana transitoria entre la escritura equivocada y su corrección, y una caída del proceso dentro de ella.
709
+ *
710
+ * **Punto de paso ÚNICO**: las ALTAS de `scope#binding` de un rol local solo salen de aquí (`writeBindingEdges`, que
711
+ * también usa la decisión previa del `grant`); los borrados pueden ir directos (borrar nunca concede). */
712
+ /**
713
+ * **El catálogo de una ESCRITURA** (K4, resolución 2 del juez): con la versión contrastada en fresco SIEMPRE
714
+ * (`CatalogCache.freshView`), ignore o no la ventana `{ everyMs }`. Un `catalog` inyectado que no lo traiga cae en
715
+ * `view()` (el contrato del memo es suyo).
716
+ */
717
+ writeCatalog() {
718
+ const catalog = this.catalog;
719
+ return typeof catalog.freshView === 'function' ? catalog.freshView() : catalog.view();
720
+ }
721
+ /**
722
+ * K4 (segunda mitad): un uuid de rol que el STORE tiene y el catálogo de la escritura no conoce fuerza UNA recarga del
723
+ * memo (`invalidate` + foto en fresco). Si tras ella sigue ausente, el rol se borró: quien llama BORRA su arista.
724
+ */
725
+ async writeCatalogKnowing(uuids) {
726
+ const view = await this.writeCatalog();
727
+ for (const uuid of uuids) {
728
+ if (view.roleByUuid(uuid))
729
+ continue;
730
+ this.catalog.invalidate();
731
+ return this.writeCatalog();
732
+ }
733
+ return view;
734
+ }
735
+ /**
736
+ * Un `batchCheck` con `HIGHER_CONSISTENCY` para el settle, SIN colapsar los errores por ítem: el llamante decide qué
737
+ * hacer con un `E_AUTHZ_RESOLUTION_LIMIT` de `ancestor` (caer a `storeChain`, condición 2 de la resolución 9) y propaga
738
+ * el resto (clasificados por el MISMO `fgaFailureError` que `alignBatchResults`).
739
+ */
740
+ async checkForWrite(checks) {
741
+ if (checks.length === 0)
742
+ return [];
743
+ const withIds = checks.map((check, index) => ({ ...check, correlationId: String(index) }));
744
+ const response = await this.client.batchCheck({ checks: withIds }, { consistency: WRITE_CONSISTENCY });
745
+ return correlateBatchResults(withIds, response.result).map((r) => r.error
746
+ ? { error: fgaFailureError('openfga', `settle (${r.request?.relation} ${r.request?.object})`, r.error) }
747
+ : { allowed: r.allowed === true });
748
+ }
749
+ /**
750
+ * **La decisión del settle**, con el STORE (todas las lecturas con HIGHER, en UN `batchCheck`): para cada par, si la
751
+ * arista TIENE que estar (`want`) y si ESTÁ (`has`). `want` exige, por este orden:
752
+ * - el rol en el catálogo de la escritura (K4: en fresco y, si falta, tras una recarga; si sigue faltando, `known: false`
753
+ * y la arista se borra — «rol ausente ⇒ borra»);
754
+ * - declarado para el NIVEL del scope (3b-2g · R1);
755
+ * - `role_binding#role` VIVO (auditor ⚪ 8: tras un `revoke`/`purgeScope`/`purgeRole` concurrente el settle no resucita
756
+ * la arista; `liveness: false` solo en la decisión PREVIA del `grant`, que escribe esa arista en el mismo `Write`);
757
+ * - y, si el rol es LOCAL, su owner ancestro-o-igual del scope EN EL STORE: atajo `scope == owner` (`ancestor` no es
758
+ * reflexivo, K6), `Check(scope:<owner> ancestor scope:<S>)` y, si ese `Check` excede la cota del servidor (medido en M2:
759
+ * solo cuando el owner SÍ es ancestro a > 26 saltos), la cadena del store salto a salto (`storeChain`) — nunca «borrar
760
+ * por si acaso» (fail-closed aleatorio cerca de la cota). La laxitud de `ancestor` ante DOS padres es deriva que ya
761
+ * denuncian `moved` y reconcile (riesgo declarado del juez).
762
+ */
763
+ async decideRoleVisibility(pairs, options = {}) {
764
+ const decisions = new Map();
765
+ if (pairs.length === 0)
766
+ return decisions;
767
+ const catalog = await this.writeCatalogKnowing(pairs.map((p) => p.role));
768
+ const context = this.checkContext();
769
+ const checks = [];
770
+ const slots = [];
771
+ const seen = new Set();
772
+ for (const pair of pairs) {
773
+ const id = settlePairId(pair);
774
+ if (seen.has(id))
775
+ continue;
776
+ seen.add(id);
777
+ const role = catalog.roleByUuid(pair.role);
778
+ const binding = factsBindingObject(pair.scope, pair.role);
779
+ const levelOk = role !== null && role.scopeType === scopeFromKey(pair.scope)?.type;
780
+ const slot = {
781
+ id,
782
+ pair,
783
+ role,
784
+ levelOk,
785
+ edge: checks.push({ user: binding, relation: FACTS_BINDING_RELATION, object: factsScopeObject(pair.scope), context }) - 1,
786
+ };
787
+ if (role && levelOk) {
788
+ if (options.liveness !== false) {
789
+ slot.live = checks.push({ user: `${FACTS_ROLE_TYPE}:${pair.role}`, relation: FACTS_ROLE_RELATION, object: binding, context }) - 1;
790
+ }
791
+ if (role.owner !== GLOBAL_OWNER_KEY && role.owner !== pair.scope) {
792
+ slot.anc = checks.push({ user: factsScopeObject(role.owner), relation: FACTS_ANCESTOR_RELATION, object: factsScopeObject(pair.scope), context }) - 1;
793
+ }
794
+ }
795
+ slots.push(slot);
796
+ }
797
+ const results = await this.checkForWrite(checks);
798
+ const verdict = (index) => {
799
+ const result = results[index];
800
+ if (result.error)
801
+ throw result.error;
802
+ return result.allowed === true;
803
+ };
804
+ for (const slot of slots) {
805
+ const has = verdict(slot.edge);
806
+ if (!slot.role || !slot.levelOk) {
807
+ decisions.set(slot.id, { want: false, has, known: slot.role !== null });
808
+ continue;
809
+ }
810
+ let want = slot.live === undefined ? true : verdict(slot.live);
811
+ if (want && slot.anc !== undefined) {
812
+ const result = results[slot.anc];
813
+ if (result.error) {
814
+ if (!(result.error instanceof ResolutionLimitError))
815
+ throw result.error;
816
+ want = (await this.storeChain(slot.pair.scope)).includes(slot.role.owner);
817
+ }
818
+ else
819
+ want = result.allowed === true;
820
+ }
821
+ decisions.set(slot.id, { want, has, known: true });
822
+ }
823
+ return decisions;
824
+ }
825
+ /**
826
+ * **El ÚNICO sitio que da de ALTA una arista `scope#binding` de rol local** (y borra las que se le pidan), en lotes ≤ 100
827
+ * (`Write` con `Ignore` en las dos direcciones: dice el estado que DEBE quedar, no el delta — invariante 6).
828
+ */
829
+ async writeBindingEdges(writes, deletes, progress) {
830
+ if (!writes.length && !deletes.length)
831
+ return;
832
+ const operations = [...deletes.map((tuple) => ({ delete: tuple })), ...writes.map((tuple) => ({ write: tuple }))];
833
+ for (let i = 0; i < operations.length; i += PURGE_BATCH_SIZE) {
834
+ const chunk = operations.slice(i, i + PURGE_BATCH_SIZE);
835
+ await this.client.write({
836
+ writes: chunk.filter((o) => o.write).map((o) => o.write),
837
+ deletes: chunk.filter((o) => o.delete).map((o) => o.delete),
838
+ }, {
839
+ conflict: {
840
+ onDuplicateWrites: ClientWriteRequestOnDuplicateWrites.Ignore,
841
+ onMissingDeletes: ClientWriteRequestOnMissingDeletes.Ignore,
842
+ },
843
+ });
844
+ progress.writes += 1;
845
+ }
846
+ }
847
+ /**
848
+ * **`settleRoleVisibility`** — el punto de paso de L13. Decide con el store (`decideRoleVisibility`), y si alguna
849
+ * arista no está como tiene que estar, aplica la diferencia y VUELVE A LEER; termina cuando una lectura POSTERIOR a su
850
+ * última escritura ve el store coherente (punto fijo: «borro una vez» deja CLOSED persistente al entrar, R1-entrar).
851
+ *
852
+ * **Cota** (resolución 7): tras `SETTLE_MAX_ROUNDS` aplicaciones sin converger (un churn que mueve el árbol en cada
853
+ * relectura) se RINDE: borra las aristas de los pares que NO convergieron —y, en el mismo `Write`, `giveUpDeletes` (el `assignee` de un
854
+ * `grant` que CREÓ la asignación: vuelta a «no ocurrió»)— y lanza 409 `E_AUTHZ_WRITE_CONFLICT` marcado PARCIAL
855
+ * (`indeterminate: true`). Fail-CLOSED declarado e intra-tenant; en un re-grant el 409 NO significa «no asignado».
856
+ *
857
+ * Devuelve los pares cuyo rol el catálogo ya no conoce (`known: false`): el `grant` que los CREÓ deshace su asignación.
858
+ */
859
+ async settleRoleVisibility(pairs, progress, options = {}) {
860
+ if (pairs.length === 0)
861
+ return { unknownRoles: [] };
862
+ try {
863
+ return await this.settleRounds(pairs, progress, options);
864
+ }
865
+ catch (first) {
866
+ // **Asentar · cierre 1 (🟡 4 del auditor; medido)**: un settle que FALLA tras una carrera dejaba la arista rancia
867
+ // —OPEN persistente— y la letra solo declaraba «si el proceso muere» (fallo ∥ carrera, campaña `racefault` de
868
+ // `loteC-asentar-cierre1.ts`: 188 corridas OPEN de 26 464 en línea, 139 con el relay). Ahora, ante un fallo que no
869
+ // sea la cota (409 que ya decidió) ni un error del propio catálogo/arnés: UNA segunda vuelta completa (relee y
870
+ // corrige: un 503 suelto no deja nada); si también falla, sale el error ORIGINAL con el segundo como causa. NO se
871
+ // borra «por si acaso»: un error no es «no visible» (inv. 5, 🟠-3 del tester) y un borrado en bloque cerraría el
872
+ // subárbol entero de un `moved` por un parpadeo. Lo que queda DECLARADO (letra del paso 8): un backend que falla
873
+ // DOS veces seguidas dentro de la ventana deja lo que hubiera —la clase de «una caída dentro de la ventana»—, y lo
874
+ // repara el relay (si era un cambio de árbol con outbox) o lo ve la red (`authz:reconcile --dry-run`, exit ≠ 0).
875
+ if (isWriteConflictLike(first))
876
+ throw first;
877
+ try {
878
+ return await this.settleRounds(pairs, progress, options);
879
+ }
880
+ catch (second) {
881
+ if (isWriteConflictLike(second))
882
+ throw second;
883
+ throw withSettleCause(first, second);
884
+ }
885
+ }
886
+ }
887
+ /** Las vueltas del settle (lo que hasta el cierre 1 era el cuerpo de `settleRoleVisibility`). */
888
+ async settleRounds(pairs, progress, options) {
889
+ /**
890
+ * El `finally` del PROPIO settle (auditor 🟡 5 (iii)): si su `Write` vence el deadline —pudo confirmarse
891
+ * (invariante 13)—, la vuelta siguiente relee y corrige igual; si TAMBIÉN falla, manda el error del `Write` (el de la
892
+ * relectura va como causa). Una vez convergido tras un timeout del `Write`, el store quedó asentado: no se lanza.
893
+ */
894
+ let applyError;
895
+ let decisions;
896
+ for (let round = 0;; round++) {
897
+ try {
898
+ decisions = await this.decideRoleVisibility(pairs);
899
+ }
900
+ catch (error) {
901
+ throw applyError ? withSettleCause(applyError.error, error) : error;
902
+ }
903
+ const writes = [];
904
+ const deletes = [];
905
+ const unknownRoles = [];
906
+ for (const pair of pairs) {
907
+ const decision = decisions.get(settlePairId(pair));
908
+ if (!decision.known)
909
+ unknownRoles.push(pair);
910
+ if (decision.want === decision.has)
911
+ continue;
912
+ const edge = factsScopeBindingTuple(pair.scope, pair.role);
913
+ if (decision.want)
914
+ writes.push(edge);
915
+ else
916
+ deletes.push(edge);
917
+ }
918
+ if (writes.length === 0 && deletes.length === 0)
919
+ return { unknownRoles };
920
+ if (round >= SETTLE_MAX_ROUNDS)
921
+ break;
922
+ try {
923
+ await this.writeBindingEdges(writes, deletes, progress);
924
+ }
925
+ catch (error) {
926
+ // Solo un `Write` INDETERMINADO (deadline vencido: pudo confirmarse) se vuelve a verificar; cualquier otro fallo
927
+ // no escribió nada (FGA aplica cada `Write` entero) y se propaga tal cual.
928
+ if (applyError || !(error instanceof AuthorizationBackendTimeoutError))
929
+ throw applyError ? withSettleCause(applyError.error, error) : error;
930
+ applyError = { error };
931
+ }
932
+ }
933
+ // Asentar · cierre 1 (🟡 2 del auditor): la rendición borra SOLO las aristas de los pares que NO convergieron en la
934
+ // última lectura (`want ≠ has`). Con el subárbol de un `moved` alto (varias orgs) o un reconcile (toda la flota), borrar
935
+ // las de TODOS los pares dejaba CLOSED persistente a tenants que no hacían churn: el «intra-tenant» de la resolución 7
936
+ // no se cumplía. Un par con `want` y sin arista ya está cerrado; uno con arista y sin `want` se borra.
937
+ const edges = pairs
938
+ .filter((pair) => {
939
+ const decision = decisions.get(settlePairId(pair));
940
+ return decision.want !== decision.has;
941
+ })
942
+ .map((pair) => factsScopeBindingTuple(pair.scope, pair.role));
943
+ await this.writeBindingEdges([], [...edges, ...(options.giveUpDeletes ?? [])], progress);
944
+ throw markPartialWrite(new WriteConflictError(`La visibilidad de ${pairs.length} asignación(es) de rol local no convergió en ${SETTLE_MAX_ROUNDS} vueltas: el árbol ` +
945
+ `del store cambia en cada relectura (otro escritor mueve estos scopes en bucle). Se han BORRADO sus aristas ` +
946
+ `scope#binding (fail-closed): el rol no concede ahí hasta la siguiente escritura que lo asiente (un grant, un ` +
947
+ `moved del nodo o authz:reconcile). La escritura es PARCIAL: si era un re-grant, la asignación sigue existiendo.`));
948
+ }
558
949
  // ── Catálogo local (compartido entre drivers), desde el memo (2A) ──────
559
950
  // Una carga por driver/proceso en vez de una o dos consultas SQL por
560
951
  // pregunta, más una revalidación por operación (2D · F1). Un fallo de
@@ -578,6 +969,7 @@ export class OpenFgaAuthorizationDriver {
578
969
  // ── Contrato ──────────────────────────────────────────────────────────
579
970
  async authorize(subject, permission, scope) {
580
971
  assertIdentity({ subject, permission, scope });
972
+ await this.assertModel('authorize');
581
973
  // Una foto del catálogo por pregunta: el permiso sale de la misma versión
582
974
  // (y se paga una sola revalidación). Es lo ÚNICO local que queda.
583
975
  const catalog = await this.catalog.view();
@@ -635,6 +1027,7 @@ export class OpenFgaAuthorizationDriver {
635
1027
  assertIdentity({ scope });
636
1028
  if (scopes.length === 0)
637
1029
  return [];
1030
+ await this.assertModel('authorizeMany');
638
1031
  const catalog = await this.catalog.view();
639
1032
  const perm = catalog.permission(permission);
640
1033
  if (!perm)
@@ -694,13 +1087,15 @@ export class OpenFgaAuthorizationDriver {
694
1087
  async grant(subject, role, scope, options = {}) {
695
1088
  this.rejectTransaction('grant', options);
696
1089
  assertIdentity({ subject, role, scope, expiresAt: options.expiresAt });
1090
+ await this.assertModel('grant');
697
1091
  // El binding lleva la identidad canónica del árbol (K1), nunca la forma
698
1092
  // del llamante, y el uuid del rol, nunca su slug (3A · A1). El rol tiene
699
1093
  // que EXISTIR en ese scope (3B · B2: global, o local a un ancestro-o-igual)
700
1094
  // con una composición legal (B5).
701
1095
  const chain = await this.knownScope(scope, 'grant');
702
1096
  const [target] = chain;
703
- const catalog = await this.catalog.view();
1097
+ // K4 (lote C · carreras, resolución 2): el catálogo de una ESCRITURA se contrasta en fresco, con ventana o sin ella.
1098
+ const catalog = await this.writeCatalog();
704
1099
  const declared = resolveRoleQuery(catalog, role, target, chainKeysFrom(chain)[0]);
705
1100
  assertRoleAssignableAt(catalog, declared);
706
1101
  const roleUuid = declared.uuid;
@@ -709,6 +1104,64 @@ export class OpenFgaAuthorizationDriver {
709
1104
  relation: 'assignee',
710
1105
  object: `role_binding:${scopeKey(target)}|${roleUuid}`,
711
1106
  };
1107
+ const structure = factsBindingTuples(scopeKey(target), roleUuid);
1108
+ // Un rol GLOBAL no depende del árbol (su nivel es inmutable por uuid): sus tres tuplas, en UN `Write`, sin settle.
1109
+ if (declared.owner === GLOBAL_OWNER_KEY)
1110
+ return this.writeGrant(key, structure, options);
1111
+ // **L13 · un rol LOCAL** (lote C · carreras, opción C firmada; injerto del analista): la visibilidad se decide con el
1112
+ // STORE antes de escribir (`decideRoleVisibility`, HIGHER) — durante el lag del relay al ENTRAR en el owner el `grant`
1113
+ // ya no concede (🟡 4: fail-CLOSED, como `attached`) — y la arista solo va en el `Write` si el store la exige. Después,
1114
+ // pase lo que pase con el `Write` (un timeout pudo confirmarse, invariante 13), el SETTLE relee y corrige hasta un punto
1115
+ // fijo (R2: un `moved` que barrió antes de que este `grant` escribiera).
1116
+ const pair = { scope: scopeKey(target), role: roleUuid };
1117
+ const pre = (await this.decideRoleVisibility([pair], { liveness: false })).get(settlePairId(pair));
1118
+ const progress = { writes: 0, attempted: 0 };
1119
+ let outcome;
1120
+ let failure;
1121
+ try {
1122
+ outcome = await this.writeGrant(key, pre.want ? structure : structure.filter((t) => t.relation !== FACTS_BINDING_RELATION), options, progress);
1123
+ }
1124
+ catch (error) {
1125
+ failure = { error };
1126
+ }
1127
+ const created = outcome?.existed === false;
1128
+ let settled = { unknownRoles: [] };
1129
+ if (failure === undefined || progress.attempted > 0) {
1130
+ try {
1131
+ // Rendición de la cota con una asignación RECIÉN creada: el `assignee` se va en el MISMO `Write` que la arista.
1132
+ settled = await this.settleRoleVisibility([pair], progress, { giveUpDeletes: created ? [key] : [] });
1133
+ }
1134
+ catch (settleError) {
1135
+ // El error ORIGINAL manda (el del settle va como causa); sin original, el del settle —ya escribimos: PARCIAL—.
1136
+ if (failure)
1137
+ throw withSettleCause(progress.writes > 0 ? markPartialObject(failure.error) : failure.error, settleError);
1138
+ throw markPartialObject(settleError);
1139
+ }
1140
+ }
1141
+ if (settled.unknownRoles.length > 0) {
1142
+ // **Hallazgo 2 del panel TREE (ORPHAN)**: el rol se PURGÓ mientras se asignaba (`purgeRole` concurrente). Una
1143
+ // asignación de un rol que ya no existe no se deja: se borran sus TRES tuplas en UN `Write` (vuelta a «no ocurrió»;
1144
+ // con el rol purgado, `role_binding#role` no es de nadie más) y 409 marcado parcial. **Asentar · cierre 1 (🟡 3 del
1145
+ // auditor)**: también en un RE-grant (`existed: true`: el `replaceAssignment` repone `assignee` y `role_binding#role`
1146
+ // DESPUÉS del repaso de la purga) y también si el `Write` lanzó tras confirmarse (antes, `if (failure) throw` salía
1147
+ // primero): con el rol purgado no hay nada que preservar. El error original, si lo hay, manda.
1148
+ await this.client.write({ writes: [], deletes: [key, ...structure] }, { conflict: { onDuplicateWrites: ClientWriteRequestOnDuplicateWrites.Ignore, onMissingDeletes: ClientWriteRequestOnMissingDeletes.Ignore } });
1149
+ if (failure)
1150
+ throw markPartialObject(failure.error);
1151
+ throw markPartialWrite(new WriteConflictError(`grant: el rol ${roleUuid} se purgó del catálogo mientras se asignaba en ${scopeKey(target)}; la asignación se ` +
1152
+ `ha deshecho (no ocurrió). Si el rol se vuelve a crear, repite el grant.`));
1153
+ }
1154
+ if (failure)
1155
+ throw progress.writes > 0 ? markPartialObject(failure.error) : failure.error;
1156
+ return outcome;
1157
+ }
1158
+ /**
1159
+ * El cuerpo de ESCRITURA del `grant` (sin la decisión de visibilidad): lee la asignación, decide entre no-op, write o
1160
+ * delete+write, y re-aplica ante una carrera. `structure` son las aristas que van con el `assignee` en el MISMO `Write`
1161
+ * (las tres para un rol global; sin la `scope#binding` si el store dice que el rol local no es visible ahí). `progress`
1162
+ * cuenta los `Write` INTENTADOS (el settle del `grant` local corre si hubo alguno, también si falló: invariante 13).
1163
+ */
1164
+ async writeGrant(key, structure, options, progress = { attempted: 0 }) {
712
1165
  // **La forma nueva de los objetos (3b-2c), en UNA sola escritura
713
1166
  // (3b-2f · R3).** En (c2) la asignación no es alcanzable con el
714
1167
  // `assignee` a secas: el binding tiene que colgar del scope
@@ -722,7 +1175,6 @@ export class OpenFgaAuthorizationDriver {
722
1175
  // mismo hecho, que es peor que perder la escritura. Ahora las TRES van
723
1176
  // en el mismo `Write` (transaccional en FGA): o están las tres o no está
724
1177
  // ninguna.
725
- const structure = factsBindingTuples(scopeKey(target), roleUuid);
726
1178
  /** El write COMPLETO de una asignación: su estructura y ella. */
727
1179
  const writeSet = (tuple) => [...structure, tuple];
728
1180
  const tupleFor = (expiresAt) => expiresAt
@@ -751,14 +1203,20 @@ export class OpenFgaAuthorizationDriver {
751
1203
  if (options.expiresAt === undefined)
752
1204
  throw withPreserveRecipe(current.error);
753
1205
  const expiresAt = options.expiresAt;
1206
+ progress.attempted += 1;
754
1207
  const existed = await this.writeAssignment(key, tupleFor(expiresAt), structure);
755
1208
  return { existed, expiresAt };
756
1209
  }
757
1210
  if (current.kind === 'present') {
1211
+ // L11 · H8: la asignación YA existe con una condición que el paquete no escribe o no sabe leer: ni «sin
1212
+ // caducidad» (no-op mudo) ni «vencida» (reescritura a ciegas). 409, cero escrituras, receta.
1213
+ if (current.invalid !== undefined)
1214
+ throw RenewalUnsafeError.foreignCondition('grant', `${key.object}#${key.relation}@${key.user}`, current.invalid);
758
1215
  const expiresAt = resolveGrantExpiry(current.validUntil, options.expiresAt, this.now());
759
1216
  if (sameInstant(current.validUntil, expiresAt)) {
760
1217
  return { existed: true, previousExpiresAt: current.validUntil, expiresAt };
761
1218
  }
1219
+ progress.attempted += 1;
762
1220
  await this.replaceAssignment(key, tupleFor(expiresAt), structure);
763
1221
  return { existed: true, previousExpiresAt: current.validUntil, expiresAt };
764
1222
  }
@@ -775,7 +1233,8 @@ export class OpenFgaAuthorizationDriver {
775
1233
  // a la vez: la asignación sigue sin existir, así que se repite el
776
1234
  // MISMO write ignorando duplicados (las aristas no llevan condición,
777
1235
  // así que un duplicado suyo es idéntico por construcción). Sigue
778
- // siendo UNA escritura y sigue siendo atómica.
1236
+ // siendo UNA escritura y sigue siendo atómica PARA EL ESCRITOR (para
1237
+ // un lector, `can_<P>` exige las tres: una mezcla es fail-closed).
779
1238
  //
780
1239
  // Cuál de los dos fue lo dice la RELECTURA, no el error: el conflicto
781
1240
  // transaccional de FGA (`Aborted`, HTTP 409) no nombra ninguna tupla.
@@ -791,6 +1250,7 @@ export class OpenFgaAuthorizationDriver {
791
1250
  const expiresAt = options.expiresAt ?? null;
792
1251
  for (let attempt = 0;; attempt++) {
793
1252
  try {
1253
+ progress.attempted += 1;
794
1254
  await this.client.writeTuples(writeSet(tupleFor(expiresAt)), attempt === 0 ? undefined : IGNORE_DUPLICATE_WRITES);
795
1255
  return { existed: false, expiresAt };
796
1256
  }
@@ -803,8 +1263,11 @@ export class OpenFgaAuthorizationDriver {
803
1263
  }
804
1264
  const raced = await this.readAssignment(key);
805
1265
  if (raced.kind === 'present') {
1266
+ if (raced.invalid !== undefined)
1267
+ throw RenewalUnsafeError.foreignCondition('grant', `${key.object}#${key.relation}@${key.user}`, raced.invalid);
806
1268
  const target = resolveGrantExpiry(raced.validUntil, options.expiresAt, this.now());
807
1269
  if (!sameInstant(raced.validUntil, target)) {
1270
+ progress.attempted += 1;
808
1271
  await this.replaceAssignment(key, tupleFor(target), structure);
809
1272
  }
810
1273
  return { existed: true, previousExpiresAt: raced.validUntil, expiresAt: target };
@@ -814,6 +1277,7 @@ export class OpenFgaAuthorizationDriver {
814
1277
  // preservar, y el error de lectura va como causa.
815
1278
  if (options.expiresAt === undefined)
816
1279
  throw withPreserveRecipe(raced.error);
1280
+ progress.attempted += 1;
817
1281
  const existed = await this.writeAssignment(key, tupleFor(options.expiresAt), structure);
818
1282
  return { existed, expiresAt: options.expiresAt };
819
1283
  }
@@ -857,7 +1321,16 @@ export class OpenFgaAuthorizationDriver {
857
1321
  await this.client.deleteTuples([key], {
858
1322
  conflict: { onMissingDeletes: ClientWriteRequestOnMissingDeletes.Ignore },
859
1323
  });
860
- await this.client.writeTuples([...structure, tuple], IGNORE_DUPLICATE_WRITES);
1324
+ // Cierre 3 de D2 · ⚪ C2-5 (gemelo de roles del N1 (b) de relaciones): el delete YA entró — si el write falla, el
1325
+ // `assignee` quedó BORRADO (el grant se perdió: fail-closed) y la escritura es PARCIAL, no «no escrita»: el error va
1326
+ // marcado (`markPartialWrite`) y el manager notifica `indeterminate: true` (invariante 13). Hasta el cierre 3 era un
1327
+ // 503 sin marca y sin evento, con el grant ya revocado.
1328
+ try {
1329
+ await this.client.writeTuples([...structure, tuple], IGNORE_DUPLICATE_WRITES);
1330
+ }
1331
+ catch (error) {
1332
+ throw error !== null && typeof error === 'object' ? markPartialWrite(error) : error;
1333
+ }
861
1334
  }
862
1335
  /**
863
1336
  * Estado actual de una asignación, con TRES resultados posibles y no dos.
@@ -870,15 +1343,15 @@ export class OpenFgaAuthorizationDriver {
870
1343
  */
871
1344
  async readAssignment(key) {
872
1345
  try {
873
- const response = await this.client.read(key, { consistency: this.consistency });
1346
+ // Hallazgo 3 TREE: la lectura decide la ESCRITURA del `grant` (omitido = preservar) ⇒ HIGHER siempre.
1347
+ const response = await this.client.read(key, { consistency: WRITE_CONSISTENCY });
874
1348
  // ⚪ N5 (cierre 2 del lote B): un 200 sin `tuples` no es «ausente», es
875
1349
  // «no se sabe» (503 de `tuplesOrThrow` como causa): sin objetivo explícito
876
1350
  // `grant` no escribe a ciegas sobre una caducidad que no ha podido ver.
877
1351
  const tuple = this.tuplesOrThrow(response, 'readAssignment')[0];
878
1352
  if (!tuple)
879
1353
  return { kind: 'absent' };
880
- const validUntil = tuple.key?.condition?.context?.valid_until;
881
- return { kind: 'present', validUntil: toExpiryDate(validUntil) };
1354
+ return { kind: 'present', ...storedCondition(tuple.key?.condition) };
882
1355
  }
883
1356
  catch (error) {
884
1357
  return { kind: 'unknown', error };
@@ -887,10 +1360,14 @@ export class OpenFgaAuthorizationDriver {
887
1360
  async revoke(subject, role, scope, options) {
888
1361
  this.rejectTransaction('revoke', options);
889
1362
  assertIdentity({ subject, role, scope });
1363
+ await this.assertModel('revoke');
890
1364
  // Rol fuera del catálogo para ese nivel ⇒ 422, como en `grant` (D10). Se
891
1365
  // quitan los bindings de TODOS los roles con ese nombre en el scope
892
1366
  // exacto (3B): a lo sumo uno es visible ahí; quitar nunca concede.
893
- const named = rolesToRevoke(await this.catalog.view(), role, scope);
1367
+ // Asentar · cierre 1 (🟡 5 del auditor, K4): el catálogo de una ESCRITURA, con la versión en fresco. Con el memo de
1368
+ // `{ everyMs }` un local recién definido en otro proceso que ensombrece al global no salía en `rolesNamed`: el delete
1369
+ // con `Ignore` era un no-op y la asignación local seguía concediendo (un «revocado» falso, mudo).
1370
+ const named = rolesToRevoke(await this.writeCatalog(), role, scope);
894
1371
  // Sin cadena, TODAS las ortografías de las que el uuid puede ser alias
895
1372
  // (3b-8 · A4): con una sola, el alias hacía del delete un no-op
896
1373
  // silencioso (`onMissingDeletes: Ignore`) y el hecho canónico seguía ahí.
@@ -900,6 +1377,7 @@ export class OpenFgaAuthorizationDriver {
900
1377
  }
901
1378
  async hasRole(subject, role, scope) {
902
1379
  assertIdentity({ subject, role, scope });
1380
+ await this.assertModel('hasRole');
903
1381
  const user = this.fgaSubject(subject);
904
1382
  const chain = await this.chain(scope, 'hasRole');
905
1383
  if (!chain)
@@ -926,6 +1404,7 @@ export class OpenFgaAuthorizationDriver {
926
1404
  async deny(subject, permission, scope, options) {
927
1405
  this.rejectTransaction('deny', options);
928
1406
  assertIdentity({ subject, permission, scope });
1407
+ await this.assertModel('deny');
929
1408
  const perm = await this.findPermission(permission);
930
1409
  if (!perm)
931
1410
  throw new UnknownPermissionError(permission);
@@ -937,6 +1416,7 @@ export class OpenFgaAuthorizationDriver {
937
1416
  async removeDeny(subject, permission, scope, options) {
938
1417
  this.rejectTransaction('removeDeny', options);
939
1418
  assertIdentity({ subject, permission, scope });
1419
+ await this.assertModel('removeDeny');
940
1420
  const perm = await this.findPermission(permission);
941
1421
  if (!perm)
942
1422
  throw new UnknownPermissionError(permission);
@@ -969,6 +1449,7 @@ export class OpenFgaAuthorizationDriver {
969
1449
  */
970
1450
  async listSubjects(role, scope) {
971
1451
  assertIdentity({ role, scope });
1452
+ await this.assertModel('listSubjects');
972
1453
  // Un rol que el catálogo no declara para ese nivel (en ningún owner) no
973
1454
  // tiene holders (D5): nada que leer, ni árbol ni store. Un scope que el
974
1455
  // árbol no conoce no existe para el motor (D8, K1): nada; uno que conoce
@@ -1032,7 +1513,10 @@ export class OpenFgaAuthorizationDriver {
1032
1513
  user: this.fgaSubject(subject),
1033
1514
  relation: factsRelationsOf(slug).denied,
1034
1515
  object: `${FACTS_SCOPE_TYPE}:`,
1035
- }, { at });
1516
+ },
1517
+ // Cierre de L11 (🟡-3 del tester, gemelo de `#carriedDeny`): el deny con una condición que el paquete no escribe
1518
+ // cuenta VIVO donde NIEGA — `listScopes` lista de menos, nunca de más.
1519
+ { at, includeInvalid: true });
1036
1520
  return new Set(tuples.map((t) => t.object.slice(FACTS_SCOPE_TYPE.length + 1)));
1037
1521
  }
1038
1522
  /**
@@ -1088,6 +1572,7 @@ export class OpenFgaAuthorizationDriver {
1088
1572
  }
1089
1573
  async listRoles(subject, scope) {
1090
1574
  assertIdentity({ subject, scope });
1575
+ await this.assertModel('listRoles');
1091
1576
  // Un scope que el árbol no conoce no existe para el motor (D8): nada.
1092
1577
  const chain = await this.chain(scope, 'listRoles');
1093
1578
  if (!chain)
@@ -1117,6 +1602,7 @@ export class OpenFgaAuthorizationDriver {
1117
1602
  assertScope(scope);
1118
1603
  if (chain.length === 0)
1119
1604
  return [];
1605
+ await this.assertModel('rolesInChain');
1120
1606
  const catalog = await this.catalog.view();
1121
1607
  const keysFrom = chainKeysFrom(chain);
1122
1608
  const wanted = new Map(chain.map((s, i) => [scopeKey(s), { scope: s, keys: keysFrom[i] }]));
@@ -1142,6 +1628,7 @@ export class OpenFgaAuthorizationDriver {
1142
1628
  }
1143
1629
  async listRoleScopes(subject, scopeType) {
1144
1630
  assertIdentity({ subject, scopeType });
1631
+ await this.assertModel('listRoleScopes');
1145
1632
  const catalog = await this.catalog.view();
1146
1633
  const byScope = new Map();
1147
1634
  for (const binding of await this.listBindings(subject)) {
@@ -1168,6 +1655,7 @@ export class OpenFgaAuthorizationDriver {
1168
1655
  }
1169
1656
  async listScopes(subject, permission) {
1170
1657
  assertIdentity({ subject, permission });
1658
+ await this.assertModel('listScopes');
1171
1659
  const catalog = await this.catalog.view();
1172
1660
  const perm = catalog.permission(permission);
1173
1661
  if (!perm)
@@ -1205,6 +1693,7 @@ export class OpenFgaAuthorizationDriver {
1205
1693
  */
1206
1694
  async listDenies(subject, scope) {
1207
1695
  assertIdentity(scope ? { subject, scope } : { subject });
1696
+ await this.assertModel('listDenies');
1208
1697
  const chain = scope ? await this.chain(scope, 'listDenies') : null;
1209
1698
  if (scope && !chain)
1210
1699
  return [];
@@ -1255,8 +1744,12 @@ export class OpenFgaAuthorizationDriver {
1255
1744
  * borrar una tupla inexistente—, la cadena del padre NUEVO en el store
1256
1745
  * (3b-8 · A5: el anti-ciclos del consumidor no ve un store
1257
1746
  * desincronizado) y **UN solo `Write`** con el delete del padre viejo y el
1258
- * write del nuevo, que es atómico dentro de la request. O(profundidad)
1259
- * requests, UNA mutación.
1747
+ * write del nuevo, que es atómico PARA EL ESCRITOR (el store nunca guarda
1748
+ * los dos padres ni ninguno) pero NO para un lector concurrente: un `Check`
1749
+ * que dura lo que el `Write` puede leer la arista vieja por una rama del
1750
+ * modelo y la nueva por otra (lector rasgado, fila «torn read» de Limits del
1751
+ * README; medido 1,2–7,1 %, decisión del dueño 6b). O(profundidad) requests;
1752
+ * con roles locales, además el barrido F4 (ver `reparent`).
1260
1753
  */
1261
1754
  async onScopeMoved(child, newParent) {
1262
1755
  await this.reparent(child, newParent, 'scopes.moved');
@@ -1326,9 +1819,10 @@ export class OpenFgaAuthorizationDriver {
1326
1819
  if (child.type === APP_SCOPE_TYPE) {
1327
1820
  throw new InvalidIdentityError('scopes.detached: la raíz `app` no se puede borrar ni purgar');
1328
1821
  }
1822
+ await this.assertModel('scopes.detached');
1329
1823
  const scope = await this.canonicalOrSelf(child, 'scopes.detached');
1330
1824
  const object = factsScopeObject(scopeKey(scope));
1331
- const current = await this.readAllTuples({ object, relation: FACTS_PARENT_RELATION }, { includeExpired: true });
1825
+ const current = await this.readForWrite({ object, relation: FACTS_PARENT_RELATION }, { includeExpired: true });
1332
1826
  // Aquí NO se exige un solo padre: el nodo se va entero y llevarse las dos
1333
1827
  // aristas de una deriva es lo correcto (a diferencia de `moved`, donde
1334
1828
  // habría que elegir cuál sobrevive).
@@ -1360,58 +1854,115 @@ export class OpenFgaAuthorizationDriver {
1360
1854
  * impide esa carrera es el ESCRITOR ÚNICO del relay (`ScopeOutbox.acquire`).
1361
1855
  */
1362
1856
  async reparent(child, parent, operation) {
1857
+ // Cierre de L11 (⚪ A3.4 del auditor): la puerta ANTES de resolver la arista (`assertEdge` lee la cadena del
1858
+ // CONSUMIDOR), como en el resto de métodos — la desviación 3 del informe («antes del catálogo y del resolutor») era
1859
+ // falsa aquí. Consecuencia, fail-closed: con un modelo distinto un `attached`/`moved` es 500 aunque la arista fuera 422.
1860
+ await this.assertModel(operation);
1363
1861
  const { childKey, parentKey } = await this.assertEdge(child, parent, operation);
1364
1862
  const wanted = factsParentTuple(childKey, parentKey);
1365
- for (let attempt = 0;; attempt++) {
1366
- const current = await this.readAllTuples({ object: wanted.object, relation: FACTS_PARENT_RELATION }, { includeExpired: true });
1367
- this.assertOneParent(wanted.object, current);
1368
- if (current.length === 1 && current[0].user === wanted.user) {
1369
- // **El atajo idempotente TAMBIÉN barre** (3b-8 · A3). El relay
1370
- // reintenta: un `moved` cuyo primer intento escribió la arista y
1371
- // murió ANTES del barrido vuelve a entregarse, cae aquí, y hasta
1372
- // 3b-8 el `return` a secas se saltaba `sweepLocalRoleBindings` — el
1373
- // rol local del tenant A seguía concediendo en el subárbol del B
1374
- // para siempre, con el relay en verde (invariante 18 roto por
1375
- // COMPOSICIÓN de dos piezas correctas: la idempotencia y el barrido).
1376
- // Coste: cero requests sin roles locales, como el propio barrido.
1377
- await this.sweepLocalRoleBindings(childKey);
1863
+ // Lote «antes de RC» · F4 (invariante 13): cuántos `Write` YA entraron. Un error tras ≥ 1 sale marcado PARCIAL (el
1864
+ // barrido previo o la arista ya cambiaron respuestas); antes del primero, no. Lote C (L13): `attempted` cuenta los
1865
+ // INTENTADOS —un `Write` que vence el deadline pudo confirmarse—, y con ≥ 1 el SETTLE corre también si la operación
1866
+ // lanza (`finally` lógico de la resolución del juez; el error ORIGINAL manda).
1867
+ const progress = { writes: 0, attempted: 0 };
1868
+ /** El settle ya corrió (o está corriendo): un error SUYO no lo vuelve a lanzar desde el `catch`. */
1869
+ let settling = false;
1870
+ const settle = async () => {
1871
+ settling = true;
1872
+ await this.settleSubtree(childKey, progress);
1873
+ };
1874
+ try {
1875
+ for (let attempt = 0;; attempt++) {
1876
+ // Hallazgo 3 del panel TREE: la lectura del padre ALIMENTA el `Write` de la arista (y su atajo idempotente) ⇒
1877
+ // HIGHER. Con una foto atrasada el atajo veía «ya está ahí» y volvía sin escribir: el store se quedaba con OTRO
1878
+ // padre que el consumidor, sin concurrencia (`churnC` solo, en `minimize`).
1879
+ const current = await this.readForWrite({ object: wanted.object, relation: FACTS_PARENT_RELATION }, { includeExpired: true });
1880
+ this.assertOneParent(wanted.object, current);
1881
+ if (current.length === 1 && current[0].user === wanted.user) {
1882
+ // **El atajo idempotente TAMBIÉN barre** (3b-8 · A3). El relay
1883
+ // reintenta: un `moved` cuyo primer intento escribió la arista y
1884
+ // murió ANTES del barrido vuelve a entregarse, cae aquí, y hasta
1885
+ // 3b-8 el `return` a secas se saltaba `sweepLocalRoleBindings` — el
1886
+ // rol local del tenant A seguía concediendo en el subárbol del B
1887
+ // para siempre, con el relay en verde (invariante 18 roto por
1888
+ // COMPOSICIÓN de dos piezas correctas: la idempotencia y el barrido).
1889
+ // Desde F4 es el barrido COMPLETO de después; desde el lote C, el SETTLE del subárbol.
1890
+ // Coste: cero requests sin roles locales, como el propio barrido.
1891
+ await settle();
1892
+ return;
1893
+ }
1894
+ // **El ciclo se mira también en el árbol DEL STORE** (3b-8 · A5).
1895
+ // `assertEdge` valida contra la cadena del CONSUMIDOR, pero quien
1896
+ // decide en `facts` es el store, y una outbox aplicada en desorden (un
1897
+ // `moved` aparcado + el move inverso posterior) lo deja desincronizado:
1898
+ // la arista nueva —legal para el consumidor— puede cerrar un ciclo
1899
+ // REAL, que FGA acepta y evalúa en los dos sentidos (cruce 3:
1900
+ // fail-open mudo). `storeChain` del barrido solo lanzaba DESPUÉS de
1901
+ // escribir y no deshacía nada. Se sube por la cadena del PADRE en el
1902
+ // store antes del `Write`; el coste es O(profundidad) lecturas por
1903
+ // cambio de árbol, fuera del camino caliente.
1904
+ const parentChain = await this.storeChain(parentKey);
1905
+ if (parentChain.includes(childKey)) {
1906
+ throw new ScopeCycleError(`${operation}: en el árbol del STORE ${factsScopeObject(parentKey)} desciende de ${childKey} ` +
1907
+ `(o es él mismo); escribir la arista cerraría un ciclo real que FGA evalúa en los dos ` +
1908
+ `sentidos. El árbol del store está desincronizado del consumidor (una outbox aplicada en ` +
1909
+ `desorden, una entrada aparcada): reconstrúyelo con authz:reconcile.`);
1910
+ }
1911
+ // **F4 — el barrido PREVIO** (lote «antes de RC», decisión del dueño 6b-estudio, opción E; linaje L10): ANTES de
1912
+ // escribir la arista se BORRAN las aristas `scope#binding` de los roles locales que la regla del invariante 18
1913
+ // prohíbe con la cadena NUEVA (la del padre en el store + el hijo). Hasta F4 el orden era arista → barrido, y el
1914
+ // estado intermedio «hijo bajo el padre nuevo con el binding del owner viejo visible» era un estado REAL del store
1915
+ // que concedía —saltándose un deny del owner— en el 15,9 % de los `Check` concurrentes (🟠 del auditor del
1916
+ // estudio 6b). Con el barrido previo ese prefijo falla CERRADO: el usuario pierde el rol local antes de tiempo,
1917
+ // nunca lo gana. Solo BORRA: escribir aquí lo que la cadena nueva exige concedería bajo el padre VIEJO (entrar en
1918
+ // el owner con un deny en el owner: mutante `writes-before-edge`). Las escrituras van en el SETTLE de después. Lo
1919
+ // que NO elimina: un `Check` que dura los dos `Write` (lee el binding antes del primero y la arista después del
1920
+ // segundo) — el residuo «dos escrituras en un Check» de la fila «torn read» (medido con c2r por el auditor de F4:
1921
+ // ~1,8·10⁻³ en un DESCENDIENTE al salir del owner, ~10⁻⁵ en el nodo movido). Las carreras entre ESCRITORES
1922
+ // (`projectCatalogRole`/`grant` concurrentes con el move) las cierra el settle (lote C, L13).
1923
+ await this.sweepLocalRoleBindings(childKey, progress, { phase: 'pre', chain: [childKey, ...parentChain] });
1924
+ try {
1925
+ progress.attempted += 1;
1926
+ await this.client.write({ writes: [wanted], deletes: current });
1927
+ progress.writes += 1;
1928
+ }
1929
+ catch (error) {
1930
+ if (!isTreeWriteRace(error))
1931
+ throw error;
1932
+ if (attempt >= TREE_WRITE_ATTEMPTS - 1) {
1933
+ throw new WriteConflictError(`${operation}: ${TREE_WRITE_ATTEMPTS} intentos y el árbol del store sigue en conflicto sobre ` +
1934
+ `${wanted.object}: otro escritor toca las mismas tuplas (otra escritura del árbol fuera del relay, o dos ` +
1935
+ `pasadas a la vez sin lease en tu outbox). Es contención, no una entrada irreparable: en el relay NO ` +
1936
+ `consume intento y la pasada siguiente relee y re-aplica.`, { cause: error });
1937
+ }
1938
+ continue;
1939
+ }
1940
+ // **El SETTLE del subárbol** (lote C · L13; sustituye al barrido COMPLETO de F4): con la arista ya escrita, relee
1941
+ // el store (HIGHER) y deja cada `scope#binding` de rol local del subárbol como dice la cadena DEL STORE, hasta un
1942
+ // punto fijo. Escribe lo que la cadena nueva exige y vuelve a borrar lo que prohíbe: borrar otra vez no es
1943
+ // redundante — un `grant` del rol local que aterrice ENTRE el barrido previo y la arista deja su `scope#binding`
1944
+ // visible fuera del owner, y solo esta pasada lo limpia (mutante `post-writes-only`) —; y un `projectCatalogRole` o
1945
+ // un `moved` del padre que corran a la vez se ven en la relectura (R1/R3/K2).
1946
+ await settle();
1378
1947
  return;
1379
1948
  }
1380
- // **El ciclo se mira también en el árbol DEL STORE** (3b-8 · A5).
1381
- // `assertEdge` valida contra la cadena del CONSUMIDOR, pero quien
1382
- // decide en `facts` es el store, y una outbox aplicada en desorden (un
1383
- // `moved` aparcado + el move inverso posterior) lo deja desincronizado:
1384
- // la arista nueva —legal para el consumidor— puede cerrar un ciclo
1385
- // REAL, que FGA acepta y evalúa en los dos sentidos (cruce 3:
1386
- // fail-open mudo). `storeChain` del barrido solo lanzaba DESPUÉS de
1387
- // escribir y no deshacía nada. Se sube por la cadena del PADRE en el
1388
- // store antes del `Write`; el coste es O(profundidad) lecturas por
1389
- // cambio de árbol, fuera del camino caliente.
1390
- if ((await this.storeChain(parentKey)).includes(childKey)) {
1391
- throw new ScopeCycleError(`${operation}: en el árbol del STORE ${factsScopeObject(parentKey)} desciende de ${childKey} ` +
1392
- `(o es él mismo); escribir la arista cerraría un ciclo real que FGA evalúa en los dos ` +
1393
- `sentidos. El árbol del store está desincronizado del consumidor (una outbox aplicada en ` +
1394
- `desorden, una entrada aparcada): reconstrúyelo con authz:reconcile.`);
1395
- }
1396
- try {
1397
- await this.client.write({ writes: [wanted], deletes: current });
1398
- }
1399
- catch (error) {
1400
- if (!isTreeWriteRace(error))
1401
- throw error;
1402
- if (attempt >= TREE_WRITE_ATTEMPTS - 1) {
1403
- throw new WriteConflictError(`${operation}: ${TREE_WRITE_ATTEMPTS} intentos y el árbol del store sigue en conflicto sobre ` +
1404
- `${wanted.object}. El relay es escritor ÚNICO: si hay dos pasadas a la vez, dale un lease a tu outbox.`, { cause: error });
1949
+ }
1950
+ catch (error) {
1951
+ let final = error;
1952
+ if (!settling && progress.attempted > 0) {
1953
+ try {
1954
+ await settle();
1955
+ }
1956
+ catch (settleError) {
1957
+ final = withSettleCause(error, settleError);
1405
1958
  }
1406
- continue;
1407
1959
  }
1408
- await this.sweepLocalRoleBindings(childKey);
1409
- return;
1960
+ throw progress.writes > 0 && final !== null && typeof final === 'object' ? markPartialWrite(final) : final;
1410
1961
  }
1411
1962
  }
1412
1963
  /**
1413
- * **El barrido del rol local** (3b-2e · E1; decisión del dueño del
1414
- * 2026-08-30, opción 1).
1964
+ * **El barrido PREVIO del rol local** (3b-2e · E1; decisión del dueño del
1965
+ * 2026-08-30, opción 1; F4: solo borra).
1415
1966
  *
1416
1967
  * En (c2) el modelo no tiene `owner`, así que `authorize` NO vuelve a
1417
1968
  * decidir con el árbol de hoy si un rol LOCAL sigue siendo visible: un
@@ -1421,11 +1972,12 @@ export class OpenFgaAuthorizationDriver {
1421
1972
  * un **fail-open** — y encima uno que solo se ve comparando drivers.
1422
1973
  *
1423
1974
  * Lo que se toca es la arista `scope#binding`, que es lo que hace
1424
- * ALCANZABLE la asignación: se BORRA donde el owner del rol ya no está en
1425
- * la cadena y se REESCRIBE donde vuelve a estarlo (invariante 18: volver la
1426
- * unit a su sitio restaura). No se toca el `assignee` —el hecho de la
1427
- * asignación no cambia porque el árbol se mueva, igual que en `database`—
1428
- * ni nada de un rol GLOBAL, cuya visibilidad no depende del árbol.
1975
+ * ALCANZABLE la asignación: el barrido previo la BORRA donde el owner del
1976
+ * rol no estará en la cadena NUEVA; las ALTAS (volver a su sitio restaura)
1977
+ * son del settle de después (`settleSubtree`), el punto de paso único de
1978
+ * L13. No se toca el `assignee` —el hecho de la asignación no cambia porque
1979
+ * el árbol se mueva, igual que en `database`— ni nada de un rol GLOBAL, cuya
1980
+ * visibilidad no depende del árbol.
1429
1981
  *
1430
1982
  * **Por subárbol, no por nodo** (consecuencia 2): los descendientes del
1431
1983
  * nodo movido también cambian de cadena.
@@ -1434,24 +1986,43 @@ export class OpenFgaAuthorizationDriver {
1434
1986
  * que no usa delegación— son **cero** requests y `moved` sigue siendo el
1435
1987
  * `Read` + `Write` del cruce 8. Con roles locales: una lectura por rol local
1436
1988
  * (sus bindings, por `role_binding#role`), la bajada del subárbol y un
1437
- * `Write` por lote.
1989
+ * `Write` por lote (el coste O(roles locales) por `moved` es el 🟡 6, declarado hasta 2.6).
1438
1990
  */
1439
- async sweepLocalRoleBindings(movedKey) {
1440
- const catalog = await this.catalog.view();
1991
+ async sweepLocalRoleBindings(movedKey, progress, options) {
1992
+ const { catalog, pairs, subtree } = await this.localBindingsBelow(movedKey, options.chain);
1993
+ if (pairs.length === 0)
1994
+ return;
1995
+ const writes = [];
1996
+ const deletes = [];
1997
+ for (const pair of pairs) {
1998
+ this.classifyBindingEdge(catalog, { scope: scopeFromKey(pair.scope), uuid: pair.role }, subtree.get(pair.scope), writes, deletes);
1999
+ }
2000
+ // F4: el previo solo BORRA (lo que la cadena nueva prohíbe); las altas, del settle de después.
2001
+ await this.applyBindingSweep(deletes, progress);
2002
+ }
2003
+ /**
2004
+ * Los bindings de roles LOCALES que cuelgan del subárbol de `movedKey` en el STORE (HIGHER), con la cadena de cada
2005
+ * scope del subárbol. El catálogo es el de una ESCRITURA (K4: en fresco), y un uuid que el store tiene y el catálogo no
2006
+ * conoce fuerza una recarga (`writeCatalogKnowing`); los roles se enumeran por el catálogo (el amarillo 6 —enumerar
2007
+ * desde el store— es de 2.6). `chain` es la cadena que el nodo TENDRÁ (barrido previo) o, sin ella, la del store.
2008
+ */
2009
+ async localBindingsBelow(movedKey, chain) {
2010
+ const catalog = await this.writeCatalog();
1441
2011
  const locals = catalog.localRoles();
2012
+ const subtree = new Map();
1442
2013
  if (locals.length === 0)
1443
- return;
2014
+ return { catalog, pairs: [], subtree };
1444
2015
  // La cadena del nodo movido y su subárbol se leen del STORE, no del
1445
2016
  // consumidor: el árbol que decide en `facts` es el del store, y con la
1446
2017
  // outbox el relay aplica esto MÁS TARDE, cuando el árbol del consumidor
1447
- // puede haber seguido moviéndose.
1448
- const chainOfMoved = await this.storeChain(movedKey);
1449
- /** `scopeKey` → claves de su cadena (él incluido), con el árbol de AHORA. */
1450
- const subtree = new Map([[movedKey, chainOfMoved]]);
2018
+ // puede haber seguido moviéndose. En el barrido PREVIO de F4 la arista
2019
+ // nueva aún no está escrita: la cadena es la que TENDRÁ (`chain`:
2020
+ // el hijo + la cadena del padre nuevo en el store), nunca la vieja.
2021
+ subtree.set(movedKey, chain ?? (await this.storeChain(movedKey)));
1451
2022
  const pending = [movedKey];
1452
2023
  while (pending.length) {
1453
2024
  const key = pending.pop();
1454
- const children = await this.readAllTuples({ user: factsScopeObject(key), relation: FACTS_PARENT_RELATION, object: `${FACTS_SCOPE_TYPE}:` }, { includeExpired: true });
2025
+ const children = await this.readForWrite({ user: factsScopeObject(key), relation: FACTS_PARENT_RELATION, object: `${FACTS_SCOPE_TYPE}:` }, { includeExpired: true });
1455
2026
  for (const edge of children) {
1456
2027
  const childKey = edge.object.slice(FACTS_SCOPE_TYPE.length + 1);
1457
2028
  if (subtree.has(childKey))
@@ -1460,21 +2031,28 @@ export class OpenFgaAuthorizationDriver {
1460
2031
  pending.push(childKey);
1461
2032
  }
1462
2033
  }
1463
- const writes = [];
1464
- const deletes = [];
2034
+ const pairs = [];
1465
2035
  for (const role of locals) {
1466
2036
  // Los bindings de ESTE rol, esté donde esté: `role_binding:…#role@role:<uuid>`.
1467
2037
  // El filtro lleva `user`, que es lo que el servidor real exige cuando el
1468
2038
  // objeto es solo un tipo (lección del 2c).
1469
- const bindings = await this.readAllTuples({ user: `${FACTS_ROLE_TYPE}:${role.uuid}`, relation: FACTS_ROLE_RELATION, object: `${FACTS_BINDING_TYPE}:` }, { includeExpired: true });
2039
+ const bindings = await this.readForWrite({ user: `${FACTS_ROLE_TYPE}:${role.uuid}`, relation: FACTS_ROLE_RELATION, object: `${FACTS_BINDING_TYPE}:` }, { includeExpired: true });
1470
2040
  for (const binding of this.parseBindings(FACTS_BINDING_TYPE, bindings.map((t) => t.object))) {
1471
- const chainKeys = subtree.get(scopeKey(binding.scope));
1472
- if (!chainKeys)
1473
- continue;
1474
- this.classifyBindingEdge(catalog, binding, chainKeys, writes, deletes);
2041
+ const key = scopeKey(binding.scope);
2042
+ if (subtree.has(key))
2043
+ pairs.push({ scope: key, role: binding.uuid });
1475
2044
  }
1476
2045
  }
1477
- await this.applyBindingSweep(writes, deletes);
2046
+ return { catalog, pairs, subtree };
2047
+ }
2048
+ /**
2049
+ * **El settle del subárbol de un cambio de árbol** (lote C · L13): los bindings de roles locales del subárbol de
2050
+ * `movedKey` (en el store, HIGHER, catálogo en fresco) pasan por `settleRoleVisibility`. Lo llaman `reparent` tras la
2051
+ * arista, su atajo idempotente y su `finally`.
2052
+ */
2053
+ async settleSubtree(movedKey, progress) {
2054
+ const { pairs } = await this.localBindingsBelow(movedKey);
2055
+ await this.settleRoleVisibility(pairs, progress);
1478
2056
  }
1479
2057
  /**
1480
2058
  * **El barrido por NIVEL** (3b-2g · R1; decisión del dueño del 2026-08-30
@@ -1486,46 +2064,29 @@ export class OpenFgaAuthorizationDriver {
1486
2064
  * —donde `declaredRoleAt` se evalúa en cada pregunta— y **sigue
1487
2065
  * concediendo** en `facts`, que es la divergencia R1 del lote 2e.
1488
2066
  *
1489
- * Se cierra igual que el owner: barriendo la arista `scope#binding` de los
1490
- * bindings de ESE rol con la regla única de visibilidad. Lo llama
2067
+ * Se cierra igual que el owner: dejando la arista `scope#binding` de los
2068
+ * bindings de ESE rol como dice la regla única de visibilidad. Lo llama
1491
2069
  * `projectCatalogRole`, que es el hook de «una escritura de catálogo cambió
1492
2070
  * este rol»: el manager lo dispara tras `defineScopedRole`/`updateScopedRole`
1493
2071
  * y un escritor «a mano» de `authz_*` tiene el mismo deber que ya tenía con
1494
2072
  * el espejo de permisos (sin él, en `facts` un rol recién definido no
1495
2073
  * concedería nada y quitarle un permiso seguiría concediéndolo).
1496
2074
  *
1497
- * Coste: una lectura (los bindings del rol) y, **solo si el rol es LOCAL y
1498
- * tiene bindings**, la cadena del store de cada scope distinto donde cuelga
1499
- * uno; un `Write` por lote si hay algo que barrer. Un rol sin bindings —el
1500
- * caso de todo `defineScopedRole`— son 0 escrituras.
2075
+ * **Desde el lote C pasa ENTERO por el settle** (L13; 🟠 1 de F4-carreras: este barrido leía `storeChain` y escribía
2076
+ * DESPUÉS, y un `moved` que barriera entre medias quedaba pisado — fail-OPEN persistente entre tenants). El catálogo es
2077
+ * el de una escritura (K4, hallazgo 4 del panel TREE): con un memo rancio un rol recién definido en otro proceso se
2078
+ * ignoraba. Un rol que el catálogo ya no declara —tras la recarga— no tiene visibilidad que decidir: sus aristas se BORRAN
2079
+ * («rol ausente ⇒ borra», K4); el resto de sus hechos son de `purgeRole`/`authz:reconcile`.
2080
+ *
2081
+ * Coste: una lectura (los bindings del rol) y un `batchCheck` (HIGHER) por vuelta del settle; un `Write` por lote solo
2082
+ * si hay algo que corregir. Un rol sin bindings —todo `defineScopedRole`— son 0 escrituras.
1501
2083
  */
1502
- async sweepRoleVisibility(roleUuid) {
1503
- const catalog = await this.catalog.view();
1504
- // Un rol que el catálogo ya no declara no tiene visibilidad que decidir:
1505
- // sus hechos son de `purgeRole` (que los borra enteros) y de
1506
- // `authz:reconcile`, no de este barrido.
1507
- const role = catalog.roleByUuid(roleUuid);
1508
- if (!role)
1509
- return;
1510
- const bindings = await this.readAllTuples({ user: `${FACTS_ROLE_TYPE}:${roleUuid}`, relation: FACTS_ROLE_RELATION, object: `${FACTS_BINDING_TYPE}:` }, { includeExpired: true });
2084
+ async sweepRoleVisibility(roleUuid, progress) {
2085
+ const bindings = await this.readForWrite({ user: `${FACTS_ROLE_TYPE}:${roleUuid}`, relation: FACTS_ROLE_RELATION, object: `${FACTS_BINDING_TYPE}:` }, { includeExpired: true });
1511
2086
  if (bindings.length === 0)
1512
2087
  return;
1513
- const writes = [];
1514
- const deletes = [];
1515
- /** `scopeKey` → su cadena en el árbol del STORE, una vez por scope. */
1516
- const chains = new Map();
1517
- for (const binding of this.parseBindings(FACTS_BINDING_TYPE, bindings.map((t) => t.object))) {
1518
- const key = scopeKey(binding.scope);
1519
- let chainKeys = chains.get(key);
1520
- if (!chainKeys) {
1521
- // La cadena solo decide para un rol LOCAL: la visibilidad de un
1522
- // global no depende del árbol, así que no se paga por leerlo.
1523
- chainKeys = role.owner === GLOBAL_OWNER_KEY ? [key] : await this.storeChain(key);
1524
- chains.set(key, chainKeys);
1525
- }
1526
- this.classifyBindingEdge(catalog, binding, chainKeys, writes, deletes);
1527
- }
1528
- await this.applyBindingSweep(writes, deletes);
2088
+ const pairs = this.parseBindings(FACTS_BINDING_TYPE, bindings.map((t) => t.object)).map((b) => ({ scope: scopeKey(b.scope), role: b.uuid }));
2089
+ await this.settleRoleVisibility(pairs, progress);
1529
2090
  }
1530
2091
  /**
1531
2092
  * La arista `scope#binding` de un binding, a escribir o a borrar según la
@@ -1534,7 +2095,7 @@ export class OpenFgaAuthorizationDriver {
1534
2095
  * NIVEL de ese scope (3b-2g · R1) y ser global o tener a su owner en la
1535
2096
  * cadena (3b-2e · E1). Visible ⇒ la arista se (re)escribe; no visible ⇒ se
1536
2097
  * borra. El `assignee` no se toca: la asignación existe igual, lo que
1537
- * cambia es dónde se la ve.
2098
+ * cambia es dónde se la ve. (Desde el lote C solo lo usa el barrido PREVIO, que solo borra.)
1538
2099
  */
1539
2100
  classifyBindingEdge(catalog, binding, chainKeys, writes, deletes) {
1540
2101
  const edge = factsScopeBindingTuple(scopeKey(binding.scope), binding.uuid);
@@ -1544,31 +2105,11 @@ export class OpenFgaAuthorizationDriver {
1544
2105
  deletes.push(edge);
1545
2106
  }
1546
2107
  /**
1547
- * Aplica el barrido en lotes ≤ 100 (el límite del `Write`).
1548
- *
1549
- * `Ignore` en las dos direcciones: el barrido dice el estado que DEBE
1550
- * quedar, no el delta — borrar lo que ya no está y reescribir lo que ya
1551
- * estaba son no-ops, no errores (invariante 6).
2108
+ * Aplica los BORRADOS de un barrido (lote C · L13: `applyBindingSweep` ya no da ALTAS; las altas de `scope#binding` de
2109
+ * rol local solo salen del settle). Borrar nunca concede, así que va directo, en lotes ≤ 100 con `Ignore`.
1552
2110
  */
1553
- async applyBindingSweep(writes, deletes) {
1554
- if (!writes.length && !deletes.length)
1555
- return;
1556
- const operations = [
1557
- ...deletes.map((tuple) => ({ delete: tuple })),
1558
- ...writes.map((tuple) => ({ write: tuple })),
1559
- ];
1560
- for (let i = 0; i < operations.length; i += PURGE_BATCH_SIZE) {
1561
- const chunk = operations.slice(i, i + PURGE_BATCH_SIZE);
1562
- await this.client.write({
1563
- writes: chunk.filter((o) => o.write).map((o) => o.write),
1564
- deletes: chunk.filter((o) => o.delete).map((o) => o.delete),
1565
- }, {
1566
- conflict: {
1567
- onDuplicateWrites: ClientWriteRequestOnDuplicateWrites.Ignore,
1568
- onMissingDeletes: ClientWriteRequestOnMissingDeletes.Ignore,
1569
- },
1570
- });
1571
- }
2111
+ async applyBindingSweep(deletes, progress = { writes: 0 }) {
2112
+ await this.writeBindingEdges([], deletes, progress);
1572
2113
  }
1573
2114
  /**
1574
2115
  * `[key, ...ancestros]` según el ÁRBOL DEL STORE (3b-2e · E1), subiendo por
@@ -1576,14 +2117,15 @@ export class OpenFgaAuthorizationDriver {
1576
2117
  * tiene que gobernar el barrido; el resolutor del consumidor no participa.
1577
2118
  * Un nodo con más de un padre es deriva y se dice (`assertOneParent`), y el
1578
2119
  * recorrido está acotado por el mismo tope de páginas que las
1579
- * enumeraciones: un ciclo escrito a mano no cuelga el proceso.
2120
+ * enumeraciones: un ciclo escrito a mano no cuelga el proceso. Alimenta
2121
+ * escrituras (anti-ciclos de `reparent`, barrido, caída del settle) ⇒ HIGHER (hallazgo 3 TREE).
1580
2122
  */
1581
2123
  async storeChain(key) {
1582
2124
  const chain = [key];
1583
2125
  const seen = new Set([key]);
1584
2126
  for (let hop = 0; hop < MAX_SCOPE_CHAIN_HOPS; hop++) {
1585
2127
  const object = factsScopeObject(chain[chain.length - 1]);
1586
- const parents = await this.readAllTuples({ object, relation: FACTS_PARENT_RELATION }, { includeExpired: true });
2128
+ const parents = await this.readForWrite({ object, relation: FACTS_PARENT_RELATION }, { includeExpired: true });
1587
2129
  this.assertOneParent(object, parents);
1588
2130
  if (parents.length === 0)
1589
2131
  return chain;
@@ -1614,6 +2156,7 @@ export class OpenFgaAuthorizationDriver {
1614
2156
  if (purged.type === APP_SCOPE_TYPE) {
1615
2157
  throw new InvalidIdentityError('purgeScope: la raíz `app` no se purga');
1616
2158
  }
2159
+ await this.assertModel('purgeScope');
1617
2160
  // La identidad canónica si el árbol aún lo conoce (K1); tal cual si ya no.
1618
2161
  const scope = await this.canonicalOrSelf(purged, 'purgeScope');
1619
2162
  const key = scopeKey(scope);
@@ -1630,15 +2173,35 @@ export class OpenFgaAuthorizationDriver {
1630
2173
  const objects = [...roles.map((r) => `role_binding:${key}|${r.uuid}`), scopeObject];
1631
2174
  /** Lo purgable de un objeto: todo, salvo la arista `parent` del scope. */
1632
2175
  const purgeable = async (object) => {
1633
- const keys = await this.readAllTuples({ object }, { includeExpired: true });
2176
+ const keys = await this.readForWrite({ object }, { includeExpired: true });
1634
2177
  return object === scopeObject ? keys.filter((k) => k.relation !== FACTS_PARENT_RELATION) : keys;
1635
2178
  };
1636
- /** Borra en lotes ≤ 100 (límite del `Write`), sin quejarse de lo que ya no está. */
1637
- const deleteKeys = async (keys) => {
2179
+ /**
2180
+ * Cierre 4 de D2 · ⚪ C3-4 (gemelo de `#partialIfDeleted` de relaciones): cuántos `Write` de borrado YA entraron. La
2181
+ * purga son varios `Write` (estructura, hechos por objeto, denies; lotes de 100): un fallo después del primero llega
2182
+ * con parte ya borrada y se marca `markPartialWrite` (el manager notifica `indeterminate: true`, invariante 13). Un
2183
+ * lote que falla no borró nada (FGA aplica cada `Write` entero).
2184
+ */
2185
+ const progress = { deleted: 0, unconfirmed: false };
2186
+ /**
2187
+ * Borra en lotes ≤ 100 (límite del `Write`), sin quejarse de lo que ya no está. Cierre 5 de D2 · ⚪ D4-5: el contador
2188
+ * suma lo que el lote BORRA DE VERDAD (`effective`, por defecto todo: las fases 2-4 borran lo que acaban de LEER), no
2189
+ * lo pedido — la fase 1 borra la estructura a CIEGAS con `Ignore` y hasta el cierre 5 contaba claves que no existían
2190
+ * (un fallo posterior salía `indeterminate: true` sin haber borrado nada).
2191
+ */
2192
+ const deleteKeys = async (keys, effective = () => true) => {
1638
2193
  for (let i = 0; i < keys.length; i += PURGE_BATCH_SIZE) {
1639
- await this.client.deleteTuples(keys.slice(i, i + PURGE_BATCH_SIZE), {
2194
+ const batch = keys.slice(i, i + PURGE_BATCH_SIZE);
2195
+ await this.client.deleteTuples(batch, {
1640
2196
  conflict: { onMissingDeletes: ClientWriteRequestOnMissingDeletes.Ignore },
1641
2197
  });
2198
+ progress.deleted += batch.filter(effective).length;
2199
+ // Cierre 6 de D2 · ⚪ D5-4: una clave pedida a ciegas que la lectura previa NO vio puede haberla escrito un
2200
+ // `grant` concurrente entre esa lectura y este borrado: entonces este lote SÍ borró algo que no se contó. Hasta
2201
+ // que la fase 2 termine (borra —y cuenta— el `assignee` de ese grant) el efecto es INCIERTO, y un fallo en ese
2202
+ // tramo sale marcado parcial: preferir sobre-notificar a sub-notificar.
2203
+ if (batch.some((k) => !effective(k)))
2204
+ progress.unconfirmed = true;
1642
2205
  }
1643
2206
  };
1644
2207
  /** Las dos aristas de (c2): estructura, no hechos (`#role` y `#binding`). */
@@ -1671,36 +2234,55 @@ export class OpenFgaAuthorizationDriver {
1671
2234
  // el residuo es justo lo que la demostración de cero reporta. La arista
1672
2235
  // `parent` sigue sin tocarse: la borra `scopes.detached` DESPUÉS de que
1673
2236
  // esta purga demuestre cero (S6, cruce 9).
1674
- await deleteKeys(roles.flatMap((r) => factsBindingTuples(key, r.uuid)));
1675
- for (const object of objects) {
1676
- if (object === scopeObject)
1677
- continue;
1678
- await deleteKeys((await purgeable(object)).filter((k) => !isStructure(k)));
1679
- }
1680
- // **Las aristas `#binding` de roles que el catálogo YA NO declara en este
1681
- // nivel también se purgan** (3b-8 · C2). El barrido no es atómico con el
1682
- // catálogo: un rol cuya fila se borró (o cambió de `scope_type`) entre el
1683
- // commit y esta purga dejaba una arista que el paso 1 no ve —solo mira
1684
- // `authz_roles WHERE scope_type = <este>`— y que la demostración de cero
1685
- // SÍ cuenta ⇒ `E_AUTHZ_PURGE_INCOMPLETE` en TODOS los intentos y
1686
- // `detached` bloqueado para siempre. Borrarla nunca concede (es la arista
1687
- // de visibilidad, y su rol ya no existe en este nivel), y la arista de un
1688
- // rol DEL catálogo que un `grant` concurrente reescribió no se toca
1689
- // (`known` la protege): esa sigue saliendo como residuo, que es lo
1690
- // correcto — se reintenta.
1691
- const known = new Set(roles.map((r) => `role_binding:${key}|${r.uuid}`));
1692
- await deleteKeys((await purgeable(scopeObject)).filter((k) => k.relation === FACTS_BINDING_RELATION && !known.has(k.user)));
1693
- await deleteKeys((await purgeable(scopeObject)).filter((k) => k.relation.startsWith(FACTS_DENIED_PREFIX)));
1694
- // Demostrar cero: lo que no se puede demostrar, se reporta.
1695
- const residue = [];
1696
- for (const object of objects) {
1697
- const left = await purgeable(object);
1698
- if (left.length)
1699
- residue.push(`${object} (${left.length})`);
1700
- }
1701
- if (residue.length) {
1702
- throw new PurgeIncompleteError(`purgeScope ${key}: quedan tuplas tras el borrado — ${residue.join('; ')}. ` +
1703
- `No confirmes el borrado del scope; reintenta la purga.`);
2237
+ // Los pasos (estructura → hechos → denies → demostrar cero), en una función LOCAL (no un método `#`: el driver
2238
+ // puede llegar envuelto en un Proxy y un campo privado exige el receptor real), para marcar el corte a medias.
2239
+ const steps = async () => {
2240
+ // ⚪ D4-5 (cierre 5 de D2): qué estructura EXISTE antes del borrado a ciegas — UNA lectura del scope (sus aristas
2241
+ // `#binding`). Solo la arista `scope#binding` hace visible (y por tanto observable) un rol aquí: un `role_binding#role`
2242
+ // sin su `#binding` (lo que deja el barrido de roles locales) no cambia ninguna respuesta al borrarse, así que no
2243
+ // cuenta como escritura hecha. Ventana declarada: una arista que un `grant` concurrente escribe ENTRE esta lectura y
2244
+ // el borrado se borra sin contarse; la fase 2 borra después su `assignee`, que sí cuenta, y HASTA que la fase 2
2245
+ // termina el error va marcado parcial igual (`unconfirmed`, ⚪ D5-4 del cierre 6: sobre-notificar, nunca sub-).
2246
+ const visible = new Set((await purgeable(scopeObject)).filter((k) => k.relation === FACTS_BINDING_RELATION).map((k) => k.user));
2247
+ await deleteKeys(roles.flatMap((r) => factsBindingTuples(key, r.uuid)), (k) => visible.has(k.relation === FACTS_BINDING_RELATION ? k.user : k.object));
2248
+ for (const object of objects) {
2249
+ if (object === scopeObject)
2250
+ continue;
2251
+ await deleteKeys((await purgeable(object)).filter((k) => !isStructure(k)));
2252
+ }
2253
+ // ⚪ D5-4: la fase 2 terminó — el `assignee` de un grant que se coló antes de la fase 1 ya se borró y se contó.
2254
+ progress.unconfirmed = false;
2255
+ // **Las aristas `#binding` de roles que el catálogo YA NO declara en este
2256
+ // nivel también se purgan** (3b-8 · C2). El barrido no es atómico con el
2257
+ // catálogo: un rol cuya fila se borró (o cambió de `scope_type`) entre el
2258
+ // commit y esta purga dejaba una arista que el paso 1 no ve —solo mira
2259
+ // `authz_roles WHERE scope_type = <este>`— y que la demostración de cero
2260
+ // SÍ cuenta ⇒ `E_AUTHZ_PURGE_INCOMPLETE` en TODOS los intentos y
2261
+ // `detached` bloqueado para siempre. Borrarla nunca concede (es la arista
2262
+ // de visibilidad, y su rol ya no existe en este nivel), y la arista de un
2263
+ // rol DEL catálogo que un `grant` concurrente reescribió no se toca
2264
+ // (`known` la protege): esa sigue saliendo como residuo, que es lo
2265
+ // correcto — se reintenta.
2266
+ const known = new Set(roles.map((r) => `role_binding:${key}|${r.uuid}`));
2267
+ await deleteKeys((await purgeable(scopeObject)).filter((k) => k.relation === FACTS_BINDING_RELATION && !known.has(k.user)));
2268
+ await deleteKeys((await purgeable(scopeObject)).filter((k) => k.relation.startsWith(FACTS_DENIED_PREFIX)));
2269
+ // Demostrar cero: lo que no se puede demostrar, se reporta.
2270
+ const residue = [];
2271
+ for (const object of objects) {
2272
+ const left = await purgeable(object);
2273
+ if (left.length)
2274
+ residue.push(`${object} (${left.length})`);
2275
+ }
2276
+ if (residue.length) {
2277
+ throw new PurgeIncompleteError(`purgeScope ${key}: quedan tuplas tras el borrado — ${residue.join('; ')}. ` +
2278
+ `No confirmes el borrado del scope; reintenta la purga.`);
2279
+ }
2280
+ };
2281
+ try {
2282
+ await steps();
2283
+ }
2284
+ catch (error) {
2285
+ throw (progress.deleted > 0 || progress.unconfirmed) && error !== null && typeof error === 'object' ? markPartialWrite(error) : error;
1704
2286
  }
1705
2287
  }
1706
2288
  /**
@@ -1744,7 +2326,7 @@ export class OpenFgaAuthorizationDriver {
1744
2326
  */
1745
2327
  async ensureFactsRoot() {
1746
2328
  const wanted = factsRootTuples(this.holderTypes);
1747
- const current = await this.readAllTuples({ object: factsScopeObject(APP_SCOPE_TYPE), relation: FACTS_ROOTED_RELATION }, { includeExpired: true });
2329
+ const current = await this.readForWrite({ object: factsScopeObject(APP_SCOPE_TYPE), relation: FACTS_ROOTED_RELATION }, { includeExpired: true });
1748
2330
  const have = new Set(current.map((tuple) => tuple.user));
1749
2331
  const missing = wanted.filter((tuple) => !have.has(tuple.user));
1750
2332
  if (missing.length)
@@ -1761,6 +2343,7 @@ export class OpenFgaAuthorizationDriver {
1761
2343
  * catálogo escribe CERO tuplas.
1762
2344
  */
1763
2345
  async projectCatalog(snapshot) {
2346
+ await this.assertModel('catalogProjection.project');
1764
2347
  await this.ensureFactsRoot();
1765
2348
  const wanted = new Map();
1766
2349
  for (const tuple of factsCatalogTuples(snapshot.roles, this.holderTypes)) {
@@ -1778,7 +2361,7 @@ export class OpenFgaAuthorizationDriver {
1778
2361
  // otro user sobre `role:` no es de esta proyección y no se toca.
1779
2362
  const current = new Map();
1780
2363
  for (const holderType of Object.values(this.holderTypes)) {
1781
- for (const key of await this.readAllTuples({ user: `${holderType}:*`, object: `${FACTS_ROLE_TYPE}:` }, { includeExpired: true })) {
2364
+ for (const key of await this.readForWrite({ user: `${holderType}:*`, object: `${FACTS_ROLE_TYPE}:` }, { includeExpired: true })) {
1782
2365
  // Solo la familia del catálogo: si el modelo crece con otras
1783
2366
  // relaciones sobre `role`, no son de esta proyección.
1784
2367
  if (!key.relation.startsWith(FACTS_PERMITS_PREFIX))
@@ -1825,14 +2408,24 @@ export class OpenFgaAuthorizationDriver {
1825
2408
  */
1826
2409
  async purgeRole(roleUuid) {
1827
2410
  assertCatalogUuid('rol', roleUuid);
2411
+ await this.assertModel('purgeRole');
1828
2412
  const existing = await this.sql('purgeRole.role', () => db.from('authz_roles').where('uuid', roleUuid).select('uuid'));
1829
2413
  if (!existing.length)
1830
2414
  throw new UnknownRoleError(roleUuid);
1831
2415
  const roleObject = `${FACTS_ROLE_TYPE}:${roleUuid}`;
1832
- /** Los objetos `role_binding` de este rol, por su arista `#role`. */
2416
+ /**
2417
+ * Los objetos `role_binding` de este rol, por su arista `#role` — **y todos los que ya se vieron** (lote C · hallazgo 2
2418
+ * del panel TREE, ORPHAN): el borrado se lleva la arista `#role`, así que la demostración de cero y el repaso que
2419
+ * enumeraban SOLO por ella no veían el `assignee` que un `grant` concurrente escribió en un binding ya enumerado
2420
+ * (estructura compartida: `Ignore` de duplicados) — la purga «demostraba» cero y ese hecho quedaba huérfano para
2421
+ * siempre. Con la unión, ese hecho sale como residuo (500 `E_AUTHZ_PURGE_INCOMPLETE`, se reintenta) o lo borra el repaso.
2422
+ */
2423
+ const seenBindings = new Set();
1833
2424
  const bindingsOf = async () => {
1834
- const edges = await this.readAllTuples({ user: roleObject, relation: FACTS_ROLE_RELATION, object: `${FACTS_BINDING_TYPE}:` }, { includeExpired: true });
1835
- return [...new Set(edges.map((edge) => edge.object))];
2425
+ const edges = await this.readForWrite({ user: roleObject, relation: FACTS_ROLE_RELATION, object: `${FACTS_BINDING_TYPE}:` }, { includeExpired: true });
2426
+ for (const edge of edges)
2427
+ seenBindings.add(edge.object);
2428
+ return [...seenBindings];
1836
2429
  };
1837
2430
  /** El scope del que cuelga un binding, según su propio id (`<scopeKey>|<roleUuid>`). */
1838
2431
  const scopeOf = (binding) => {
@@ -1840,37 +2433,63 @@ export class OpenFgaAuthorizationDriver {
1840
2433
  const separator = id.lastIndexOf('|');
1841
2434
  return separator > 0 ? id.slice(0, separator) : null;
1842
2435
  };
1843
- /** Todo lo purgable del rol: sus bindings enteros, las aristas del scope y su proyección. */
2436
+ /** Los HECHOS purgables del rol: sus bindings enteros y las aristas del scope (sin la proyección: ver `projection`). */
1844
2437
  const purgeable = async () => {
1845
2438
  const keys = [];
1846
2439
  for (const binding of await bindingsOf()) {
1847
- keys.push(...(await this.readAllTuples({ object: binding }, { includeExpired: true })));
2440
+ keys.push(...(await this.readForWrite({ object: binding }, { includeExpired: true })));
1848
2441
  const key = scopeOf(binding);
1849
2442
  if (!key)
1850
2443
  continue;
1851
2444
  // La arista vive en el objeto `scope`, así que se lee de ahí (y solo
1852
2445
  // la que apunta a ESTE binding: el scope tiene las de otros roles).
1853
- const scopeEdges = await this.readAllTuples({ user: binding, relation: FACTS_BINDING_RELATION, object: factsScopeObject(key) }, { includeExpired: true });
2446
+ const scopeEdges = await this.readForWrite({ user: binding, relation: FACTS_BINDING_RELATION, object: factsScopeObject(key) }, { includeExpired: true });
1854
2447
  keys.push(...scopeEdges);
1855
2448
  }
1856
- // La proyección del catálogo del rol: `role:<uuid>#permits_<P>@<holder>:*`.
2449
+ return keys;
2450
+ };
2451
+ /** La proyección del catálogo del rol: `role:<uuid>#permits_<P>@<holder>:*`. */
2452
+ const projection = async () => {
2453
+ const keys = [];
1857
2454
  for (const holderType of Object.values(this.holderTypes)) {
1858
- keys.push(...(await this.readAllTuples({ user: `${holderType}:*`, object: roleObject }, { includeExpired: true })));
2455
+ keys.push(...(await this.readForWrite({ user: `${holderType}:*`, object: roleObject }, { includeExpired: true })));
1859
2456
  }
1860
2457
  return keys;
1861
2458
  };
1862
- const keys = await purgeable();
1863
- for (let i = 0; i < keys.length; i += PURGE_BATCH_SIZE) {
1864
- await this.client.deleteTuples(keys.slice(i, i + PURGE_BATCH_SIZE), {
1865
- conflict: { onMissingDeletes: ClientWriteRequestOnMissingDeletes.Ignore },
1866
- });
1867
- }
1868
- const residue = await purgeable();
1869
- if (residue.length) {
1870
- throw new PurgeIncompleteError(`purgeRole ${roleUuid}: quedan ${residue.length} tuplas tras el borrado ` +
1871
- `(${residue.slice(0, 3).map((k) => `${k.user}#${k.relation}@${k.object}`).join('; ')}…). ` +
1872
- `El rol NO se borra del catálogo: reintenta la purga.`);
1873
- }
2459
+ /** Borrar y RELEER hasta cero, con cota; devuelve lo que sobrevive a la última relectura. */
2460
+ const deleteUntilZero = async (read) => {
2461
+ let residue = await read();
2462
+ for (let round = 0; residue.length > 0 && round < PURGE_ROLE_ROUNDS; round++) {
2463
+ for (let i = 0; i < residue.length; i += PURGE_BATCH_SIZE) {
2464
+ await this.client.deleteTuples(residue.slice(i, i + PURGE_BATCH_SIZE), {
2465
+ conflict: { onMissingDeletes: ClientWriteRequestOnMissingDeletes.Ignore },
2466
+ });
2467
+ }
2468
+ residue = await read();
2469
+ }
2470
+ return residue;
2471
+ };
2472
+ const incomplete = (residue, what) => new PurgeIncompleteError(`purgeRole ${roleUuid}: quedan ${residue.length} tuplas ${what} tras el borrado ` +
2473
+ `(${residue.slice(0, 3).map((k) => `${k.user}#${k.relation}@${k.object}`).join('; ')}…). ` +
2474
+ `El rol NO se borra del catálogo: reintenta la purga.`);
2475
+ // Lote C (hallazgo 2, ORPHAN): borrar y RELEER hasta cero, con cota. Un escritor concurrente (el settle de un `moved`
2476
+ // o de un `grant` que decidió con `role_binding#role` vivo) puede dejar una arista en un binding ya enumerado entre la
2477
+ // lectura y el borrado; la vuelta siguiente la ve (los bindings vistos se recuerdan) y la borra. Antes, una vuelta y la
2478
+ // demostración: ese residuo salía como `E_AUTHZ_PURGE_INCOMPLETE`, y el reintento ya NO lo veía (la arista `#role` se
2479
+ // había ido con la primera pasada): una `scope#binding` resucitada para siempre.
2480
+ //
2481
+ // **Asentar · cierre 1, desviación 2 = (b) (firma del dueño 2026-10-21): la proyección `permits_*` se borra DESPUÉS de
2482
+ // demostrar cero en los HECHOS.** Antes iban juntos, y una purga que fallaba (500 `E_AUTHZ_PURGE_INCOMPLETE` o un 503)
2483
+ // dejaba el rol en el catálogo SIN conceder lo que aún tenía asignado (CLOSED hasta el reintento; la propiedad lo
2484
+ // eximía con la atribución `purge-incomplete`, que tapaba síntomas reales del mutante `purgeRole.one-round`). Ahora
2485
+ // una purga que falla en los hechos deja el rol entero (lo que queda asignado sigue concediendo, reintentable); con
2486
+ // los hechos a cero ya no hay asignación a la que la proyección conceda, así que su borrado no cierra nada.
2487
+ const residue = await deleteUntilZero(purgeable);
2488
+ if (residue.length)
2489
+ throw incomplete(residue, 'de hechos');
2490
+ const projected = await deleteUntilZero(projection);
2491
+ if (projected.length)
2492
+ throw incomplete(projected, 'de la proyección permits_*');
1874
2493
  try {
1875
2494
  await this.sql('purgeRole', () => withAuthzCatalogWrite(async (trx) => {
1876
2495
  // `authz_assignments` es del driver `database` y aquí está vacía,
@@ -1886,6 +2505,25 @@ export class OpenFgaAuthorizationDriver {
1886
2505
  finally {
1887
2506
  this.catalog.invalidate();
1888
2507
  }
2508
+ // **Hallazgo 2 del panel TREE (ORPHAN), la pata Dekker de `purgeRole`**: un `grant` del rol que se coló entre la
2509
+ // demostración de cero y el DELETE SQL (leyó el catálogo con el rol vivo) dejó sus tuplas —incluida la arista
2510
+ // `scope#binding`, que ya ninguna regla gobierna— DESPUÉS de la purga. Con el rol ya fuera de la base, se repasa lo
2511
+ // purgable y se BORRA lo reaparecido. No lanza: la purga del rol ya ocurrió (el catálogo lo dice), y lo que un `grant`
2512
+ // —nuevo o RE-grant: desde el cierre 1 del asentar, sin mirar `created`— escriba después de ESTA lectura lo deshace él
2513
+ // mismo: su settle ve el rol ausente tras recargar y el `grant` borra sus tres tuplas. Un fallo de esta pasada (503) tampoco revierte nada: lo que quede no concede (no hay `permits_*` ni rol en
2514
+ // el catálogo) y lo recoge `authz:reconcile --prune`.
2515
+ try {
2516
+ const reappeared = [...(await purgeable()), ...(await projection())];
2517
+ for (let i = 0; i < reappeared.length; i += PURGE_BATCH_SIZE) {
2518
+ await this.client.deleteTuples(reappeared.slice(i, i + PURGE_BATCH_SIZE), {
2519
+ conflict: { onMissingDeletes: ClientWriteRequestOnMissingDeletes.Ignore },
2520
+ });
2521
+ }
2522
+ }
2523
+ catch (error) {
2524
+ this.warn(`authz(openfga): purgeRole ${roleUuid}: el repaso tras borrar el rol del catálogo falló (${String(error?.message ?? error)}); ` +
2525
+ 'lo que reapareció no concede y lo recoge authz:reconcile --prune.');
2526
+ }
1889
2527
  }
1890
2528
  /**
1891
2529
  * Cuántos hechos VIGENTES tiene cada rol, en todos los scopes (3b-2j).
@@ -1919,12 +2557,15 @@ export class OpenFgaAuthorizationDriver {
1919
2557
  async countRoleAssignments(roleUuids) {
1920
2558
  for (const uuid of roleUuids)
1921
2559
  assertCatalogUuid('rol', uuid);
2560
+ await this.assertModel('countRoleAssignments');
1922
2561
  const counts = [];
1923
2562
  for (const roleUuid of roleUuids) {
1924
2563
  const edges = await this.readAllTuples({ user: `${FACTS_ROLE_TYPE}:${roleUuid}`, relation: FACTS_ROLE_RELATION, object: `${FACTS_BINDING_TYPE}:` }, { includeExpired: true });
1925
2564
  let total = 0;
1926
2565
  for (const binding of new Set(edges.map((edge) => edge.object))) {
1927
- total += (await this.readAllTuples({ relation: FACTS_ASSIGNEE_RELATION, object: binding })).length;
2566
+ // Cierre de L11 (🟡 A2(a) / 🟠-2 del tester, clase 3b-2j): un `assignee` `foreign`/`unparseable` CUENTA —puede estar
2567
+ // vivo en el servidor y contarlo de menos es decir «no concede» sobre algo que sí, justo antes de un purgeRole—.
2568
+ total += (await this.readAllTuples({ relation: FACTS_ASSIGNEE_RELATION, object: binding }, { includeInvalid: true })).length;
1928
2569
  }
1929
2570
  counts.push(total);
1930
2571
  }
@@ -1949,8 +2590,25 @@ export class OpenFgaAuthorizationDriver {
1949
2590
  */
1950
2591
  async projectCatalogRole(roleUuid) {
1951
2592
  assertCatalogUuid('rol', roleUuid);
1952
- await this.sweepRoleVisibility(roleUuid);
1953
- const catalog = await this.catalog.view();
2593
+ await this.assertModel('projectCatalogRole');
2594
+ // Lote C · L13: la visibilidad pasa por el settle (punto de paso único); un fallo tras ≥ 1 `Write` sale PARCIAL.
2595
+ const progress = { writes: 0 };
2596
+ try {
2597
+ await this.sweepRoleVisibility(roleUuid, progress);
2598
+ await this.projectRolePermits(roleUuid);
2599
+ }
2600
+ catch (error) {
2601
+ throw progress.writes > 0 ? markPartialObject(error) : error;
2602
+ }
2603
+ }
2604
+ /**
2605
+ * La proyección `permits_<P>` de UN rol (la otra mitad de `projectCatalogRole`). **Hallazgo 4 del panel TREE (K4)**: el
2606
+ * catálogo es el de una ESCRITURA (`writeCatalog`, versión en fresco) — con un memo `{ everyMs }` que todavía no conoce
2607
+ * un rol recién definido en otro proceso, `rolePermissionsOf` devolvía `[]` y esto BORRABA sus `permits_*` (fail-closed
2608
+ * medido: `dave@C`). Mutante `project.memo-catalog`.
2609
+ */
2610
+ async projectRolePermits(roleUuid) {
2611
+ const catalog = await this.writeCatalogKnowing([roleUuid]);
1954
2612
  const permissions = [...catalog.rolePermissionsOf(roleUuid)].sort();
1955
2613
  const object = `${FACTS_ROLE_TYPE}:${roleUuid}`;
1956
2614
  const wanted = new Map();
@@ -1959,7 +2617,7 @@ export class OpenFgaAuthorizationDriver {
1959
2617
  }
1960
2618
  const current = new Map();
1961
2619
  for (const holderType of Object.values(this.holderTypes)) {
1962
- for (const key of await this.readAllTuples({ user: `${holderType}:*`, object }, { includeExpired: true })) {
2620
+ for (const key of await this.readForWrite({ user: `${holderType}:*`, object }, { includeExpired: true })) {
1963
2621
  if (!key.relation.startsWith(FACTS_PERMITS_PREFIX))
1964
2622
  continue;
1965
2623
  current.set(factsTupleId(key), key);
@@ -2019,7 +2677,9 @@ export class OpenFgaAuthorizationDriver {
2019
2677
  const prune = options.prune === true;
2020
2678
  const batchSize = reconcileBatchSize(options.batchSize);
2021
2679
  const maxTuples = reconcileMaxTuples(options.maxTuples);
2022
- const catalog = await this.catalog.view();
2680
+ await this.assertModel('reconcile');
2681
+ // K4 (lote C · carreras): la pasada que ESCRIBE decide con el catálogo contrastado en fresco.
2682
+ const catalog = await this.writeCatalog();
2023
2683
  const now = this.now();
2024
2684
  /** Lo que el destino DEBE tener, por id de tupla. */
2025
2685
  const wanted = new Map();
@@ -2170,6 +2830,8 @@ export class OpenFgaAuthorizationDriver {
2170
2830
  */
2171
2831
  const foreignTypes = new Map();
2172
2832
  let foreignTuples = 0;
2833
+ /** Cierre de L11: los denies que el origen quiere y el destino tiene con una condición ajena (niegan la pasada). */
2834
+ const blockedDenies = [];
2173
2835
  for (const tuple of current) {
2174
2836
  const id = factsTupleId(tuple);
2175
2837
  const family = familyOfTuple(tuple);
@@ -2179,6 +2841,25 @@ export class OpenFgaAuthorizationDriver {
2179
2841
  foreignTypes.set(type, (foreignTypes.get(type) ?? 0) + 1);
2180
2842
  continue;
2181
2843
  }
2844
+ // L11 · H8: una tupla del DESTINO con una condición que el paquete no escribe es PRESENTE e INTOCABLE: ni se
2845
+ // borra (tampoco con `--prune`) ni se reescribe encima lo que el origen quiere en esa clave; se lista y es deriva.
2846
+ if (tuple.invalid !== undefined) {
2847
+ const target = wanted.get(id);
2848
+ if (target)
2849
+ seen.add(id);
2850
+ note('tuple', 'invalid-condition', `${id} (${tuple.invalid})`);
2851
+ // Cierre de L11 (⚪ A2(b) del auditor; gemelo de relaciones, donde el deny bloqueado por una clave ajena del destino
2852
+ // NIEGA la pasada): un DENY que el origen quiere y el destino tiene con una condición que el paquete no escribe NO
2853
+ // queda «presente» — el servidor puede ignorarlo (una restricción sin condición, M-a(1)) y los grants que la
2854
+ // pasada escribe concederían. Ante la duda, lo que nunca concede de más: la pasada que escribe se NIEGA (abajo).
2855
+ // Cierre-2 (C1): en MANTENIMIENTO el origen ES este store, así que ese deny lo emitió él mismo (marcado): la pasada
2856
+ // no escribe hechos alrededor, negarla solo bloquearía lo DERIVADO (marcador, árbol, barrido `roleVisibility` del
2857
+ // invariante 18). Se deja intocable y se reporta (la nota de arriba), sin negar.
2858
+ if (target && !originIsSelf && target.tuple.relation.startsWith(FACTS_DENIED_PREFIX)) {
2859
+ blockedDenies.push(`${id} (${tuple.invalid})`);
2860
+ }
2861
+ continue;
2862
+ }
2182
2863
  if (tuple.relation === FACTS_ASSIGNEE_RELATION)
2183
2864
  storeBindings.add(tuple.object);
2184
2865
  if (tuple.relation === FACTS_PARENT_RELATION && tuple.object.startsWith(`${FACTS_SCOPE_TYPE}:`)) {
@@ -2237,6 +2918,13 @@ export class OpenFgaAuthorizationDriver {
2237
2918
  // que devuelve N filas y las N se descartan (caducadas, scopes que ya no
2238
2919
  // resuelven) sigue sin respaldar NADA de lo que --prune va a borrar.
2239
2920
  const massDelete = prune && factsDeleted > 0 && facts.usable === 0;
2921
+ if (blockedDenies.length > 0 && !dryRun) {
2922
+ throw new DenyMigrationRefusedError(`deny NO migrable: authz:reconcile --to=openfga se NIEGA — ${blockedDenies.length} deny(s) que el origen quiere y el ` +
2923
+ `DESTINO tiene con una condición que el paquete no escribe ni sabe leer (L11): ${blockedDenies.slice(0, 5).join('; ')}` +
2924
+ `${blockedDenies.length > 5 ? '; …' : ''}. El servidor puede ignorar ese deny (su restricción no admite condición) y ` +
2925
+ 'escribir los grants alrededor concedería lo que el origen niega. No se ha escrito nada. Receta: borra esa tupla del ' +
2926
+ 'destino a mano (SDK/CLI de OpenFGA) y repite; --dry-run la lista como invalid-condition.');
2927
+ }
2240
2928
  if (massDelete && !dryRun && options.allowMassDelete !== true) {
2241
2929
  throw new MassReconcileRefusedError(`authz:reconcile --to=openfga --prune borraría ${factsDeleted} tupla(s) de hechos del store y el ` +
2242
2930
  `ORIGEN no ha aportado NI UN hecho utilizable (${facts.rows} leídos, todos descartados — mira ` +
@@ -2247,10 +2935,27 @@ export class OpenFgaAuthorizationDriver {
2247
2935
  `--allow-mass-delete.`);
2248
2936
  }
2249
2937
  if (!dryRun) {
2938
+ // **Lote C · L13 (resolución 10 del juez): las ALTAS de `scope#binding` de un rol LOCAL pasan por el settle.** La
2939
+ // pasada las decide con una foto de toda la pasada (segundos o minutos) sin relectura, y el freeze no para las
2940
+ // escrituras en vuelo ni `manager.driver()`: escritas a ciegas, un `moved` que barriera entre medias quedaba
2941
+ // pisado. Se sacan de `writes` y, con el árbol y la estructura ya escritos, el settle las relee y las asienta con el
2942
+ // STORE. Las de roles globales (su visibilidad no depende del árbol) y todo lo demás van como siempre.
2943
+ const settlePairs = [];
2944
+ for (let i = writes.length - 1; i >= 0; i--) {
2945
+ const tuple = writes[i];
2946
+ if (tuple?.relation !== FACTS_BINDING_RELATION || !String(tuple.object).startsWith(`${FACTS_SCOPE_TYPE}:`))
2947
+ continue;
2948
+ const binding = parseBindingId(String(tuple.user).slice(FACTS_BINDING_TYPE.length + 1));
2949
+ if (!binding || catalog.roleByUuid(binding.uuid)?.owner === GLOBAL_OWNER_KEY)
2950
+ continue;
2951
+ settlePairs.push({ scope: scopeKey(binding.scope), role: binding.uuid });
2952
+ writes.splice(i, 1);
2953
+ }
2250
2954
  // Los DELETES primero: si la pasada muere entre medias, lo que queda es
2251
2955
  // de MENOS (fail-closed), nunca una tupla vieja conviviendo con la nueva.
2252
2956
  await this.applyReconcileWrites(deletes, []);
2253
2957
  await this.applyReconcileWrites([], writes);
2958
+ await this.settleRoleVisibility(settlePairs.reverse(), { writes: 0 });
2254
2959
  }
2255
2960
  const totals = sumPhases(phases);
2256
2961
  return {
@@ -2284,18 +2989,9 @@ export class OpenFgaAuthorizationDriver {
2284
2989
  * el modelo que sí sirve (fail-closed sobre un permiso vivo).
2285
2990
  */
2286
2991
  async modelPermissions() {
2287
- // `readAuthorizationModel()` sin argumentos usa el `authorizationModelId`
2288
- // con el que se construyó el cliente (el mismo de cada `Write`/`Check`).
2289
- const response = this.modelId
2290
- ? await this.client.readAuthorizationModel()
2291
- : await this.client.readLatestAuthorizationModel();
2292
- const model = response?.authorization_model;
2293
- if (!model?.type_definitions)
2294
- return null;
2295
- const role = model.type_definitions.find((definition) => definition.type === FACTS_ROLE_TYPE);
2296
- if (!role?.relations)
2297
- return null;
2298
- return new Set(Object.keys(role.relations).filter((name) => name.startsWith(FACTS_PERMITS_PREFIX)));
2992
+ // L11: la MISMA lectura (memorizada) que la puerta; `P_M` son las bases de `role#permits_*` del modelo FIJADO.
2993
+ const { permissions } = await this.modelGate();
2994
+ return new Set(permissions.map((permission) => `${FACTS_PERMITS_PREFIX}${permission}`));
2299
2995
  }
2300
2996
  /**
2301
2997
  * El árbol del ORIGEN, paginado (`scopes.enumerateEdges`), con los ciclos
@@ -2579,6 +3275,12 @@ export class OpenFgaAuthorizationDriver {
2579
3275
  ctx.discard('deny', 'unknown-permission', fact.detail);
2580
3276
  continue;
2581
3277
  }
3278
+ // Cierre-2 de L11 (C1): un deny MARCADO del origen se quiere igual (fail-closed: se escribe sin condición, niega);
3279
+ // si el origen es OTRO store, se declara aquí. En mantenimiento la clave es la misma tupla del destino y la cuenta
3280
+ // el bucle del destino (una sola nota por tupla).
3281
+ if (fact.invalidCondition !== undefined && source.factsOrigin?.self !== true) {
3282
+ ctx.note('deny', 'invalid-condition', `${fact.detail} (${fact.invalidCondition})`);
3283
+ }
2582
3284
  if (this.wantDeny(ctx, {
2583
3285
  holder: fact.holder,
2584
3286
  scope: fact.scope,
@@ -2722,6 +3424,9 @@ export class OpenFgaAuthorizationDriver {
2722
3424
  if (!Number.isFinite(limit) || limit < 1) {
2723
3425
  throw new AuthorizationConfigError(`enumerateFacts: limit debe ser un entero >= 1 (llegó ${String(page.limit)})`);
2724
3426
  }
3427
+ // L11: el ORIGEN de una migración pasa SOLO la parte `conditions` (tiene que poder rescatar un store con deriva de
3428
+ // reglas; la vigencia de lo que emite sí depende de la condición del modelo).
3429
+ await this.assertModel('enumerateFacts', 'conditions');
2725
3430
  const catalog = await this.catalog.view();
2726
3431
  const permissionByRelation = new Map();
2727
3432
  for (const slug of catalog.permissionSlugs) {
@@ -2766,12 +3471,19 @@ export class OpenFgaAuthorizationDriver {
2766
3471
  skipped.push({ kind: 'tuple', reason: 'unknown-holder-type', detail: id });
2767
3472
  continue;
2768
3473
  }
3474
+ // L11: una condición que el paquete no escribe o no sabe leer NO es «permanente»: no se migra (fail-closed: el
3475
+ // destino no concede lo que el origen quizá no concedía) y se cuenta.
3476
+ const condition = storedCondition(key.condition);
3477
+ if (condition.invalid !== undefined) {
3478
+ skipped.push({ kind: 'tuple', reason: 'invalid-condition', detail: `${id} (${condition.invalid})` });
3479
+ continue;
3480
+ }
2769
3481
  facts.push({
2770
3482
  kind: 'assignment',
2771
3483
  holder,
2772
3484
  scope: binding.scope,
2773
3485
  roleUuid: binding.uuid,
2774
- expiresAt: toExpiryDate(key.condition?.context?.valid_until),
3486
+ expiresAt: condition.validUntil,
2775
3487
  detail: id,
2776
3488
  });
2777
3489
  continue;
@@ -2794,7 +3506,18 @@ export class OpenFgaAuthorizationDriver {
2794
3506
  skipped.push({ kind: 'tuple', reason: 'unknown-holder-type', detail: id });
2795
3507
  continue;
2796
3508
  }
2797
- facts.push({ kind: 'deny', holder, scope, permission, detail: id });
3509
+ // Cierre-2 de L11 (C1 del auditor; gemelo del `assignee` de arriba): un deny con una condición que el paquete no
3510
+ // escribe se MARCA en vez de salir como deny limpio. No se salta (hacia `database` perderlo sería fail-OPEN): el
3511
+ // destino lo migra como deny y lo declara, y la pasada de MANTENIMIENTO lo deja donde está y lo reporta.
3512
+ const condition = storedCondition(key.condition);
3513
+ facts.push({
3514
+ kind: 'deny',
3515
+ holder,
3516
+ scope,
3517
+ permission,
3518
+ detail: id,
3519
+ ...(condition.invalid !== undefined ? { invalidCondition: condition.invalid } : {}),
3520
+ });
2798
3521
  continue;
2799
3522
  }
2800
3523
  // **El deny del modo `resolver` de 2.2 ES un hecho y se emite** (3b-8 ·
@@ -2853,7 +3576,7 @@ export class OpenFgaAuthorizationDriver {
2853
3576
  const seenTokens = new Set();
2854
3577
  let pages = 0;
2855
3578
  do {
2856
- const response = await this.client.read({}, { pageSize: READ_PAGE_SIZE, continuationToken, consistency: this.consistency });
3579
+ const response = await this.client.read({}, { pageSize: READ_PAGE_SIZE, continuationToken, consistency: WRITE_CONSISTENCY });
2857
3580
  pages += 1;
2858
3581
  for (const tuple of this.tuplesOrThrow(response, 'reconcile.readEveryTuple')) {
2859
3582
  const key = tuple?.key;
@@ -2863,12 +3586,7 @@ export class OpenFgaAuthorizationDriver {
2863
3586
  `no se puede comparar (total: ${this.diagnostics.unparseableBindings})`);
2864
3587
  continue;
2865
3588
  }
2866
- out.push({
2867
- user: key.user,
2868
- relation: key.relation,
2869
- object: key.object,
2870
- validUntil: toExpiryDate(key.condition?.context?.valid_until),
2871
- });
3589
+ out.push({ user: key.user, relation: key.relation, object: key.object, ...storedCondition(key.condition) });
2872
3590
  if (out.length > maxTuples) {
2873
3591
  throw new ReconcileTooLargeError(`authz:reconcile --to=openfga: el store pasa de maxTuples (${maxTuples}) tuplas y la pasada ` +
2874
3592
  `necesita la foto ENTERA del destino para saber qué sobra (que es la mitad del trabajo: ` +
@@ -2938,6 +3656,14 @@ export class OpenFgaAuthorizationDriver {
2938
3656
  }
2939
3657
  return tuples;
2940
3658
  }
3659
+ /**
3660
+ * **`readAllTuples` para una lectura que ALIMENTA UNA ESCRITURA** (lote C · carreras, resolución 8; hallazgo 3 TREE):
3661
+ * con `HIGHER_CONSISTENCY` siempre (`WRITE_CONSISTENCY`). Es el helper ÚNICO: la guardia de fuente de
3662
+ * `tests/visibility_settle.spec.ts` exige que los métodos que escriben lean por aquí (o por `checkForWrite`).
3663
+ */
3664
+ readForWrite(filter, options = {}) {
3665
+ return this.readAllTuples(filter, { ...options, consistency: WRITE_CONSISTENCY });
3666
+ }
2941
3667
  async readAllTuples(filter, options = {}) {
2942
3668
  // El instante con el que se filtra: el de la operación si lo trae (K9), o
2943
3669
  // el de esta lectura.
@@ -2950,7 +3676,7 @@ export class OpenFgaAuthorizationDriver {
2950
3676
  const response = await this.client.read(filter, {
2951
3677
  pageSize: READ_PAGE_SIZE,
2952
3678
  continuationToken,
2953
- consistency: this.consistency,
3679
+ consistency: options.consistency ?? this.consistency,
2954
3680
  });
2955
3681
  pages += 1;
2956
3682
  for (const tuple of this.tuplesOrThrow(response, `Read ${JSON.stringify(filter)}`)) {
@@ -2963,8 +3689,22 @@ export class OpenFgaAuthorizationDriver {
2963
3689
  continue;
2964
3690
  }
2965
3691
  if (!options.includeExpired) {
2966
- const validUntil = toExpiryDate(k.condition?.context?.valid_until);
2967
- if (validUntil && validUntil <= now)
3692
+ const condition = storedCondition(k.condition);
3693
+ // L11: una condición que el paquete no escribe o no sabe leer no es un hecho vigente que enumerar (sería
3694
+ // «permanente» por defecto): se avisa y se cuenta, como una tupla malformada. Las purgas leen con
3695
+ // `includeExpired` y la borran igual.
3696
+ // Cierre de L11 (criterio: ante la duda, lo que nunca concede de más): donde la tupla NIEGA o CUENTA
3697
+ // (`includeInvalid`: los denies de `listScopes`, el conteo de `countRoleAssignments`) se toma VIVA —un
3698
+ // `not_expired` con `current_time` congelado lo está en el servidor, H2—, con el mismo aviso.
3699
+ if (condition.invalid !== undefined) {
3700
+ this.diagnostics.unparseableBindings += 1;
3701
+ this.warn(`authz(openfga): tupla con una condición que el paquete no escribe en Read ${JSON.stringify(filter)} (${k.user}#${k.relation}@${k.object}: ${condition.invalid}); ${options.includeInvalid ? 'se cuenta VIVA (niega o cuenta: lo conservador)' : 'se ignora en la enumeración'} (total: ${this.diagnostics.unparseableBindings})`);
3702
+ if (!options.includeInvalid)
3703
+ continue;
3704
+ keys.push({ user: k.user, relation: k.relation, object: k.object });
3705
+ continue;
3706
+ }
3707
+ if (condition.validUntil && condition.validUntil <= now)
2968
3708
  continue;
2969
3709
  }
2970
3710
  keys.push({ user: k.user, relation: k.relation, object: k.object });
@@ -3088,6 +3828,16 @@ function foreignReport(tuples, types, relationTypes) {
3088
3828
  relations += count;
3089
3829
  return { tuples, types: byType, relations, unknown: tuples - relations };
3090
3830
  }
3831
+ /** Una tupla tal como está HOY en el store (con su caducidad). */
3832
+ /** La condición de una tupla leída (L11): el ÚNICO paso por `fgaTupleExpiry` de este driver. */
3833
+ function storedCondition(condition) {
3834
+ const expiry = fgaTupleExpiry(condition);
3835
+ if (expiry.kind === 'expiry')
3836
+ return { validUntil: expiry.at };
3837
+ if (expiry.kind === 'none')
3838
+ return { validUntil: null };
3839
+ return { validUntil: null, invalid: `${expiry.kind}: ${expiry.detail}` };
3840
+ }
3091
3841
  /** La clave sola: un `delete` de FGA no lleva condición. */
3092
3842
  function bareTuple(tuple) {
3093
3843
  return { user: tuple.user, relation: tuple.relation, object: tuple.object };
@@ -3129,4 +3879,3 @@ function walkUpTo(parentOf, from, target) {
3129
3879
  }
3130
3880
  return null;
3131
3881
  }
3132
- //# sourceMappingURL=openfga_driver.js.map