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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (277) hide show
  1. package/README.md +437 -48
  2. package/build/commands/authz_catalog_diff.d.ts +0 -1
  3. package/build/commands/authz_catalog_diff.js +0 -1
  4. package/build/commands/authz_catalog_prune_orphans.d.ts +17 -3
  5. package/build/commands/authz_catalog_prune_orphans.js +28 -6
  6. package/build/commands/authz_catalog_sync.d.ts +0 -1
  7. package/build/commands/authz_catalog_sync.js +0 -1
  8. package/build/commands/authz_freeze.d.ts +0 -1
  9. package/build/commands/authz_freeze.js +0 -1
  10. package/build/commands/authz_reconcile.d.ts +0 -1
  11. package/build/commands/authz_reconcile.js +0 -1
  12. package/build/commands/authz_relations_reconcile.d.ts +0 -1
  13. package/build/commands/authz_relations_reconcile.js +56 -8
  14. package/build/commands/authz_scopes_relay.d.ts +0 -1
  15. package/build/commands/authz_scopes_relay.js +54 -6
  16. package/build/commands/authz_scopes_requeue.d.ts +0 -1
  17. package/build/commands/authz_scopes_requeue.js +16 -3
  18. package/build/commands/authz_unfreeze.d.ts +0 -1
  19. package/build/commands/authz_unfreeze.js +0 -1
  20. package/build/commands/main.d.ts +0 -1
  21. package/build/commands/main.js +0 -1
  22. package/build/commands/openfga_provision.d.ts +10 -1
  23. package/build/commands/openfga_provision.js +43 -3
  24. package/build/configure.d.ts +0 -1
  25. package/build/configure.js +0 -1
  26. package/build/index.d.ts +9 -7
  27. package/build/index.js +10 -3
  28. package/build/providers/authz_provider.d.ts +0 -1
  29. package/build/providers/authz_provider.js +0 -1
  30. package/build/services/main.d.ts +0 -1
  31. package/build/services/main.js +0 -1
  32. package/build/services/relations.d.ts +0 -1
  33. package/build/services/relations.js +0 -1
  34. package/build/src/catalog/catalog.d.ts +0 -1
  35. package/build/src/catalog/catalog.js +0 -1
  36. package/build/src/catalog/catalog_cache.d.ts +13 -1
  37. package/build/src/catalog/catalog_cache.js +27 -2
  38. package/build/src/clock.d.ts +0 -1
  39. package/build/src/clock.js +0 -1
  40. package/build/src/define_config.d.ts +0 -1
  41. package/build/src/define_config.js +24 -1
  42. package/build/src/drivers/database_driver.d.ts +60 -3
  43. package/build/src/drivers/database_driver.js +160 -25
  44. package/build/src/drivers/database_relations_driver.d.ts +11 -5
  45. package/build/src/drivers/database_relations_driver.js +115 -33
  46. package/build/src/drivers/openfga_driver.d.ts +159 -26
  47. package/build/src/drivers/openfga_driver.js +882 -199
  48. package/build/src/drivers/openfga_facts.d.ts +91 -1
  49. package/build/src/drivers/openfga_facts.js +277 -3
  50. package/build/src/drivers/openfga_relations_driver.d.ts +41 -13
  51. package/build/src/drivers/openfga_relations_driver.js +422 -228
  52. package/build/src/errors.d.ts +160 -1
  53. package/build/src/errors.js +161 -1
  54. package/build/src/expiry.d.ts +51 -2
  55. package/build/src/expiry.js +96 -2
  56. package/build/src/freeze.d.ts +0 -1
  57. package/build/src/freeze.js +0 -1
  58. package/build/src/hierarchical_resolver.d.ts +0 -1
  59. package/build/src/hierarchical_resolver.js +0 -1
  60. package/build/src/http/app_access_middleware.d.ts +0 -1
  61. package/build/src/http/app_access_middleware.js +0 -1
  62. package/build/src/http/resource_access_middleware.d.ts +0 -1
  63. package/build/src/http/resource_access_middleware.js +0 -1
  64. package/build/src/identity.d.ts +9 -2
  65. package/build/src/identity.js +33 -1
  66. package/build/src/manager.d.ts +24 -1
  67. package/build/src/manager.js +381 -85
  68. package/build/src/memoize_ancestors.d.ts +0 -1
  69. package/build/src/memoize_ancestors.js +0 -1
  70. package/build/src/models/authz_assignment.d.ts +5 -6
  71. package/build/src/models/authz_assignment.js +0 -1
  72. package/build/src/models/authz_deny.d.ts +5 -6
  73. package/build/src/models/authz_deny.js +0 -1
  74. package/build/src/models/authz_permission.d.ts +5 -6
  75. package/build/src/models/authz_permission.js +0 -1
  76. package/build/src/models/authz_role.d.ts +5 -6
  77. package/build/src/models/authz_role.js +0 -1
  78. package/build/src/models/authz_role_permission.d.ts +5 -6
  79. package/build/src/models/authz_role_permission.js +0 -1
  80. package/build/src/openfga.d.ts +2 -3
  81. package/build/src/openfga.js +4 -2
  82. package/build/src/reconcile.d.ts +0 -1
  83. package/build/src/reconcile.js +0 -1
  84. package/build/src/relation_partition_trigger.d.ts +0 -1
  85. package/build/src/relation_partition_trigger.js +0 -1
  86. package/build/src/relations/check_many.d.ts +0 -1
  87. package/build/src/relations/check_many.js +0 -1
  88. package/build/src/relations/define_relations_config.d.ts +25 -3
  89. package/build/src/relations/define_relations_config.js +8 -3
  90. package/build/src/relations/deny_reach.d.ts +102 -0
  91. package/build/src/relations/deny_reach.js +230 -0
  92. package/build/src/relations/group_depth.d.ts +215 -0
  93. package/build/src/relations/group_depth.js +453 -0
  94. package/build/src/relations/manager.d.ts +14 -7
  95. package/build/src/relations/manager.js +60 -15
  96. package/build/src/relations/page.d.ts +19 -0
  97. package/build/src/relations/page.js +74 -0
  98. package/build/src/relations/reconcile.d.ts +58 -4
  99. package/build/src/relations/reconcile.js +266 -12
  100. package/build/src/relations/tuple_key.d.ts +0 -1
  101. package/build/src/relations/tuple_key.js +0 -1
  102. package/build/src/relations/tuple_order.d.ts +5 -4
  103. package/build/src/relations/tuple_order.js +5 -4
  104. package/build/src/relations_config_store.d.ts +57 -2
  105. package/build/src/relations_config_store.js +155 -5
  106. package/build/src/relations_reconcile.d.ts +0 -1
  107. package/build/src/relations_reconcile.js +0 -1
  108. package/build/src/scope_outbox.d.ts +36 -3
  109. package/build/src/scope_outbox.js +78 -18
  110. package/build/src/shared/backend_guard.d.ts +52 -1
  111. package/build/src/shared/backend_guard.js +164 -2
  112. package/build/src/shared/sql_expiry.d.ts +0 -1
  113. package/build/src/shared/sql_expiry.js +0 -1
  114. package/build/src/shared/transaction_guard.d.ts +0 -1
  115. package/build/src/shared/transaction_guard.js +0 -1
  116. package/build/src/sql_descendants.d.ts +0 -1
  117. package/build/src/sql_descendants.js +0 -1
  118. package/build/src/testing/contract.d.ts +26 -3
  119. package/build/src/testing/contract.js +258 -20
  120. package/build/src/testing/main.d.ts +0 -1
  121. package/build/src/testing/main.js +0 -1
  122. package/build/src/testing/migration_contract.d.ts +0 -1
  123. package/build/src/testing/migration_contract.js +0 -1
  124. package/build/src/testing/relations_contract.d.ts +7 -1
  125. package/build/src/testing/relations_contract.js +174 -7
  126. package/build/src/testing/relations_reconcile_contract.d.ts +0 -1
  127. package/build/src/testing/relations_reconcile_contract.js +0 -1
  128. package/build/src/testing/scope_outbox_contract.d.ts +0 -1
  129. package/build/src/testing/scope_outbox_contract.js +0 -1
  130. package/build/src/testing/scope_tree.d.ts +0 -1
  131. package/build/src/testing/scope_tree.js +0 -1
  132. package/build/src/testing/test_clock.d.ts +0 -1
  133. package/build/src/testing/test_clock.js +0 -1
  134. package/build/src/traits/authz_scopes.d.ts +0 -1
  135. package/build/src/traits/authz_scopes.js +0 -1
  136. package/build/src/traits/has_uuid.d.ts +5 -6
  137. package/build/src/traits/has_uuid.js +0 -1
  138. package/build/src/types.d.ts +138 -14
  139. package/build/src/types.js +6 -1
  140. package/build/stubs/config/authorization.stub +22 -6
  141. package/build/stubs/main.d.ts +0 -1
  142. package/build/stubs/main.js +0 -1
  143. package/package.json +4 -3
  144. package/build/commands/authz_catalog_diff.d.ts.map +0 -1
  145. package/build/commands/authz_catalog_diff.js.map +0 -1
  146. package/build/commands/authz_catalog_prune_orphans.d.ts.map +0 -1
  147. package/build/commands/authz_catalog_prune_orphans.js.map +0 -1
  148. package/build/commands/authz_catalog_sync.d.ts.map +0 -1
  149. package/build/commands/authz_catalog_sync.js.map +0 -1
  150. package/build/commands/authz_freeze.d.ts.map +0 -1
  151. package/build/commands/authz_freeze.js.map +0 -1
  152. package/build/commands/authz_reconcile.d.ts.map +0 -1
  153. package/build/commands/authz_reconcile.js.map +0 -1
  154. package/build/commands/authz_relations_reconcile.d.ts.map +0 -1
  155. package/build/commands/authz_relations_reconcile.js.map +0 -1
  156. package/build/commands/authz_scopes_relay.d.ts.map +0 -1
  157. package/build/commands/authz_scopes_relay.js.map +0 -1
  158. package/build/commands/authz_scopes_requeue.d.ts.map +0 -1
  159. package/build/commands/authz_scopes_requeue.js.map +0 -1
  160. package/build/commands/authz_unfreeze.d.ts.map +0 -1
  161. package/build/commands/authz_unfreeze.js.map +0 -1
  162. package/build/commands/main.d.ts.map +0 -1
  163. package/build/commands/main.js.map +0 -1
  164. package/build/commands/openfga_provision.d.ts.map +0 -1
  165. package/build/commands/openfga_provision.js.map +0 -1
  166. package/build/configure.d.ts.map +0 -1
  167. package/build/configure.js.map +0 -1
  168. package/build/index.d.ts.map +0 -1
  169. package/build/index.js.map +0 -1
  170. package/build/providers/authz_provider.d.ts.map +0 -1
  171. package/build/providers/authz_provider.js.map +0 -1
  172. package/build/services/main.d.ts.map +0 -1
  173. package/build/services/main.js.map +0 -1
  174. package/build/services/relations.d.ts.map +0 -1
  175. package/build/services/relations.js.map +0 -1
  176. package/build/src/catalog/catalog.d.ts.map +0 -1
  177. package/build/src/catalog/catalog.js.map +0 -1
  178. package/build/src/catalog/catalog_cache.d.ts.map +0 -1
  179. package/build/src/catalog/catalog_cache.js.map +0 -1
  180. package/build/src/clock.d.ts.map +0 -1
  181. package/build/src/clock.js.map +0 -1
  182. package/build/src/define_config.d.ts.map +0 -1
  183. package/build/src/define_config.js.map +0 -1
  184. package/build/src/drivers/database_driver.d.ts.map +0 -1
  185. package/build/src/drivers/database_driver.js.map +0 -1
  186. package/build/src/drivers/database_relations_driver.d.ts.map +0 -1
  187. package/build/src/drivers/database_relations_driver.js.map +0 -1
  188. package/build/src/drivers/openfga_driver.d.ts.map +0 -1
  189. package/build/src/drivers/openfga_driver.js.map +0 -1
  190. package/build/src/drivers/openfga_facts.d.ts.map +0 -1
  191. package/build/src/drivers/openfga_facts.js.map +0 -1
  192. package/build/src/drivers/openfga_relations_driver.d.ts.map +0 -1
  193. package/build/src/drivers/openfga_relations_driver.js.map +0 -1
  194. package/build/src/errors.d.ts.map +0 -1
  195. package/build/src/errors.js.map +0 -1
  196. package/build/src/expiry.d.ts.map +0 -1
  197. package/build/src/expiry.js.map +0 -1
  198. package/build/src/freeze.d.ts.map +0 -1
  199. package/build/src/freeze.js.map +0 -1
  200. package/build/src/hierarchical_resolver.d.ts.map +0 -1
  201. package/build/src/hierarchical_resolver.js.map +0 -1
  202. package/build/src/http/app_access_middleware.d.ts.map +0 -1
  203. package/build/src/http/app_access_middleware.js.map +0 -1
  204. package/build/src/http/resource_access_middleware.d.ts.map +0 -1
  205. package/build/src/http/resource_access_middleware.js.map +0 -1
  206. package/build/src/identity.d.ts.map +0 -1
  207. package/build/src/identity.js.map +0 -1
  208. package/build/src/manager.d.ts.map +0 -1
  209. package/build/src/manager.js.map +0 -1
  210. package/build/src/memoize_ancestors.d.ts.map +0 -1
  211. package/build/src/memoize_ancestors.js.map +0 -1
  212. package/build/src/models/authz_assignment.d.ts.map +0 -1
  213. package/build/src/models/authz_assignment.js.map +0 -1
  214. package/build/src/models/authz_deny.d.ts.map +0 -1
  215. package/build/src/models/authz_deny.js.map +0 -1
  216. package/build/src/models/authz_permission.d.ts.map +0 -1
  217. package/build/src/models/authz_permission.js.map +0 -1
  218. package/build/src/models/authz_role.d.ts.map +0 -1
  219. package/build/src/models/authz_role.js.map +0 -1
  220. package/build/src/models/authz_role_permission.d.ts.map +0 -1
  221. package/build/src/models/authz_role_permission.js.map +0 -1
  222. package/build/src/openfga.d.ts.map +0 -1
  223. package/build/src/openfga.js.map +0 -1
  224. package/build/src/reconcile.d.ts.map +0 -1
  225. package/build/src/reconcile.js.map +0 -1
  226. package/build/src/relation_partition_trigger.d.ts.map +0 -1
  227. package/build/src/relation_partition_trigger.js.map +0 -1
  228. package/build/src/relations/check_many.d.ts.map +0 -1
  229. package/build/src/relations/check_many.js.map +0 -1
  230. package/build/src/relations/define_relations_config.d.ts.map +0 -1
  231. package/build/src/relations/define_relations_config.js.map +0 -1
  232. package/build/src/relations/manager.d.ts.map +0 -1
  233. package/build/src/relations/manager.js.map +0 -1
  234. package/build/src/relations/reconcile.d.ts.map +0 -1
  235. package/build/src/relations/reconcile.js.map +0 -1
  236. package/build/src/relations/tuple_key.d.ts.map +0 -1
  237. package/build/src/relations/tuple_key.js.map +0 -1
  238. package/build/src/relations/tuple_order.d.ts.map +0 -1
  239. package/build/src/relations/tuple_order.js.map +0 -1
  240. package/build/src/relations_config_store.d.ts.map +0 -1
  241. package/build/src/relations_config_store.js.map +0 -1
  242. package/build/src/relations_reconcile.d.ts.map +0 -1
  243. package/build/src/relations_reconcile.js.map +0 -1
  244. package/build/src/scope_outbox.d.ts.map +0 -1
  245. package/build/src/scope_outbox.js.map +0 -1
  246. package/build/src/shared/backend_guard.d.ts.map +0 -1
  247. package/build/src/shared/backend_guard.js.map +0 -1
  248. package/build/src/shared/sql_expiry.d.ts.map +0 -1
  249. package/build/src/shared/sql_expiry.js.map +0 -1
  250. package/build/src/shared/transaction_guard.d.ts.map +0 -1
  251. package/build/src/shared/transaction_guard.js.map +0 -1
  252. package/build/src/sql_descendants.d.ts.map +0 -1
  253. package/build/src/sql_descendants.js.map +0 -1
  254. package/build/src/testing/contract.d.ts.map +0 -1
  255. package/build/src/testing/contract.js.map +0 -1
  256. package/build/src/testing/main.d.ts.map +0 -1
  257. package/build/src/testing/main.js.map +0 -1
  258. package/build/src/testing/migration_contract.d.ts.map +0 -1
  259. package/build/src/testing/migration_contract.js.map +0 -1
  260. package/build/src/testing/relations_contract.d.ts.map +0 -1
  261. package/build/src/testing/relations_contract.js.map +0 -1
  262. package/build/src/testing/relations_reconcile_contract.d.ts.map +0 -1
  263. package/build/src/testing/relations_reconcile_contract.js.map +0 -1
  264. package/build/src/testing/scope_outbox_contract.d.ts.map +0 -1
  265. package/build/src/testing/scope_outbox_contract.js.map +0 -1
  266. package/build/src/testing/scope_tree.d.ts.map +0 -1
  267. package/build/src/testing/scope_tree.js.map +0 -1
  268. package/build/src/testing/test_clock.d.ts.map +0 -1
  269. package/build/src/testing/test_clock.js.map +0 -1
  270. package/build/src/traits/authz_scopes.d.ts.map +0 -1
  271. package/build/src/traits/authz_scopes.js.map +0 -1
  272. package/build/src/traits/has_uuid.d.ts.map +0 -1
  273. package/build/src/traits/has_uuid.js.map +0 -1
  274. package/build/src/types.d.ts.map +0 -1
  275. package/build/src/types.js.map +0 -1
  276. package/build/stubs/main.d.ts.map +0 -1
  277. package/build/stubs/main.js.map +0 -1
@@ -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, markPartialWrite, } 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, isServerWriteLimit, 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
  }
@@ -270,6 +276,8 @@ function serverWriteLimitError(operation, cause) {
270
276
  * ahí el error del SDK es la información más útil y no rompe ninguna
271
277
  * abstracción.
272
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']);
273
281
  function guardBackendErrors(client, timeoutMs) {
274
282
  return new Proxy(client, {
275
283
  get(target, prop, receiver) {
@@ -278,11 +286,15 @@ function guardBackendErrors(client, timeoutMs) {
278
286
  return value;
279
287
  return (...args) => {
280
288
  const operation = String(prop);
281
- const fail = (cause) => isTimeoutLike(cause)
282
- ? new AuthorizationBackendTimeoutError('openfga', operation, timeoutMs, cause)
283
- : isServerWriteLimit(cause)
284
- ? serverWriteLimitError(operation, cause)
285
- : 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
+ };
286
298
  try {
287
299
  const result = value.apply(target, args);
288
300
  if (!(result instanceof Promise))
@@ -353,6 +365,55 @@ const TREE_WRITE_ATTEMPTS = 3;
353
365
  const PURGE_BATCH_SIZE = 100;
354
366
  /** Tamaño de página de `Read` (máximo del servidor). */
355
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
+ }
356
417
  /**
357
418
  * El deny del modo `resolver` (≤ 2.2), que el modelo de hoy ni declara pero
358
419
  * un store de aquella versión sí tiene: `enumerateFacts` lo EMITE como deny
@@ -421,6 +482,9 @@ export class OpenFgaAuthorizationDriver {
421
482
  // `database` para lo que tenga que ir en su transacción, o
422
483
  // `requireTransactionalWrites` para fallar al arrancar).
423
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,
424
488
  });
425
489
  }
426
490
  client;
@@ -442,6 +506,12 @@ export class OpenFgaAuthorizationDriver {
442
506
  * con el que este driver escribe y responde, no el último del store.
443
507
  */
444
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 = {};
445
515
  consistency;
446
516
  /** Reloj de pared del driver (J1): el ÚNICO `now` de checks, filtros y re-grant. */
447
517
  now;
@@ -455,8 +525,13 @@ export class OpenFgaAuthorizationDriver {
455
525
  */
456
526
  catalog;
457
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);
458
530
  assertHolderTypes(options.holderTypes);
459
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);
460
535
  if (options.now !== undefined && !isClock(options.now)) {
461
536
  throw new AuthorizationConfigError(`OpenFgaAuthorizationDriver: 'now' debe ser una función () => Date (llegó ${typeof options.now})`);
462
537
  }
@@ -543,6 +618,53 @@ export class OpenFgaAuthorizationDriver {
543
618
  checkContext() {
544
619
  return checkContext(this.now());
545
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
+ }
546
668
  /**
547
669
  * Consulta al catálogo local clasificando su fallo. Con este driver el
548
670
  * catálogo SQL sigue siendo una dependencia dura de cada pregunta: su caída
@@ -573,6 +695,257 @@ export class OpenFgaAuthorizationDriver {
573
695
  // Antes la fase de roles y `hasRole` lo colapsaban en `false`.
574
696
  return alignBatchResults('openfga', 'batchCheck', withIds, response.result);
575
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
+ }
576
949
  // ── Catálogo local (compartido entre drivers), desde el memo (2A) ──────
577
950
  // Una carga por driver/proceso en vez de una o dos consultas SQL por
578
951
  // pregunta, más una revalidación por operación (2D · F1). Un fallo de
@@ -596,6 +969,7 @@ export class OpenFgaAuthorizationDriver {
596
969
  // ── Contrato ──────────────────────────────────────────────────────────
597
970
  async authorize(subject, permission, scope) {
598
971
  assertIdentity({ subject, permission, scope });
972
+ await this.assertModel('authorize');
599
973
  // Una foto del catálogo por pregunta: el permiso sale de la misma versión
600
974
  // (y se paga una sola revalidación). Es lo ÚNICO local que queda.
601
975
  const catalog = await this.catalog.view();
@@ -653,6 +1027,7 @@ export class OpenFgaAuthorizationDriver {
653
1027
  assertIdentity({ scope });
654
1028
  if (scopes.length === 0)
655
1029
  return [];
1030
+ await this.assertModel('authorizeMany');
656
1031
  const catalog = await this.catalog.view();
657
1032
  const perm = catalog.permission(permission);
658
1033
  if (!perm)
@@ -712,13 +1087,15 @@ export class OpenFgaAuthorizationDriver {
712
1087
  async grant(subject, role, scope, options = {}) {
713
1088
  this.rejectTransaction('grant', options);
714
1089
  assertIdentity({ subject, role, scope, expiresAt: options.expiresAt });
1090
+ await this.assertModel('grant');
715
1091
  // El binding lleva la identidad canónica del árbol (K1), nunca la forma
716
1092
  // del llamante, y el uuid del rol, nunca su slug (3A · A1). El rol tiene
717
1093
  // que EXISTIR en ese scope (3B · B2: global, o local a un ancestro-o-igual)
718
1094
  // con una composición legal (B5).
719
1095
  const chain = await this.knownScope(scope, 'grant');
720
1096
  const [target] = chain;
721
- 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();
722
1099
  const declared = resolveRoleQuery(catalog, role, target, chainKeysFrom(chain)[0]);
723
1100
  assertRoleAssignableAt(catalog, declared);
724
1101
  const roleUuid = declared.uuid;
@@ -727,6 +1104,64 @@ export class OpenFgaAuthorizationDriver {
727
1104
  relation: 'assignee',
728
1105
  object: `role_binding:${scopeKey(target)}|${roleUuid}`,
729
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 }) {
730
1165
  // **La forma nueva de los objetos (3b-2c), en UNA sola escritura
731
1166
  // (3b-2f · R3).** En (c2) la asignación no es alcanzable con el
732
1167
  // `assignee` a secas: el binding tiene que colgar del scope
@@ -740,7 +1175,6 @@ export class OpenFgaAuthorizationDriver {
740
1175
  // mismo hecho, que es peor que perder la escritura. Ahora las TRES van
741
1176
  // en el mismo `Write` (transaccional en FGA): o están las tres o no está
742
1177
  // ninguna.
743
- const structure = factsBindingTuples(scopeKey(target), roleUuid);
744
1178
  /** El write COMPLETO de una asignación: su estructura y ella. */
745
1179
  const writeSet = (tuple) => [...structure, tuple];
746
1180
  const tupleFor = (expiresAt) => expiresAt
@@ -769,14 +1203,20 @@ export class OpenFgaAuthorizationDriver {
769
1203
  if (options.expiresAt === undefined)
770
1204
  throw withPreserveRecipe(current.error);
771
1205
  const expiresAt = options.expiresAt;
1206
+ progress.attempted += 1;
772
1207
  const existed = await this.writeAssignment(key, tupleFor(expiresAt), structure);
773
1208
  return { existed, expiresAt };
774
1209
  }
775
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);
776
1215
  const expiresAt = resolveGrantExpiry(current.validUntil, options.expiresAt, this.now());
777
1216
  if (sameInstant(current.validUntil, expiresAt)) {
778
1217
  return { existed: true, previousExpiresAt: current.validUntil, expiresAt };
779
1218
  }
1219
+ progress.attempted += 1;
780
1220
  await this.replaceAssignment(key, tupleFor(expiresAt), structure);
781
1221
  return { existed: true, previousExpiresAt: current.validUntil, expiresAt };
782
1222
  }
@@ -793,7 +1233,8 @@ export class OpenFgaAuthorizationDriver {
793
1233
  // a la vez: la asignación sigue sin existir, así que se repite el
794
1234
  // MISMO write ignorando duplicados (las aristas no llevan condición,
795
1235
  // así que un duplicado suyo es idéntico por construcción). Sigue
796
- // 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).
797
1238
  //
798
1239
  // Cuál de los dos fue lo dice la RELECTURA, no el error: el conflicto
799
1240
  // transaccional de FGA (`Aborted`, HTTP 409) no nombra ninguna tupla.
@@ -809,6 +1250,7 @@ export class OpenFgaAuthorizationDriver {
809
1250
  const expiresAt = options.expiresAt ?? null;
810
1251
  for (let attempt = 0;; attempt++) {
811
1252
  try {
1253
+ progress.attempted += 1;
812
1254
  await this.client.writeTuples(writeSet(tupleFor(expiresAt)), attempt === 0 ? undefined : IGNORE_DUPLICATE_WRITES);
813
1255
  return { existed: false, expiresAt };
814
1256
  }
@@ -821,8 +1263,11 @@ export class OpenFgaAuthorizationDriver {
821
1263
  }
822
1264
  const raced = await this.readAssignment(key);
823
1265
  if (raced.kind === 'present') {
1266
+ if (raced.invalid !== undefined)
1267
+ throw RenewalUnsafeError.foreignCondition('grant', `${key.object}#${key.relation}@${key.user}`, raced.invalid);
824
1268
  const target = resolveGrantExpiry(raced.validUntil, options.expiresAt, this.now());
825
1269
  if (!sameInstant(raced.validUntil, target)) {
1270
+ progress.attempted += 1;
826
1271
  await this.replaceAssignment(key, tupleFor(target), structure);
827
1272
  }
828
1273
  return { existed: true, previousExpiresAt: raced.validUntil, expiresAt: target };
@@ -832,6 +1277,7 @@ export class OpenFgaAuthorizationDriver {
832
1277
  // preservar, y el error de lectura va como causa.
833
1278
  if (options.expiresAt === undefined)
834
1279
  throw withPreserveRecipe(raced.error);
1280
+ progress.attempted += 1;
835
1281
  const existed = await this.writeAssignment(key, tupleFor(options.expiresAt), structure);
836
1282
  return { existed, expiresAt: options.expiresAt };
837
1283
  }
@@ -897,15 +1343,15 @@ export class OpenFgaAuthorizationDriver {
897
1343
  */
898
1344
  async readAssignment(key) {
899
1345
  try {
900
- 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 });
901
1348
  // ⚪ N5 (cierre 2 del lote B): un 200 sin `tuples` no es «ausente», es
902
1349
  // «no se sabe» (503 de `tuplesOrThrow` como causa): sin objetivo explícito
903
1350
  // `grant` no escribe a ciegas sobre una caducidad que no ha podido ver.
904
1351
  const tuple = this.tuplesOrThrow(response, 'readAssignment')[0];
905
1352
  if (!tuple)
906
1353
  return { kind: 'absent' };
907
- const validUntil = tuple.key?.condition?.context?.valid_until;
908
- return { kind: 'present', validUntil: toExpiryDate(validUntil) };
1354
+ return { kind: 'present', ...storedCondition(tuple.key?.condition) };
909
1355
  }
910
1356
  catch (error) {
911
1357
  return { kind: 'unknown', error };
@@ -914,10 +1360,14 @@ export class OpenFgaAuthorizationDriver {
914
1360
  async revoke(subject, role, scope, options) {
915
1361
  this.rejectTransaction('revoke', options);
916
1362
  assertIdentity({ subject, role, scope });
1363
+ await this.assertModel('revoke');
917
1364
  // Rol fuera del catálogo para ese nivel ⇒ 422, como en `grant` (D10). Se
918
1365
  // quitan los bindings de TODOS los roles con ese nombre en el scope
919
1366
  // exacto (3B): a lo sumo uno es visible ahí; quitar nunca concede.
920
- 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);
921
1371
  // Sin cadena, TODAS las ortografías de las que el uuid puede ser alias
922
1372
  // (3b-8 · A4): con una sola, el alias hacía del delete un no-op
923
1373
  // silencioso (`onMissingDeletes: Ignore`) y el hecho canónico seguía ahí.
@@ -927,6 +1377,7 @@ export class OpenFgaAuthorizationDriver {
927
1377
  }
928
1378
  async hasRole(subject, role, scope) {
929
1379
  assertIdentity({ subject, role, scope });
1380
+ await this.assertModel('hasRole');
930
1381
  const user = this.fgaSubject(subject);
931
1382
  const chain = await this.chain(scope, 'hasRole');
932
1383
  if (!chain)
@@ -953,6 +1404,7 @@ export class OpenFgaAuthorizationDriver {
953
1404
  async deny(subject, permission, scope, options) {
954
1405
  this.rejectTransaction('deny', options);
955
1406
  assertIdentity({ subject, permission, scope });
1407
+ await this.assertModel('deny');
956
1408
  const perm = await this.findPermission(permission);
957
1409
  if (!perm)
958
1410
  throw new UnknownPermissionError(permission);
@@ -964,6 +1416,7 @@ export class OpenFgaAuthorizationDriver {
964
1416
  async removeDeny(subject, permission, scope, options) {
965
1417
  this.rejectTransaction('removeDeny', options);
966
1418
  assertIdentity({ subject, permission, scope });
1419
+ await this.assertModel('removeDeny');
967
1420
  const perm = await this.findPermission(permission);
968
1421
  if (!perm)
969
1422
  throw new UnknownPermissionError(permission);
@@ -996,6 +1449,7 @@ export class OpenFgaAuthorizationDriver {
996
1449
  */
997
1450
  async listSubjects(role, scope) {
998
1451
  assertIdentity({ role, scope });
1452
+ await this.assertModel('listSubjects');
999
1453
  // Un rol que el catálogo no declara para ese nivel (en ningún owner) no
1000
1454
  // tiene holders (D5): nada que leer, ni árbol ni store. Un scope que el
1001
1455
  // árbol no conoce no existe para el motor (D8, K1): nada; uno que conoce
@@ -1059,7 +1513,10 @@ export class OpenFgaAuthorizationDriver {
1059
1513
  user: this.fgaSubject(subject),
1060
1514
  relation: factsRelationsOf(slug).denied,
1061
1515
  object: `${FACTS_SCOPE_TYPE}:`,
1062
- }, { 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 });
1063
1520
  return new Set(tuples.map((t) => t.object.slice(FACTS_SCOPE_TYPE.length + 1)));
1064
1521
  }
1065
1522
  /**
@@ -1115,6 +1572,7 @@ export class OpenFgaAuthorizationDriver {
1115
1572
  }
1116
1573
  async listRoles(subject, scope) {
1117
1574
  assertIdentity({ subject, scope });
1575
+ await this.assertModel('listRoles');
1118
1576
  // Un scope que el árbol no conoce no existe para el motor (D8): nada.
1119
1577
  const chain = await this.chain(scope, 'listRoles');
1120
1578
  if (!chain)
@@ -1144,6 +1602,7 @@ export class OpenFgaAuthorizationDriver {
1144
1602
  assertScope(scope);
1145
1603
  if (chain.length === 0)
1146
1604
  return [];
1605
+ await this.assertModel('rolesInChain');
1147
1606
  const catalog = await this.catalog.view();
1148
1607
  const keysFrom = chainKeysFrom(chain);
1149
1608
  const wanted = new Map(chain.map((s, i) => [scopeKey(s), { scope: s, keys: keysFrom[i] }]));
@@ -1169,6 +1628,7 @@ export class OpenFgaAuthorizationDriver {
1169
1628
  }
1170
1629
  async listRoleScopes(subject, scopeType) {
1171
1630
  assertIdentity({ subject, scopeType });
1631
+ await this.assertModel('listRoleScopes');
1172
1632
  const catalog = await this.catalog.view();
1173
1633
  const byScope = new Map();
1174
1634
  for (const binding of await this.listBindings(subject)) {
@@ -1195,6 +1655,7 @@ export class OpenFgaAuthorizationDriver {
1195
1655
  }
1196
1656
  async listScopes(subject, permission) {
1197
1657
  assertIdentity({ subject, permission });
1658
+ await this.assertModel('listScopes');
1198
1659
  const catalog = await this.catalog.view();
1199
1660
  const perm = catalog.permission(permission);
1200
1661
  if (!perm)
@@ -1232,6 +1693,7 @@ export class OpenFgaAuthorizationDriver {
1232
1693
  */
1233
1694
  async listDenies(subject, scope) {
1234
1695
  assertIdentity(scope ? { subject, scope } : { subject });
1696
+ await this.assertModel('listDenies');
1235
1697
  const chain = scope ? await this.chain(scope, 'listDenies') : null;
1236
1698
  if (scope && !chain)
1237
1699
  return [];
@@ -1282,8 +1744,12 @@ export class OpenFgaAuthorizationDriver {
1282
1744
  * borrar una tupla inexistente—, la cadena del padre NUEVO en el store
1283
1745
  * (3b-8 · A5: el anti-ciclos del consumidor no ve un store
1284
1746
  * desincronizado) y **UN solo `Write`** con el delete del padre viejo y el
1285
- * write del nuevo, que es atómico dentro de la request. O(profundidad)
1286
- * 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`).
1287
1753
  */
1288
1754
  async onScopeMoved(child, newParent) {
1289
1755
  await this.reparent(child, newParent, 'scopes.moved');
@@ -1353,9 +1819,10 @@ export class OpenFgaAuthorizationDriver {
1353
1819
  if (child.type === APP_SCOPE_TYPE) {
1354
1820
  throw new InvalidIdentityError('scopes.detached: la raíz `app` no se puede borrar ni purgar');
1355
1821
  }
1822
+ await this.assertModel('scopes.detached');
1356
1823
  const scope = await this.canonicalOrSelf(child, 'scopes.detached');
1357
1824
  const object = factsScopeObject(scopeKey(scope));
1358
- 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 });
1359
1826
  // Aquí NO se exige un solo padre: el nodo se va entero y llevarse las dos
1360
1827
  // aristas de una deriva es lo correcto (a diferencia de `moved`, donde
1361
1828
  // habría que elegir cuál sobrevive).
@@ -1387,58 +1854,115 @@ export class OpenFgaAuthorizationDriver {
1387
1854
  * impide esa carrera es el ESCRITOR ÚNICO del relay (`ScopeOutbox.acquire`).
1388
1855
  */
1389
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);
1390
1861
  const { childKey, parentKey } = await this.assertEdge(child, parent, operation);
1391
1862
  const wanted = factsParentTuple(childKey, parentKey);
1392
- for (let attempt = 0;; attempt++) {
1393
- const current = await this.readAllTuples({ object: wanted.object, relation: FACTS_PARENT_RELATION }, { includeExpired: true });
1394
- this.assertOneParent(wanted.object, current);
1395
- if (current.length === 1 && current[0].user === wanted.user) {
1396
- // **El atajo idempotente TAMBIÉN barre** (3b-8 · A3). El relay
1397
- // reintenta: un `moved` cuyo primer intento escribió la arista y
1398
- // murió ANTES del barrido vuelve a entregarse, cae aquí, y hasta
1399
- // 3b-8 el `return` a secas se saltaba `sweepLocalRoleBindings` — el
1400
- // rol local del tenant A seguía concediendo en el subárbol del B
1401
- // para siempre, con el relay en verde (invariante 18 roto por
1402
- // COMPOSICIÓN de dos piezas correctas: la idempotencia y el barrido).
1403
- // Coste: cero requests sin roles locales, como el propio barrido.
1404
- 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();
1405
1947
  return;
1406
1948
  }
1407
- // **El ciclo se mira también en el árbol DEL STORE** (3b-8 · A5).
1408
- // `assertEdge` valida contra la cadena del CONSUMIDOR, pero quien
1409
- // decide en `facts` es el store, y una outbox aplicada en desorden (un
1410
- // `moved` aparcado + el move inverso posterior) lo deja desincronizado:
1411
- // la arista nueva —legal para el consumidor— puede cerrar un ciclo
1412
- // REAL, que FGA acepta y evalúa en los dos sentidos (cruce 3:
1413
- // fail-open mudo). `storeChain` del barrido solo lanzaba DESPUÉS de
1414
- // escribir y no deshacía nada. Se sube por la cadena del PADRE en el
1415
- // store antes del `Write`; el coste es O(profundidad) lecturas por
1416
- // cambio de árbol, fuera del camino caliente.
1417
- if ((await this.storeChain(parentKey)).includes(childKey)) {
1418
- throw new ScopeCycleError(`${operation}: en el árbol del STORE ${factsScopeObject(parentKey)} desciende de ${childKey} ` +
1419
- `(o es él mismo); escribir la arista cerraría un ciclo real que FGA evalúa en los dos ` +
1420
- `sentidos. El árbol del store está desincronizado del consumidor (una outbox aplicada en ` +
1421
- `desorden, una entrada aparcada): reconstrúyelo con authz:reconcile.`);
1422
- }
1423
- try {
1424
- await this.client.write({ writes: [wanted], deletes: current });
1425
- }
1426
- catch (error) {
1427
- if (!isTreeWriteRace(error))
1428
- throw error;
1429
- if (attempt >= TREE_WRITE_ATTEMPTS - 1) {
1430
- throw new WriteConflictError(`${operation}: ${TREE_WRITE_ATTEMPTS} intentos y el árbol del store sigue en conflicto sobre ` +
1431
- `${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);
1432
1958
  }
1433
- continue;
1434
1959
  }
1435
- await this.sweepLocalRoleBindings(childKey);
1436
- return;
1960
+ throw progress.writes > 0 && final !== null && typeof final === 'object' ? markPartialWrite(final) : final;
1437
1961
  }
1438
1962
  }
1439
1963
  /**
1440
- * **El barrido del rol local** (3b-2e · E1; decisión del dueño del
1441
- * 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).
1442
1966
  *
1443
1967
  * En (c2) el modelo no tiene `owner`, así que `authorize` NO vuelve a
1444
1968
  * decidir con el árbol de hoy si un rol LOCAL sigue siendo visible: un
@@ -1448,11 +1972,12 @@ export class OpenFgaAuthorizationDriver {
1448
1972
  * un **fail-open** — y encima uno que solo se ve comparando drivers.
1449
1973
  *
1450
1974
  * Lo que se toca es la arista `scope#binding`, que es lo que hace
1451
- * ALCANZABLE la asignación: se BORRA donde el owner del rol ya no está en
1452
- * la cadena y se REESCRIBE donde vuelve a estarlo (invariante 18: volver la
1453
- * unit a su sitio restaura). No se toca el `assignee` —el hecho de la
1454
- * asignación no cambia porque el árbol se mueva, igual que en `database`—
1455
- * 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.
1456
1981
  *
1457
1982
  * **Por subárbol, no por nodo** (consecuencia 2): los descendientes del
1458
1983
  * nodo movido también cambian de cadena.
@@ -1461,24 +1986,43 @@ export class OpenFgaAuthorizationDriver {
1461
1986
  * que no usa delegación— son **cero** requests y `moved` sigue siendo el
1462
1987
  * `Read` + `Write` del cruce 8. Con roles locales: una lectura por rol local
1463
1988
  * (sus bindings, por `role_binding#role`), la bajada del subárbol y un
1464
- * `Write` por lote.
1989
+ * `Write` por lote (el coste O(roles locales) por `moved` es el 🟡 6, declarado hasta 2.6).
1465
1990
  */
1466
- async sweepLocalRoleBindings(movedKey) {
1467
- 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();
1468
2011
  const locals = catalog.localRoles();
2012
+ const subtree = new Map();
1469
2013
  if (locals.length === 0)
1470
- return;
2014
+ return { catalog, pairs: [], subtree };
1471
2015
  // La cadena del nodo movido y su subárbol se leen del STORE, no del
1472
2016
  // consumidor: el árbol que decide en `facts` es el del store, y con la
1473
2017
  // outbox el relay aplica esto MÁS TARDE, cuando el árbol del consumidor
1474
- // puede haber seguido moviéndose.
1475
- const chainOfMoved = await this.storeChain(movedKey);
1476
- /** `scopeKey` → claves de su cadena (él incluido), con el árbol de AHORA. */
1477
- 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)));
1478
2022
  const pending = [movedKey];
1479
2023
  while (pending.length) {
1480
2024
  const key = pending.pop();
1481
- 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 });
1482
2026
  for (const edge of children) {
1483
2027
  const childKey = edge.object.slice(FACTS_SCOPE_TYPE.length + 1);
1484
2028
  if (subtree.has(childKey))
@@ -1487,21 +2031,28 @@ export class OpenFgaAuthorizationDriver {
1487
2031
  pending.push(childKey);
1488
2032
  }
1489
2033
  }
1490
- const writes = [];
1491
- const deletes = [];
2034
+ const pairs = [];
1492
2035
  for (const role of locals) {
1493
2036
  // Los bindings de ESTE rol, esté donde esté: `role_binding:…#role@role:<uuid>`.
1494
2037
  // El filtro lleva `user`, que es lo que el servidor real exige cuando el
1495
2038
  // objeto es solo un tipo (lección del 2c).
1496
- 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 });
1497
2040
  for (const binding of this.parseBindings(FACTS_BINDING_TYPE, bindings.map((t) => t.object))) {
1498
- const chainKeys = subtree.get(scopeKey(binding.scope));
1499
- if (!chainKeys)
1500
- continue;
1501
- 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 });
1502
2044
  }
1503
2045
  }
1504
- 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);
1505
2056
  }
1506
2057
  /**
1507
2058
  * **El barrido por NIVEL** (3b-2g · R1; decisión del dueño del 2026-08-30
@@ -1513,46 +2064,29 @@ export class OpenFgaAuthorizationDriver {
1513
2064
  * —donde `declaredRoleAt` se evalúa en cada pregunta— y **sigue
1514
2065
  * concediendo** en `facts`, que es la divergencia R1 del lote 2e.
1515
2066
  *
1516
- * Se cierra igual que el owner: barriendo la arista `scope#binding` de los
1517
- * 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
1518
2069
  * `projectCatalogRole`, que es el hook de «una escritura de catálogo cambió
1519
2070
  * este rol»: el manager lo dispara tras `defineScopedRole`/`updateScopedRole`
1520
2071
  * y un escritor «a mano» de `authz_*` tiene el mismo deber que ya tenía con
1521
2072
  * el espejo de permisos (sin él, en `facts` un rol recién definido no
1522
2073
  * concedería nada y quitarle un permiso seguiría concediéndolo).
1523
2074
  *
1524
- * Coste: una lectura (los bindings del rol) y, **solo si el rol es LOCAL y
1525
- * tiene bindings**, la cadena del store de cada scope distinto donde cuelga
1526
- * uno; un `Write` por lote si hay algo que barrer. Un rol sin bindings —el
1527
- * 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.
1528
2083
  */
1529
- async sweepRoleVisibility(roleUuid) {
1530
- const catalog = await this.catalog.view();
1531
- // Un rol que el catálogo ya no declara no tiene visibilidad que decidir:
1532
- // sus hechos son de `purgeRole` (que los borra enteros) y de
1533
- // `authz:reconcile`, no de este barrido.
1534
- const role = catalog.roleByUuid(roleUuid);
1535
- if (!role)
1536
- return;
1537
- 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 });
1538
2086
  if (bindings.length === 0)
1539
2087
  return;
1540
- const writes = [];
1541
- const deletes = [];
1542
- /** `scopeKey` → su cadena en el árbol del STORE, una vez por scope. */
1543
- const chains = new Map();
1544
- for (const binding of this.parseBindings(FACTS_BINDING_TYPE, bindings.map((t) => t.object))) {
1545
- const key = scopeKey(binding.scope);
1546
- let chainKeys = chains.get(key);
1547
- if (!chainKeys) {
1548
- // La cadena solo decide para un rol LOCAL: la visibilidad de un
1549
- // global no depende del árbol, así que no se paga por leerlo.
1550
- chainKeys = role.owner === GLOBAL_OWNER_KEY ? [key] : await this.storeChain(key);
1551
- chains.set(key, chainKeys);
1552
- }
1553
- this.classifyBindingEdge(catalog, binding, chainKeys, writes, deletes);
1554
- }
1555
- 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);
1556
2090
  }
1557
2091
  /**
1558
2092
  * La arista `scope#binding` de un binding, a escribir o a borrar según la
@@ -1561,7 +2095,7 @@ export class OpenFgaAuthorizationDriver {
1561
2095
  * NIVEL de ese scope (3b-2g · R1) y ser global o tener a su owner en la
1562
2096
  * cadena (3b-2e · E1). Visible ⇒ la arista se (re)escribe; no visible ⇒ se
1563
2097
  * borra. El `assignee` no se toca: la asignación existe igual, lo que
1564
- * 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.)
1565
2099
  */
1566
2100
  classifyBindingEdge(catalog, binding, chainKeys, writes, deletes) {
1567
2101
  const edge = factsScopeBindingTuple(scopeKey(binding.scope), binding.uuid);
@@ -1571,31 +2105,11 @@ export class OpenFgaAuthorizationDriver {
1571
2105
  deletes.push(edge);
1572
2106
  }
1573
2107
  /**
1574
- * Aplica el barrido en lotes ≤ 100 (el límite del `Write`).
1575
- *
1576
- * `Ignore` en las dos direcciones: el barrido dice el estado que DEBE
1577
- * quedar, no el delta — borrar lo que ya no está y reescribir lo que ya
1578
- * 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`.
1579
2110
  */
1580
- async applyBindingSweep(writes, deletes) {
1581
- if (!writes.length && !deletes.length)
1582
- return;
1583
- const operations = [
1584
- ...deletes.map((tuple) => ({ delete: tuple })),
1585
- ...writes.map((tuple) => ({ write: tuple })),
1586
- ];
1587
- for (let i = 0; i < operations.length; i += PURGE_BATCH_SIZE) {
1588
- const chunk = operations.slice(i, i + PURGE_BATCH_SIZE);
1589
- await this.client.write({
1590
- writes: chunk.filter((o) => o.write).map((o) => o.write),
1591
- deletes: chunk.filter((o) => o.delete).map((o) => o.delete),
1592
- }, {
1593
- conflict: {
1594
- onDuplicateWrites: ClientWriteRequestOnDuplicateWrites.Ignore,
1595
- onMissingDeletes: ClientWriteRequestOnMissingDeletes.Ignore,
1596
- },
1597
- });
1598
- }
2111
+ async applyBindingSweep(deletes, progress = { writes: 0 }) {
2112
+ await this.writeBindingEdges([], deletes, progress);
1599
2113
  }
1600
2114
  /**
1601
2115
  * `[key, ...ancestros]` según el ÁRBOL DEL STORE (3b-2e · E1), subiendo por
@@ -1603,14 +2117,15 @@ export class OpenFgaAuthorizationDriver {
1603
2117
  * tiene que gobernar el barrido; el resolutor del consumidor no participa.
1604
2118
  * Un nodo con más de un padre es deriva y se dice (`assertOneParent`), y el
1605
2119
  * recorrido está acotado por el mismo tope de páginas que las
1606
- * 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).
1607
2122
  */
1608
2123
  async storeChain(key) {
1609
2124
  const chain = [key];
1610
2125
  const seen = new Set([key]);
1611
2126
  for (let hop = 0; hop < MAX_SCOPE_CHAIN_HOPS; hop++) {
1612
2127
  const object = factsScopeObject(chain[chain.length - 1]);
1613
- 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 });
1614
2129
  this.assertOneParent(object, parents);
1615
2130
  if (parents.length === 0)
1616
2131
  return chain;
@@ -1641,6 +2156,7 @@ export class OpenFgaAuthorizationDriver {
1641
2156
  if (purged.type === APP_SCOPE_TYPE) {
1642
2157
  throw new InvalidIdentityError('purgeScope: la raíz `app` no se purga');
1643
2158
  }
2159
+ await this.assertModel('purgeScope');
1644
2160
  // La identidad canónica si el árbol aún lo conoce (K1); tal cual si ya no.
1645
2161
  const scope = await this.canonicalOrSelf(purged, 'purgeScope');
1646
2162
  const key = scopeKey(scope);
@@ -1657,7 +2173,7 @@ export class OpenFgaAuthorizationDriver {
1657
2173
  const objects = [...roles.map((r) => `role_binding:${key}|${r.uuid}`), scopeObject];
1658
2174
  /** Lo purgable de un objeto: todo, salvo la arista `parent` del scope. */
1659
2175
  const purgeable = async (object) => {
1660
- const keys = await this.readAllTuples({ object }, { includeExpired: true });
2176
+ const keys = await this.readForWrite({ object }, { includeExpired: true });
1661
2177
  return object === scopeObject ? keys.filter((k) => k.relation !== FACTS_PARENT_RELATION) : keys;
1662
2178
  };
1663
2179
  /**
@@ -1810,7 +2326,7 @@ export class OpenFgaAuthorizationDriver {
1810
2326
  */
1811
2327
  async ensureFactsRoot() {
1812
2328
  const wanted = factsRootTuples(this.holderTypes);
1813
- 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 });
1814
2330
  const have = new Set(current.map((tuple) => tuple.user));
1815
2331
  const missing = wanted.filter((tuple) => !have.has(tuple.user));
1816
2332
  if (missing.length)
@@ -1827,6 +2343,7 @@ export class OpenFgaAuthorizationDriver {
1827
2343
  * catálogo escribe CERO tuplas.
1828
2344
  */
1829
2345
  async projectCatalog(snapshot) {
2346
+ await this.assertModel('catalogProjection.project');
1830
2347
  await this.ensureFactsRoot();
1831
2348
  const wanted = new Map();
1832
2349
  for (const tuple of factsCatalogTuples(snapshot.roles, this.holderTypes)) {
@@ -1844,7 +2361,7 @@ export class OpenFgaAuthorizationDriver {
1844
2361
  // otro user sobre `role:` no es de esta proyección y no se toca.
1845
2362
  const current = new Map();
1846
2363
  for (const holderType of Object.values(this.holderTypes)) {
1847
- 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 })) {
1848
2365
  // Solo la familia del catálogo: si el modelo crece con otras
1849
2366
  // relaciones sobre `role`, no son de esta proyección.
1850
2367
  if (!key.relation.startsWith(FACTS_PERMITS_PREFIX))
@@ -1891,14 +2408,24 @@ export class OpenFgaAuthorizationDriver {
1891
2408
  */
1892
2409
  async purgeRole(roleUuid) {
1893
2410
  assertCatalogUuid('rol', roleUuid);
2411
+ await this.assertModel('purgeRole');
1894
2412
  const existing = await this.sql('purgeRole.role', () => db.from('authz_roles').where('uuid', roleUuid).select('uuid'));
1895
2413
  if (!existing.length)
1896
2414
  throw new UnknownRoleError(roleUuid);
1897
2415
  const roleObject = `${FACTS_ROLE_TYPE}:${roleUuid}`;
1898
- /** 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();
1899
2424
  const bindingsOf = async () => {
1900
- const edges = await this.readAllTuples({ user: roleObject, relation: FACTS_ROLE_RELATION, object: `${FACTS_BINDING_TYPE}:` }, { includeExpired: true });
1901
- 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];
1902
2429
  };
1903
2430
  /** El scope del que cuelga un binding, según su propio id (`<scopeKey>|<roleUuid>`). */
1904
2431
  const scopeOf = (binding) => {
@@ -1906,37 +2433,63 @@ export class OpenFgaAuthorizationDriver {
1906
2433
  const separator = id.lastIndexOf('|');
1907
2434
  return separator > 0 ? id.slice(0, separator) : null;
1908
2435
  };
1909
- /** 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`). */
1910
2437
  const purgeable = async () => {
1911
2438
  const keys = [];
1912
2439
  for (const binding of await bindingsOf()) {
1913
- keys.push(...(await this.readAllTuples({ object: binding }, { includeExpired: true })));
2440
+ keys.push(...(await this.readForWrite({ object: binding }, { includeExpired: true })));
1914
2441
  const key = scopeOf(binding);
1915
2442
  if (!key)
1916
2443
  continue;
1917
2444
  // La arista vive en el objeto `scope`, así que se lee de ahí (y solo
1918
2445
  // la que apunta a ESTE binding: el scope tiene las de otros roles).
1919
- 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 });
1920
2447
  keys.push(...scopeEdges);
1921
2448
  }
1922
- // 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 = [];
1923
2454
  for (const holderType of Object.values(this.holderTypes)) {
1924
- keys.push(...(await this.readAllTuples({ user: `${holderType}:*`, object: roleObject }, { includeExpired: true })));
2455
+ keys.push(...(await this.readForWrite({ user: `${holderType}:*`, object: roleObject }, { includeExpired: true })));
1925
2456
  }
1926
2457
  return keys;
1927
2458
  };
1928
- const keys = await purgeable();
1929
- for (let i = 0; i < keys.length; i += PURGE_BATCH_SIZE) {
1930
- await this.client.deleteTuples(keys.slice(i, i + PURGE_BATCH_SIZE), {
1931
- conflict: { onMissingDeletes: ClientWriteRequestOnMissingDeletes.Ignore },
1932
- });
1933
- }
1934
- const residue = await purgeable();
1935
- if (residue.length) {
1936
- throw new PurgeIncompleteError(`purgeRole ${roleUuid}: quedan ${residue.length} tuplas tras el borrado ` +
1937
- `(${residue.slice(0, 3).map((k) => `${k.user}#${k.relation}@${k.object}`).join('; ')}…). ` +
1938
- `El rol NO se borra del catálogo: reintenta la purga.`);
1939
- }
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_*');
1940
2493
  try {
1941
2494
  await this.sql('purgeRole', () => withAuthzCatalogWrite(async (trx) => {
1942
2495
  // `authz_assignments` es del driver `database` y aquí está vacía,
@@ -1952,6 +2505,25 @@ export class OpenFgaAuthorizationDriver {
1952
2505
  finally {
1953
2506
  this.catalog.invalidate();
1954
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
+ }
1955
2527
  }
1956
2528
  /**
1957
2529
  * Cuántos hechos VIGENTES tiene cada rol, en todos los scopes (3b-2j).
@@ -1985,12 +2557,15 @@ export class OpenFgaAuthorizationDriver {
1985
2557
  async countRoleAssignments(roleUuids) {
1986
2558
  for (const uuid of roleUuids)
1987
2559
  assertCatalogUuid('rol', uuid);
2560
+ await this.assertModel('countRoleAssignments');
1988
2561
  const counts = [];
1989
2562
  for (const roleUuid of roleUuids) {
1990
2563
  const edges = await this.readAllTuples({ user: `${FACTS_ROLE_TYPE}:${roleUuid}`, relation: FACTS_ROLE_RELATION, object: `${FACTS_BINDING_TYPE}:` }, { includeExpired: true });
1991
2564
  let total = 0;
1992
2565
  for (const binding of new Set(edges.map((edge) => edge.object))) {
1993
- 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;
1994
2569
  }
1995
2570
  counts.push(total);
1996
2571
  }
@@ -2015,8 +2590,25 @@ export class OpenFgaAuthorizationDriver {
2015
2590
  */
2016
2591
  async projectCatalogRole(roleUuid) {
2017
2592
  assertCatalogUuid('rol', roleUuid);
2018
- await this.sweepRoleVisibility(roleUuid);
2019
- 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]);
2020
2612
  const permissions = [...catalog.rolePermissionsOf(roleUuid)].sort();
2021
2613
  const object = `${FACTS_ROLE_TYPE}:${roleUuid}`;
2022
2614
  const wanted = new Map();
@@ -2025,7 +2617,7 @@ export class OpenFgaAuthorizationDriver {
2025
2617
  }
2026
2618
  const current = new Map();
2027
2619
  for (const holderType of Object.values(this.holderTypes)) {
2028
- 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 })) {
2029
2621
  if (!key.relation.startsWith(FACTS_PERMITS_PREFIX))
2030
2622
  continue;
2031
2623
  current.set(factsTupleId(key), key);
@@ -2085,7 +2677,9 @@ export class OpenFgaAuthorizationDriver {
2085
2677
  const prune = options.prune === true;
2086
2678
  const batchSize = reconcileBatchSize(options.batchSize);
2087
2679
  const maxTuples = reconcileMaxTuples(options.maxTuples);
2088
- 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();
2089
2683
  const now = this.now();
2090
2684
  /** Lo que el destino DEBE tener, por id de tupla. */
2091
2685
  const wanted = new Map();
@@ -2236,6 +2830,8 @@ export class OpenFgaAuthorizationDriver {
2236
2830
  */
2237
2831
  const foreignTypes = new Map();
2238
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 = [];
2239
2835
  for (const tuple of current) {
2240
2836
  const id = factsTupleId(tuple);
2241
2837
  const family = familyOfTuple(tuple);
@@ -2245,6 +2841,25 @@ export class OpenFgaAuthorizationDriver {
2245
2841
  foreignTypes.set(type, (foreignTypes.get(type) ?? 0) + 1);
2246
2842
  continue;
2247
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
+ }
2248
2863
  if (tuple.relation === FACTS_ASSIGNEE_RELATION)
2249
2864
  storeBindings.add(tuple.object);
2250
2865
  if (tuple.relation === FACTS_PARENT_RELATION && tuple.object.startsWith(`${FACTS_SCOPE_TYPE}:`)) {
@@ -2303,6 +2918,13 @@ export class OpenFgaAuthorizationDriver {
2303
2918
  // que devuelve N filas y las N se descartan (caducadas, scopes que ya no
2304
2919
  // resuelven) sigue sin respaldar NADA de lo que --prune va a borrar.
2305
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
+ }
2306
2928
  if (massDelete && !dryRun && options.allowMassDelete !== true) {
2307
2929
  throw new MassReconcileRefusedError(`authz:reconcile --to=openfga --prune borraría ${factsDeleted} tupla(s) de hechos del store y el ` +
2308
2930
  `ORIGEN no ha aportado NI UN hecho utilizable (${facts.rows} leídos, todos descartados — mira ` +
@@ -2313,10 +2935,27 @@ export class OpenFgaAuthorizationDriver {
2313
2935
  `--allow-mass-delete.`);
2314
2936
  }
2315
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
+ }
2316
2954
  // Los DELETES primero: si la pasada muere entre medias, lo que queda es
2317
2955
  // de MENOS (fail-closed), nunca una tupla vieja conviviendo con la nueva.
2318
2956
  await this.applyReconcileWrites(deletes, []);
2319
2957
  await this.applyReconcileWrites([], writes);
2958
+ await this.settleRoleVisibility(settlePairs.reverse(), { writes: 0 });
2320
2959
  }
2321
2960
  const totals = sumPhases(phases);
2322
2961
  return {
@@ -2350,18 +2989,9 @@ export class OpenFgaAuthorizationDriver {
2350
2989
  * el modelo que sí sirve (fail-closed sobre un permiso vivo).
2351
2990
  */
2352
2991
  async modelPermissions() {
2353
- // `readAuthorizationModel()` sin argumentos usa el `authorizationModelId`
2354
- // con el que se construyó el cliente (el mismo de cada `Write`/`Check`).
2355
- const response = this.modelId
2356
- ? await this.client.readAuthorizationModel()
2357
- : await this.client.readLatestAuthorizationModel();
2358
- const model = response?.authorization_model;
2359
- if (!model?.type_definitions)
2360
- return null;
2361
- const role = model.type_definitions.find((definition) => definition.type === FACTS_ROLE_TYPE);
2362
- if (!role?.relations)
2363
- return null;
2364
- 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}`));
2365
2995
  }
2366
2996
  /**
2367
2997
  * El árbol del ORIGEN, paginado (`scopes.enumerateEdges`), con los ciclos
@@ -2645,6 +3275,12 @@ export class OpenFgaAuthorizationDriver {
2645
3275
  ctx.discard('deny', 'unknown-permission', fact.detail);
2646
3276
  continue;
2647
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
+ }
2648
3284
  if (this.wantDeny(ctx, {
2649
3285
  holder: fact.holder,
2650
3286
  scope: fact.scope,
@@ -2788,6 +3424,9 @@ export class OpenFgaAuthorizationDriver {
2788
3424
  if (!Number.isFinite(limit) || limit < 1) {
2789
3425
  throw new AuthorizationConfigError(`enumerateFacts: limit debe ser un entero >= 1 (llegó ${String(page.limit)})`);
2790
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');
2791
3430
  const catalog = await this.catalog.view();
2792
3431
  const permissionByRelation = new Map();
2793
3432
  for (const slug of catalog.permissionSlugs) {
@@ -2832,12 +3471,19 @@ export class OpenFgaAuthorizationDriver {
2832
3471
  skipped.push({ kind: 'tuple', reason: 'unknown-holder-type', detail: id });
2833
3472
  continue;
2834
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
+ }
2835
3481
  facts.push({
2836
3482
  kind: 'assignment',
2837
3483
  holder,
2838
3484
  scope: binding.scope,
2839
3485
  roleUuid: binding.uuid,
2840
- expiresAt: toExpiryDate(key.condition?.context?.valid_until),
3486
+ expiresAt: condition.validUntil,
2841
3487
  detail: id,
2842
3488
  });
2843
3489
  continue;
@@ -2860,7 +3506,18 @@ export class OpenFgaAuthorizationDriver {
2860
3506
  skipped.push({ kind: 'tuple', reason: 'unknown-holder-type', detail: id });
2861
3507
  continue;
2862
3508
  }
2863
- 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
+ });
2864
3521
  continue;
2865
3522
  }
2866
3523
  // **El deny del modo `resolver` de 2.2 ES un hecho y se emite** (3b-8 ·
@@ -2919,7 +3576,7 @@ export class OpenFgaAuthorizationDriver {
2919
3576
  const seenTokens = new Set();
2920
3577
  let pages = 0;
2921
3578
  do {
2922
- 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 });
2923
3580
  pages += 1;
2924
3581
  for (const tuple of this.tuplesOrThrow(response, 'reconcile.readEveryTuple')) {
2925
3582
  const key = tuple?.key;
@@ -2929,12 +3586,7 @@ export class OpenFgaAuthorizationDriver {
2929
3586
  `no se puede comparar (total: ${this.diagnostics.unparseableBindings})`);
2930
3587
  continue;
2931
3588
  }
2932
- out.push({
2933
- user: key.user,
2934
- relation: key.relation,
2935
- object: key.object,
2936
- validUntil: toExpiryDate(key.condition?.context?.valid_until),
2937
- });
3589
+ out.push({ user: key.user, relation: key.relation, object: key.object, ...storedCondition(key.condition) });
2938
3590
  if (out.length > maxTuples) {
2939
3591
  throw new ReconcileTooLargeError(`authz:reconcile --to=openfga: el store pasa de maxTuples (${maxTuples}) tuplas y la pasada ` +
2940
3592
  `necesita la foto ENTERA del destino para saber qué sobra (que es la mitad del trabajo: ` +
@@ -3004,6 +3656,14 @@ export class OpenFgaAuthorizationDriver {
3004
3656
  }
3005
3657
  return tuples;
3006
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
+ }
3007
3667
  async readAllTuples(filter, options = {}) {
3008
3668
  // El instante con el que se filtra: el de la operación si lo trae (K9), o
3009
3669
  // el de esta lectura.
@@ -3016,7 +3676,7 @@ export class OpenFgaAuthorizationDriver {
3016
3676
  const response = await this.client.read(filter, {
3017
3677
  pageSize: READ_PAGE_SIZE,
3018
3678
  continuationToken,
3019
- consistency: this.consistency,
3679
+ consistency: options.consistency ?? this.consistency,
3020
3680
  });
3021
3681
  pages += 1;
3022
3682
  for (const tuple of this.tuplesOrThrow(response, `Read ${JSON.stringify(filter)}`)) {
@@ -3029,8 +3689,22 @@ export class OpenFgaAuthorizationDriver {
3029
3689
  continue;
3030
3690
  }
3031
3691
  if (!options.includeExpired) {
3032
- const validUntil = toExpiryDate(k.condition?.context?.valid_until);
3033
- 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)
3034
3708
  continue;
3035
3709
  }
3036
3710
  keys.push({ user: k.user, relation: k.relation, object: k.object });
@@ -3154,6 +3828,16 @@ function foreignReport(tuples, types, relationTypes) {
3154
3828
  relations += count;
3155
3829
  return { tuples, types: byType, relations, unknown: tuples - relations };
3156
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
+ }
3157
3841
  /** La clave sola: un `delete` de FGA no lleva condición. */
3158
3842
  function bareTuple(tuple) {
3159
3843
  return { user: tuple.user, relation: tuple.relation, object: tuple.object };
@@ -3195,4 +3879,3 @@ function walkUpTo(parentOf, from, target) {
3195
3879
  }
3196
3880
  return null;
3197
3881
  }
3198
- //# sourceMappingURL=openfga_driver.js.map