@jantstack/adonis-authz 2.4.0-alpha.1 → 2.4.0-alpha.10

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 (158) hide show
  1. package/README.md +496 -29
  2. package/build/commands/authz_catalog_diff.d.ts.map +1 -1
  3. package/build/commands/authz_catalog_diff.js +0 -1
  4. package/build/commands/authz_catalog_diff.js.map +1 -1
  5. package/build/commands/authz_catalog_prune_orphans.d.ts.map +1 -1
  6. package/build/commands/authz_catalog_prune_orphans.js +0 -1
  7. package/build/commands/authz_catalog_prune_orphans.js.map +1 -1
  8. package/build/commands/authz_freeze.d.ts.map +1 -1
  9. package/build/commands/authz_freeze.js +1 -2
  10. package/build/commands/authz_freeze.js.map +1 -1
  11. package/build/commands/authz_reconcile.d.ts +15 -6
  12. package/build/commands/authz_reconcile.d.ts.map +1 -1
  13. package/build/commands/authz_reconcile.js +143 -20
  14. package/build/commands/authz_reconcile.js.map +1 -1
  15. package/build/commands/authz_relations_reconcile.d.ts +72 -1
  16. package/build/commands/authz_relations_reconcile.d.ts.map +1 -1
  17. package/build/commands/authz_relations_reconcile.js +234 -21
  18. package/build/commands/authz_relations_reconcile.js.map +1 -1
  19. package/build/commands/authz_scopes_relay.d.ts +50 -0
  20. package/build/commands/authz_scopes_relay.d.ts.map +1 -1
  21. package/build/commands/authz_scopes_relay.js +213 -10
  22. package/build/commands/authz_scopes_relay.js.map +1 -1
  23. package/build/commands/authz_scopes_requeue.d.ts +80 -0
  24. package/build/commands/authz_scopes_requeue.d.ts.map +1 -0
  25. package/build/commands/authz_scopes_requeue.js +230 -0
  26. package/build/commands/authz_scopes_requeue.js.map +1 -0
  27. package/build/commands/authz_unfreeze.d.ts +12 -4
  28. package/build/commands/authz_unfreeze.d.ts.map +1 -1
  29. package/build/commands/authz_unfreeze.js +74 -9
  30. package/build/commands/authz_unfreeze.js.map +1 -1
  31. package/build/commands/main.d.ts +16 -13
  32. package/build/commands/main.d.ts.map +1 -1
  33. package/build/commands/main.js +59 -11
  34. package/build/commands/main.js.map +1 -1
  35. package/build/commands/openfga_provision.d.ts +49 -3
  36. package/build/commands/openfga_provision.d.ts.map +1 -1
  37. package/build/commands/openfga_provision.js +72 -7
  38. package/build/commands/openfga_provision.js.map +1 -1
  39. package/build/index.d.ts +15 -5
  40. package/build/index.d.ts.map +1 -1
  41. package/build/index.js +16 -3
  42. package/build/index.js.map +1 -1
  43. package/build/providers/authz_provider.d.ts +7 -2
  44. package/build/providers/authz_provider.d.ts.map +1 -1
  45. package/build/providers/authz_provider.js +20 -3
  46. package/build/providers/authz_provider.js.map +1 -1
  47. package/build/src/define_config.d.ts +106 -1
  48. package/build/src/define_config.d.ts.map +1 -1
  49. package/build/src/define_config.js +11 -0
  50. package/build/src/define_config.js.map +1 -1
  51. package/build/src/drivers/database_driver.d.ts +53 -14
  52. package/build/src/drivers/database_driver.d.ts.map +1 -1
  53. package/build/src/drivers/database_driver.js +104 -32
  54. package/build/src/drivers/database_driver.js.map +1 -1
  55. package/build/src/drivers/database_relations_driver.d.ts +74 -9
  56. package/build/src/drivers/database_relations_driver.d.ts.map +1 -1
  57. package/build/src/drivers/database_relations_driver.js +391 -93
  58. package/build/src/drivers/database_relations_driver.js.map +1 -1
  59. package/build/src/drivers/openfga_driver.d.ts +71 -10
  60. package/build/src/drivers/openfga_driver.d.ts.map +1 -1
  61. package/build/src/drivers/openfga_driver.js +195 -16
  62. package/build/src/drivers/openfga_driver.js.map +1 -1
  63. package/build/src/drivers/openfga_facts.d.ts +15 -0
  64. package/build/src/drivers/openfga_facts.d.ts.map +1 -1
  65. package/build/src/drivers/openfga_facts.js +25 -2
  66. package/build/src/drivers/openfga_facts.js.map +1 -1
  67. package/build/src/drivers/openfga_relations_driver.d.ts +72 -6
  68. package/build/src/drivers/openfga_relations_driver.d.ts.map +1 -1
  69. package/build/src/drivers/openfga_relations_driver.js +308 -50
  70. package/build/src/drivers/openfga_relations_driver.js.map +1 -1
  71. package/build/src/errors.d.ts +175 -4
  72. package/build/src/errors.d.ts.map +1 -1
  73. package/build/src/errors.js +219 -3
  74. package/build/src/errors.js.map +1 -1
  75. package/build/src/freeze.d.ts +100 -20
  76. package/build/src/freeze.d.ts.map +1 -1
  77. package/build/src/freeze.js +159 -25
  78. package/build/src/freeze.js.map +1 -1
  79. package/build/src/manager.d.ts +239 -7
  80. package/build/src/manager.d.ts.map +1 -1
  81. package/build/src/manager.js +1192 -148
  82. package/build/src/manager.js.map +1 -1
  83. package/build/src/models/authz_assignment.d.ts +2 -2
  84. package/build/src/models/authz_assignment.d.ts.map +1 -1
  85. package/build/src/models/authz_deny.d.ts +2 -2
  86. package/build/src/models/authz_deny.d.ts.map +1 -1
  87. package/build/src/models/authz_permission.d.ts +2 -2
  88. package/build/src/models/authz_permission.d.ts.map +1 -1
  89. package/build/src/models/authz_role.d.ts +2 -2
  90. package/build/src/models/authz_role.d.ts.map +1 -1
  91. package/build/src/models/authz_role_permission.d.ts +2 -2
  92. package/build/src/models/authz_role_permission.d.ts.map +1 -1
  93. package/build/src/relation_partition_trigger.d.ts.map +1 -1
  94. package/build/src/relation_partition_trigger.js +11 -3
  95. package/build/src/relation_partition_trigger.js.map +1 -1
  96. package/build/src/relations/define_relations_config.d.ts +51 -0
  97. package/build/src/relations/define_relations_config.d.ts.map +1 -1
  98. package/build/src/relations/define_relations_config.js +68 -1
  99. package/build/src/relations/define_relations_config.js.map +1 -1
  100. package/build/src/relations/manager.d.ts +106 -6
  101. package/build/src/relations/manager.d.ts.map +1 -1
  102. package/build/src/relations/manager.js +368 -30
  103. package/build/src/relations/manager.js.map +1 -1
  104. package/build/src/relations/reconcile.d.ts +81 -6
  105. package/build/src/relations/reconcile.d.ts.map +1 -1
  106. package/build/src/relations/reconcile.js +233 -77
  107. package/build/src/relations/reconcile.js.map +1 -1
  108. package/build/src/scope_outbox.d.ts +54 -0
  109. package/build/src/scope_outbox.d.ts.map +1 -1
  110. package/build/src/scope_outbox.js +274 -33
  111. package/build/src/scope_outbox.js.map +1 -1
  112. package/build/src/shared/backend_guard.d.ts +28 -0
  113. package/build/src/shared/backend_guard.d.ts.map +1 -1
  114. package/build/src/shared/backend_guard.js +52 -0
  115. package/build/src/shared/backend_guard.js.map +1 -1
  116. package/build/src/shared/sql_expiry.d.ts +2 -0
  117. package/build/src/shared/sql_expiry.d.ts.map +1 -1
  118. package/build/src/shared/sql_expiry.js +4 -0
  119. package/build/src/shared/sql_expiry.js.map +1 -1
  120. package/build/src/shared/transaction_guard.d.ts +53 -0
  121. package/build/src/shared/transaction_guard.d.ts.map +1 -0
  122. package/build/src/shared/transaction_guard.js +74 -0
  123. package/build/src/shared/transaction_guard.js.map +1 -0
  124. package/build/src/sql_descendants.d.ts +8 -0
  125. package/build/src/sql_descendants.d.ts.map +1 -1
  126. package/build/src/sql_descendants.js.map +1 -1
  127. package/build/src/testing/contract.d.ts +55 -4
  128. package/build/src/testing/contract.d.ts.map +1 -1
  129. package/build/src/testing/contract.js +244 -9
  130. package/build/src/testing/contract.js.map +1 -1
  131. package/build/src/testing/main.d.ts +6 -4
  132. package/build/src/testing/main.d.ts.map +1 -1
  133. package/build/src/testing/main.js +3 -2
  134. package/build/src/testing/main.js.map +1 -1
  135. package/build/src/testing/migration_contract.d.ts +9 -0
  136. package/build/src/testing/migration_contract.d.ts.map +1 -1
  137. package/build/src/testing/migration_contract.js +5 -2
  138. package/build/src/testing/migration_contract.js.map +1 -1
  139. package/build/src/testing/relations_contract.d.ts +23 -1
  140. package/build/src/testing/relations_contract.d.ts.map +1 -1
  141. package/build/src/testing/relations_contract.js +603 -19
  142. package/build/src/testing/relations_contract.js.map +1 -1
  143. package/build/src/testing/relations_reconcile_contract.d.ts.map +1 -1
  144. package/build/src/testing/relations_reconcile_contract.js +49 -1
  145. package/build/src/testing/relations_reconcile_contract.js.map +1 -1
  146. package/build/src/testing/scope_outbox_contract.d.ts +98 -0
  147. package/build/src/testing/scope_outbox_contract.d.ts.map +1 -0
  148. package/build/src/testing/scope_outbox_contract.js +677 -0
  149. package/build/src/testing/scope_outbox_contract.js.map +1 -0
  150. package/build/src/traits/has_uuid.d.ts +2 -2
  151. package/build/src/traits/has_uuid.d.ts.map +1 -1
  152. package/build/src/types.d.ts +708 -38
  153. package/build/src/types.d.ts.map +1 -1
  154. package/build/src/types.js +55 -0
  155. package/build/src/types.js.map +1 -1
  156. package/build/stubs/config/authorization.stub +45 -1
  157. package/build/stubs/migration.stub +20 -10
  158. package/package.json +1 -1
@@ -1,16 +1,17 @@
1
1
  var _a;
2
2
  import { Exception } from '@adonisjs/core/exceptions';
3
3
  import { v7 as uuidv7 } from 'uuid';
4
+ import { activeRelationsDriverKey } from './define_config.js';
4
5
  import { assertCatalogUuid, assertIdentity, assertScope, assertScopeType, assertSubject, assertValidSlug, chainKeysFrom, normalizeRoleQuery, scopeFromKey, scopeKey, scopeSpellings, } from './identity.js';
5
6
  import { expiryChanged } from './expiry.js';
6
7
  import { randomBytes } from 'node:crypto';
7
- import { DEFAULT_FREEZE_LEASE_MS, acquireFreeze, freezeIsLive, freezeKindOf, readFreezeRow, releaseFreeze, renewFreeze, } from './freeze.js';
8
+ import { DEFAULT_FREEZE_LEASE_MS, acquireFreezeOutcome, UNKNOWN_HOLDER, freezeIsLive, freezeKindOf, readFreezeRow, assertNotFrozenRow, releaseFreeze, renewFreeze, } from './freeze.js';
8
9
  import { assertKnownScope, isAuthzError, resolveChain, rootOnlyResolver } from './shared/backend_guard.js';
9
10
  import { CatalogCache, GLOBAL_OWNER_KEY, invalidateAuthzCatalog, isRoleVisibleWith, readLocalRoles, withAuthzCatalogWrite } from './catalog/catalog_cache.js';
10
11
  import { assertAssignableAt } from './catalog/catalog.js';
11
12
  import { systemClock } from './clock.js';
12
- import { ActorRequiredError, AuthorizationBackendTimeoutError, AuthorizationFrozenError, AuthorizationConfigError, AuthorizationInternalError, CatalogConflictError, InvalidIdentityError, MassPurgeRefusedError, PruneInterruptedError, NoDescendantsResolverError, NotWithinError, PermissionNotDelegableError, RankExceededError, RoleImmutableError, RoleLevelAboveOwnerError, ScopeCycleError, ScopeResolverError, TooManyScopesError, UnknownPermissionError, UnknownRoleError, FreezeHeldError, UnsupportedOperationError, ViewExpiredError, WithinRequiredError, WithinRootForbiddenError, ScopeDriftUnguardedError, } from './errors.js';
13
- import { APP_SCOPE_TYPE } from './types.js';
13
+ import { ActorRequiredError, AuthorizationBackendError, AuthorizationBackendTimeoutError, AuthorizationFrozenError, AuthorizationConfigError, AuthorizationInternalError, CatalogConflictError, InvalidIdentityError, MassPurgeRefusedError, PruneInterruptedError, NoDescendantsResolverError, NotWithinError, PermissionNotDelegableError, RankExceededError, RoleImmutableError, RoleLevelAboveOwnerError, ScopeCycleError, ScopeResolverError, TooManyScopesError, UnknownPermissionError, UnknownRoleError, FreezeHeldError, UnsupportedOperationError, ViewExpiredError, WithinRequiredError, WithinRootForbiddenError, ScopeDriftUnguardedError, ScopeDepthLimitError, ScopeRequeueRefusedError, StaleSourceRefusedError, OperatorWindowExpiredError, } from './errors.js';
14
+ import { APP_SCOPE_TYPE, normalizeAuthorizationCapabilities } from './types.js';
14
15
  import { memoizeAncestors } from './memoize_ancestors.js';
15
16
  import { AUTHZ_TABLES_ORIGIN } from './reconcile.js';
16
17
  /** Longitudes de `authz_roles.name`/`description` (el esquema publicado). */
@@ -62,8 +63,49 @@ export const DEFAULT_RELAY_BATCH = 100;
62
63
  export const DEFAULT_RELAY_LIMIT = 10_000;
63
64
  /** Páginas que `authz:reconcile` pasea para MEDIR la ventana del relay (3b-3a). */
64
65
  const RELAY_WINDOW_MAX_PAGES = 1_000;
66
+ /**
67
+ * **Saltos que la sonda de `only` sigue hacia atrás para heredar el ORIGEN de
68
+ * un bloqueo** (2.4.0-alpha.6). Cada salto es una consulta `earlierPending`
69
+ * (0,69 ms) sobre una entrada NO pedida; la cadena es de ids estrictamente
70
+ * decrecientes, así que termina sola — la cota solo acota el coste dentro de
71
+ * una petición inline. Pasada la cota el origen se toma por FALLO (la purga se
72
+ * aplaza a la pasada completa): degrada hacia lo seguro, nunca aplica sin saber.
73
+ */
74
+ export const RELAY_ORIGIN_MAX_HOPS = 100;
75
+ /**
76
+ * **Cota PROPIA del censo de aparcadas** (alpha.5 · cierre, auditor 🟠 2). Las
77
+ * claves de `dead()` que taponan `attached`/`moved` se leen COMPLETAS en cada
78
+ * pasada, sean cuales sean `limit` y `batchSize` (con `--limit 50` o la receta
79
+ * inline `{ only, limit: 1 }` un censo acotado por `limit` dejaba a la aparcada
80
+ * 101 sin taponar y el `attached` colgaba del padre VIEJO — y la sonda de
81
+ * `only` no la ve porque `earlierPending` excluye lo aparcado). Si hay más
82
+ * aparcadas que esto, la pasada se NIEGA (500) en vez de aplicar con un tapón
83
+ * incompleto: una cola así es un incidente, no un lote.
84
+ */
85
+ export const PARKED_CENSUS_MAX = 50_000;
65
86
  /** Vida por defecto de una vista de `forRequest()` para LEER (F9): un request, no un módulo. */
66
87
  export const DEFAULT_VIEW_MAX_AGE_MS = 30_000;
88
+ /**
89
+ * Cada cuánto reintenta `acquire()` una pasada con `waitForLeaseMs` (2.4.0-alpha.8;
90
+ * panel relay 2, cuestión 7). Medido en PG: 0,29 ms por sondeo ocupado, y el
91
+ * sondeo no retiene conexión entre intentos (la transacción del intento fallido
92
+ * se revierte). El default de `waitForLeaseMs` es 0: el `busy` inmediato de
93
+ * siempre.
94
+ */
95
+ export const RELAY_LEASE_POLL_MS = 25;
96
+ /**
97
+ * Tope de `waitForLeaseMs` (cierre alpha.8, 🟡 1 del auditor): una espera sin tope
98
+ * cuelga la petición de un tenant; por encima es 500 `E_AUTHZ_CONFIG`. Y la espera
99
+ * se cuenta en SONDEOS (`waitForLeaseMs / RELAY_LEASE_POLL_MS`), no solo con el
100
+ * reloj de pared: con `config.clock` fijo el sondeo por reloj no terminaba nunca.
101
+ */
102
+ export const RELAY_WAIT_LEASE_MAX_MS = 60_000;
103
+ /** Las opciones que `relayScopeChanges` conoce (2.4.0-alpha.8 · X-1: cualquier otra es 500). */
104
+ const RELAY_OPTIONS = ['limit', 'batchSize', 'dryRun', 'only', 'waitForLeaseMs', 'deadDetail'];
105
+ /** Precedencia de los orígenes de un bloqueo por clave (2.4.0-alpha.8): un fallo real pisa todo; una pendiente pisa a un aparcado. */
106
+ const BLOCK_RANK = { parked: 0, pending: 1, failed: 2 };
107
+ /** Las opciones que `requeueScopeChanges` conoce (2.4.0-alpha.8 · X-1: cualquier otra es 500). */
108
+ const REQUEUE_OPTIONS = ['force', 'apply', 'waitForLeaseMs'];
67
109
  /** Reloj monótono del proceso: inmune a NTP, snapshots y `Date.now` parcheado. */
68
110
  const monotonicNow = () => performance.now();
69
111
  /**
@@ -196,10 +238,96 @@ export class AuthorizationManager {
196
238
  }
197
239
  driver = driver.withClock(clock);
198
240
  }
241
+ this.#assertTransactionalWritesRequired(driver);
199
242
  this.#assertScopeDriftGuarded(driver);
200
243
  this.#driver = driver;
201
244
  return driver;
202
245
  }
246
+ /**
247
+ * **Lo que el driver ACTIVO declara, de solo lectura** (2.4.0-alpha.4;
248
+ * pregunta 1 de COGNITIV, panel alpha.4). Para que el código de tenant
249
+ * pueda ramificar —`if ((await authorization.capabilities()).transactionalWrites)
250
+ * grant(…, { transaction }) else grant(…)`— sin llamar a `driver()`, que
251
+ * sigue siendo API de PLATAFORMA (el grep del README sigue significando lo
252
+ * que significa).
253
+ *
254
+ * Es `(await this.driver()).capabilities` y NADA más, a propósito:
255
+ * - las capacidades son de la INSTANCIA (`database` decide
256
+ * `transactionalWrites` desde sus opciones), así que hay que resolver;
257
+ * - y se resuelve POR `driver()`, con sus dos puertas
258
+ * (`requireTransactionalWrites` y el gate de deriva del árbol) y su
259
+ * caché. Resolverlo por otro camino «para que la lectura no lance» y
260
+ * guardarlo en `#driver` dejaría `driver()` sirviendo un driver que
261
+ * nunca pasó por las puertas: un bypass permanente del gate de deriva
262
+ * (🔴 1 del auditor). Por eso hereda los 500 de las puertas
263
+ * (`E_AUTHZ_CONFIG`, `E_AUTHZ_SCOPE_DRIFT_UNGUARDED`) y la factory del
264
+ * consumidor: llámalo al arrancar (un hook `booted`/`ready`), no por
265
+ * request en un endpoint de metadatos.
266
+ *
267
+ * Devuelve una copia CONGELADA y NORMALIZADA: todas las claves de
268
+ * `AuthorizationDriverCapabilities` presentes, solo `true` es `true`
269
+ * (la misma lectura que la puerta 1), nunca `undefined`; mutarla no toca
270
+ * el objeto que leen las puertas. La vista de `forRequest()` delega en el
271
+ * padre (por `driver()`).
272
+ */
273
+ async capabilities() {
274
+ const driver = await this.driver();
275
+ return normalizeAuthorizationCapabilities(driver.capabilities);
276
+ }
277
+ /**
278
+ * **Puerta 2 de `{ transaction }`** (L-2, opt-in): con
279
+ * `requireTransactionalWrites: true` el driver activo tiene que declarar
280
+ * `transactionalWrites: true` o el manager falla AL RESOLVERLO — antes de
281
+ * cachearlo, así que toda lectura, toda escritura y `driver()` fallan igual:
282
+ * el despliegue no arranca. Es la forma honesta de «fallar al construirse»:
283
+ * el roadmap lo pedía incondicional y eso haría `openfga` inconstruible en
284
+ * cualquier app que solo lo tenga registrado (y el driver se resuelve
285
+ * perezosamente y por nombre, así que al construir no se sabe si alguien
286
+ * pasará `{ transaction }`). Quien quiera fallar al arrancar, lo pide.
287
+ */
288
+ #assertTransactionalWritesRequired(driver) {
289
+ if (this.#config.requireTransactionalWrites !== true)
290
+ return;
291
+ if (driver.capabilities?.transactionalWrites === true)
292
+ return;
293
+ throw new AuthorizationConfigError(`config.requireTransactionalWrites está en true y el driver '${this.#config.default}' declara ` +
294
+ `transactionalWrites: ${driver.capabilities ? String(driver.capabilities.transactionalWrites) : 'nada (sin capabilities)'}: ` +
295
+ `no puede inscribir grant/revoke/deny/removeDeny en la transacción del consumidor («los dos o ninguno»), así ` +
296
+ `que el manager no se resuelve. Usa un driver que la declare (database) o quita requireTransactionalWrites.`);
297
+ }
298
+ /**
299
+ * **Puerta 1 de `{ transaction }`** (L-2, siempre activa): una escritura de
300
+ * HECHOS con `{ transaction }` a un driver que no declara
301
+ * `transactionalWrites: true` es 500 `E_AUTHZ_UNSUPPORTED` nombrando driver
302
+ * y operación, ANTES de la barrera, de la identidad y del driver (cero
303
+ * llamadas). Nunca se ignora, nunca un `logger.warn`: aceptarla y no
304
+ * cumplirla sería publicar con nuestra firma la fuga del cruce 4 · S5.
305
+ * `scopes.*` NO pasa por aquí: su `transaction` ENCOLA (encolar ≠ escribir).
306
+ */
307
+ async #assertTransactionalWrite(options, operation) {
308
+ if (options?.transaction === undefined || options.transaction === null)
309
+ return;
310
+ const driver = await this.driver();
311
+ if (driver.capabilities?.transactionalWrites === true)
312
+ return;
313
+ throw UnsupportedOperationError.transactional(operation, this.#config.default, 'roles');
314
+ }
315
+ /**
316
+ * La marca del evento de una escritura inscrita en la transacción del
317
+ * llamante (L-3): `onWrite` se dispara cuando el DRIVER vuelve, y en ese
318
+ * momento la fila existe solo dentro de esa transacción — es un hecho si y
319
+ * solo si el llamante confirma, cosa que el paquete nunca ve. Quien audita
320
+ * lo necesita para no registrar como firme lo que un rollback deshace.
321
+ */
322
+ #transactional(options) {
323
+ return options?.transaction === undefined || options.transaction === null ? {} : { transactional: true };
324
+ }
325
+ /** La API de delegación no admite `{ transaction }` (§1.4 del veredicto `{trx}`): 500 antes de tocar nada. */
326
+ #assertNoTransaction(options, operation) {
327
+ if (options?.transaction === undefined || options.transaction === null)
328
+ return;
329
+ throw UnsupportedOperationError.transactionalCatalog(operation);
330
+ }
203
331
  /**
204
332
  * **El gate de deriva del árbol, en el MANAGER** (3b-2e · E3; cierra el
205
333
  * agujero que declaró el 3b-2d).
@@ -279,12 +407,30 @@ export class AuthorizationManager {
279
407
  const finalReason = reason ?? 'una operación de plataforma';
280
408
  const holder = `${kind}:${process.pid}:${randomBytes(4).toString('hex')}`;
281
409
  const nowMs = root.#wallMs();
282
- const token = await acquireFreeze({ reason: finalReason, holder, untilMs: leaseMs === null ? null : nowMs + leaseMs, nowMs }, { driver: this.#config.default });
283
- if (token === null) {
284
- const row = await readFreezeRow({ driver: this.#config.default });
410
+ // alpha.10 · S2 (+ cierre del auditor · R2): una ventana de OPERADOR
411
+ // caducada no se pisa en silencio. Una fila con el lease vencido se toma
412
+ // encima (así la flota vuelve sola tras un SIGKILL de una pasada), pero la
413
+ // del operador es EVIDENCIA: pisarla y soltarla después la deja a NULL y
414
+ // borra el único rastro de que las escrituras volvieron a entrar a mitad
415
+ // del cutover. La exclusión vive DENTRO del UPDATE de `acquireFreezeOutcome`
416
+ // (atómica): aquí solo se lee el veredicto y la fila para redactarlo — un
417
+ // «compruebo y luego tomo» con dos lecturas del reloj era un TOCTOU.
418
+ const outcome = await acquireFreezeOutcome({ reason: finalReason, holder, untilMs: leaseMs === null ? null : nowMs + leaseMs, nowMs }, { driver: this.#config.default });
419
+ if (outcome.status === 'expired-operator') {
420
+ const row = outcome.row;
421
+ const when = row.untilMs === null ? '?' : new Date(row.untilMs).toISOString();
422
+ throw new OperatorWindowExpiredError(`freeze (${reason ?? kind}): la ventana del OPERADOR (${row.holder ?? '?'}, ${row.reason}, fence ` +
423
+ `${row.fence}) CADUCÓ el ${when} y nadie la cerró: desde entonces las escrituras volvieron a entrar en ` +
424
+ `toda la flota. No se toma una ventana propia encima —borraría ese rastro al soltarla y la pasada se ` +
425
+ `certificaría como si hubiera corrido dentro del cutover—. Reconócela con authz:unfreeze (dice cuánto ` +
426
+ `tiempo estuvo abierta y limpia la fila) y reabre la ventana con authz:freeze antes de repetir.`);
427
+ }
428
+ if (outcome.status === 'held') {
429
+ const row = outcome.row;
285
430
  throw new FreezeHeldError(`freeze: ya hay un freeze VIVO de otro dueño (${row.holder ?? '?'}, fence ${row.fence}, motivo: ${row.reason ?? '?'}). ` +
286
431
  `Espera a que termine, o levántalo con authz:unfreeze si su proceso murió sin lease.`);
287
432
  }
433
+ const token = outcome.token;
288
434
  const held = { token, reason: finalReason, kind, leaseMs, timer: null, lapsed: false };
289
435
  if (leaseMs !== null) {
290
436
  const interval = Math.max(250, Math.floor(leaseMs / 3));
@@ -345,6 +491,39 @@ export class AuthorizationManager {
345
491
  get frozen() {
346
492
  return this.#root().#heldFreeze !== null;
347
493
  }
494
+ /**
495
+ * La clave (en `relations.drivers`) del driver de RELACIONES activo:
496
+ * `relations.default ?? default` (2.4.0-alpha.10). La lee el guard de la
497
+ * dirección rancia de `runRelationsReconcile`; desde el cierre del auditor
498
+ * (R4) el llamante ya no puede contradecirla con un `activeKey` propio.
499
+ */
500
+ relationsDriverKey() {
501
+ return activeRelationsDriverKey(this.#root().#config);
502
+ }
503
+ /** Las claves registradas en `relations.drivers` del config de ESTE manager (vacío si no hay relaciones). */
504
+ relationsDriverKeys() {
505
+ return Object.keys(this.#root().#config.relations?.drivers ?? {});
506
+ }
507
+ /**
508
+ * La factory registrada bajo `key` en `relations.drivers` de ESTE manager, o
509
+ * `undefined` (cierre del auditor de alpha.10 · R4). `runRelationsReconcile`
510
+ * resuelve el origen y el destino por aquí —del MISMO config que decide el
511
+ * activo— y juzga la identidad por la factory resuelta, no por la etiqueta:
512
+ * dos claves con la misma factory son el mismo driver (y dos factories DISTINTAS
513
+ * sobre el mismo backend NO: límite declarado, cierre-2 · C1).
514
+ *
515
+ * **API de PLATAFORMA, junto a `driver()`** (cierre-2 · C6): con esta factory
516
+ * cualquier código que tenga el manager —vistas de `forRequest()` incluidas—
517
+ * construye un driver de relaciones NO activo y escribe en él sin `assertWrite`,
518
+ * `requireActor`, freeze ni `onRelationWrite`. No añade capacidad (el config ya
519
+ * da las factories), pero es otra cosa que buscar en una revisión.
520
+ */
521
+ relationsDriverFactory(key) {
522
+ const drivers = this.#root().#config.relations?.drivers;
523
+ if (drivers === undefined || !Object.prototype.hasOwnProperty.call(drivers, key))
524
+ return undefined;
525
+ return drivers[key];
526
+ }
348
527
  /** El freeze VIVO de la fila compartida, o `null`. Lo lee cualquiera; solo el token lo levanta. */
349
528
  async freezeStatus() {
350
529
  const row = await readFreezeRow({ driver: this.#config.default });
@@ -352,12 +531,42 @@ export class AuthorizationManager {
352
531
  return null;
353
532
  return {
354
533
  reason: row.reason,
355
- holder: row.holder ?? '?',
534
+ holder: row.holder ?? UNKNOWN_HOLDER,
356
535
  kind: freezeKindOf(row.holder),
357
536
  untilMs: row.untilMs,
358
537
  fence: row.fence,
359
538
  };
360
539
  }
540
+ /**
541
+ * **La ÚLTIMA ventana de la fila, también la CADUCADA** (2.4.0-alpha.10, 🟠 2
542
+ * del sexto informe de COGNITIV). `freezeStatus()` responde solo lo VIVO —y
543
+ * así lo siguen usando la barrera y los comandos—, de modo que una ventana
544
+ * cuyo lease venció era indistinguible de «nunca hubo»: `authz:unfreeze`
545
+ * decía éxito y el último paso del cutover no delataba que las escrituras
546
+ * habían vuelto a entrar a mitad. Se distingue por la fila: `releaseFreeze`
547
+ * pone el motivo a `null` al levantar, así que motivo puesto + `untilMs` en
548
+ * el pasado es una ventana que CADUCÓ sin que nadie la cerrase.
549
+ *
550
+ * `null` = nunca hubo o ya se levantó; `expired: false` = viva (lo mismo que
551
+ * `freezeStatus()`); `expired: true` = caducada. `checkedAtMs` es el instante
552
+ * del reloj del config con el que se decidió.
553
+ */
554
+ async lastFreezeStatus() {
555
+ const row = await readFreezeRow({ driver: this.#config.default });
556
+ if (row.reason === null)
557
+ return null;
558
+ const nowMs = this.#root().#wallMs();
559
+ return {
560
+ reason: row.reason,
561
+ holder: row.holder ?? UNKNOWN_HOLDER,
562
+ kind: freezeKindOf(row.holder),
563
+ untilMs: row.untilMs,
564
+ fence: row.fence,
565
+ expired: !freezeIsLive(row, nowMs),
566
+ // El instante (reloj del config) con el que se decidió `expired`.
567
+ checkedAtMs: nowMs,
568
+ };
569
+ }
361
570
  /**
362
571
  * `freeze()` + `finally unfreeze(token)`. El `finally` es la parte que
363
572
  * importa: una migración que revienta a la mitad no puede dejar la
@@ -366,10 +575,22 @@ export class AuthorizationManager {
366
575
  * A1.1/A1.3): si este manager ya sostiene el freeze, la ventana interior
367
576
  * no toma otro ni lo levanta al salir — la exterior sigue en pie.
368
577
  */
369
- async withFrozenWrites(reason, fn) {
370
- const context = await this.#durableFreezeContext(reason, 'platform', { operatorAsContext: false });
578
+ async withFrozenWrites(reason, fn, options = {}) {
579
+ // L-1 · J1: una pasada que quiera correr DENTRO de la ventana del
580
+ // operador (el cutover) y publicar su `lapsed` —`authz:relations:reconcile`,
581
+ // que vive fuera de este manager— pide `kind: 'reconcile'` y
582
+ // `operatorAsContext: true`, lo mismo que hace `reconcile` de roles.
583
+ const context = await this.#durableFreezeContext(reason, options.kind ?? 'platform', {
584
+ operatorAsContext: options.operatorAsContext === true,
585
+ });
371
586
  try {
372
- return await fn();
587
+ return await fn({
588
+ fence: context.fence,
589
+ leaseMs: context.leaseMs,
590
+ lapsed: context.lapsed,
591
+ ...(context.untilMs === undefined ? {} : { untilMs: context.untilMs }),
592
+ ...(context.window === undefined ? {} : { window: context.window }),
593
+ });
373
594
  }
374
595
  finally {
375
596
  await context.release();
@@ -395,6 +616,7 @@ export class AuthorizationManager {
395
616
  return {
396
617
  fence: outer.token.fence,
397
618
  leaseMs: outer.leaseMs,
619
+ window: 'enclosing',
398
620
  release: async () => { },
399
621
  lapsed: () => root.#tokenLapsed(outer.token, outer.lapsed),
400
622
  };
@@ -403,9 +625,14 @@ export class AuthorizationManager {
403
625
  const status = await this.freezeStatus();
404
626
  if (status !== null && status.kind === 'operator') {
405
627
  const token = { fence: status.fence, holder: status.holder };
628
+ // alpha.10 · 3: la caducidad REAL de la ventana adoptada. Hasta alpha.9
629
+ // `leaseMs: null` fijo — el reporte decía «sin caducidad» de una ventana
630
+ // abierta con `--lease-ms` (la certificación sí leía la fila).
406
631
  return {
407
632
  fence: status.fence,
408
- leaseMs: null,
633
+ leaseMs: status.untilMs === null ? null : Math.max(0, status.untilMs - root.#wallMs()),
634
+ untilMs: status.untilMs,
635
+ window: 'operator',
409
636
  release: async () => { },
410
637
  lapsed: () => root.#tokenLapsed(token, false),
411
638
  };
@@ -416,6 +643,7 @@ export class AuthorizationManager {
416
643
  return {
417
644
  fence: token.fence,
418
645
  leaseMs: held.leaseMs,
646
+ window: 'own',
419
647
  release: async () => {
420
648
  try {
421
649
  await this.unfreeze(token);
@@ -454,25 +682,23 @@ export class AuthorizationManager {
454
682
  * freeze, y con `catalogRevalidate: { everyMs }` la fila del memo ni se
455
683
  * lee.
456
684
  */
457
- async #assertNotFrozen(operation, transaction) {
685
+ async #assertNotFrozen(operation) {
458
686
  const root = this.#root();
459
687
  const held = root.#heldFreeze;
460
688
  if (held) {
461
689
  throw new AuthorizationFrozenError(`${operation}: el motor de autorización está congelado (${held.reason}) y no acepta escrituras. ` +
462
690
  `Las lecturas siguen funcionando; reintenta esta escritura cuando la operación termine.`);
463
691
  }
464
- // Si la escritura llegó con la TRANSACCIÓN del consumidor, la fila se lee
465
- // por ella: exigir una segunda conexión con la suya abierta interbloquea
466
- // un pool de 1 (SQLite `:memory:`). El precio va declarado en freeze.ts.
467
- const client = transaction && typeof transaction.from === 'function'
468
- ? transaction
469
- : undefined;
470
- const row = await readFreezeRow({ driver: this.#config.default, client });
471
- if (!freezeIsLive(row, root.#wallMs()))
472
- return;
473
- const lift = freezeKindOf(row.holder) === 'operator' ? ' (la levanta authz:unfreeze)' : '';
474
- throw new AuthorizationFrozenError(`${operation}: el motor de autorización está congelado (${row.reason})${lift} y no acepta escrituras. ` +
475
- `Las lecturas siguen funcionando; reintenta esta escritura cuando la ventana termine.`);
692
+ // La fila se lee SIEMPRE por la conexión del motor (L-1 · 🟠 8). Hasta
693
+ // L-1 se leía por la transacción del consumidor si la escritura llegaba
694
+ // con una —«para no interbloquear un pool de 1»—, y eso era el agujero:
695
+ // la barrera la decidía el snapshot del llamante (medido en SQLite, MySQL
696
+ // y PG). Con pool 1 el precio es un 503 con deadline, no un bypass.
697
+ await assertNotFrozenRow(operation, {
698
+ driver: this.#config.default,
699
+ nowMs: root.#wallMs(),
700
+ timeoutMs: this.#config.freezeTimeoutMs,
701
+ });
476
702
  }
477
703
  /** Solo tests: fuerza re-resolución del driver. */
478
704
  clearCachedDriver() {
@@ -529,10 +755,11 @@ export class AuthorizationManager {
529
755
  await this.#assertWithinOrigin(child, options, 'scopes.attached', 'if-known');
530
756
  const outbox = this.#outbox();
531
757
  if (outbox) {
532
- await outbox.enqueue({ op: 'attached', child: edge.child, parent: edge.chain[0] }, { transaction: options?.transaction, ...actor });
533
- return;
758
+ const receipt = await outbox.enqueue({ op: 'attached', child: edge.child, parent: edge.chain[0] }, { transaction: options?.transaction, ...actor });
759
+ return _a.#receiptOf([receipt]);
534
760
  }
535
761
  await (await this.driver()).onScopeAttached?.(child, parent);
762
+ return { queued: [] };
536
763
  },
537
764
  /**
538
765
  * `moved` NO vuelve a juzgar el catálogo, y no tiene por qué (3b-1 · D3,
@@ -554,10 +781,11 @@ export class AuthorizationManager {
554
781
  await this.#assertWithinOrigin(child, options, 'scopes.moved', 'required');
555
782
  const outbox = this.#outbox();
556
783
  if (outbox) {
557
- await outbox.enqueue({ op: 'moved', child: edge.child, parent: edge.chain[0] }, { transaction: options?.transaction, ...actor });
558
- return;
784
+ const receipt = await outbox.enqueue({ op: 'moved', child: edge.child, parent: edge.chain[0] }, { transaction: options?.transaction, ...actor });
785
+ return _a.#receiptOf([receipt]);
559
786
  }
560
787
  await (await this.driver()).onScopeMoved?.(child, newParent);
788
+ return { queued: [] };
561
789
  },
562
790
  /**
563
791
  * Hechos primero (el driver demuestra cero o lanza), arista después
@@ -611,10 +839,12 @@ export class AuthorizationManager {
611
839
  // La identidad se resuelve AQUÍ, con la fila del consumidor todavía
612
840
  // viva si la hay: al relevar el cambio ya no resolvería. Y no se
613
841
  // audita `scope_purged` todavía, porque todavía no ha pasado nada.
842
+ // El recibo es PLURAL por esto mismo: una fila por ortografía.
843
+ const receipts = [];
614
844
  for (const target of targets) {
615
- await outbox.enqueue({ op: 'detached', child: target }, { transaction: options?.transaction, ...actor });
845
+ receipts.push(await outbox.enqueue({ op: 'detached', child: target }, { transaction: options?.transaction, ...actor }));
616
846
  }
617
- return;
847
+ return _a.#receiptOf(receipts);
618
848
  }
619
849
  const driver = await this.driver();
620
850
  for (const purged of targets) {
@@ -623,8 +853,23 @@ export class AuthorizationManager {
623
853
  await driver.onScopeDetached?.(purged);
624
854
  await this.#notify(event);
625
855
  }
856
+ return { queued: [] };
626
857
  },
627
858
  };
859
+ /**
860
+ * El recibo del encolado (alpha.5 · R1): los ids que la outbox devolvió,
861
+ * en orden. Una outbox que devuelve `void` (terceros anteriores a alpha.5)
862
+ * no deja id, y el recibo sale vacío: nunca se inventa uno.
863
+ */
864
+ static #receiptOf(receipts) {
865
+ const queued = [];
866
+ for (const receipt of receipts) {
867
+ if (receipt && typeof receipt === 'object' && (typeof receipt.id === 'number' || typeof receipt.id === 'string')) {
868
+ queued.push(receipt.id);
869
+ }
870
+ }
871
+ return { queued };
872
+ }
628
873
  /**
629
874
  * **Drena la outbox del árbol y aplica los cambios al driver** (3b-2d).
630
875
  * Es lo que hay detrás de `node ace authz:scopes:relay`.
@@ -668,8 +913,29 @@ export class AuthorizationManager {
668
913
  * **fail-open temporal** —el tenant antiguo conserva acceso tras un
669
914
  * `moved`, los denies heredados no aplican tras un `attached`—. No hay
670
915
  * 2PC; es el precio de tener el árbol en dos sitios.
916
+ *
917
+ * **`only` (2.4.0-alpha.5 · R1)**: relevar SOLO estas entradas —los ids del
918
+ * recibo de `scopes.*` que ESTE proceso acaba de recibir, jamás del cuerpo
919
+ * de una petición— sin leer la cola de los demás (medido: 4 507 ms → 19 ms
920
+ * con 500 entradas ajenas por delante). El orden por clave NO se relaja:
921
+ * antes de aplicar cada entrada pedida se sondea la cola ENTERA
922
+ * (`earlierPending`, o paginando `pending()` si la outbox no lo tiene) y,
923
+ * si hay una pendiente ANTERIOR que comparte clave de scope con ella, sale
924
+ * `deferred` con `depends-on:<id>` — nunca aplicada fuera de orden (el 🔴 1
925
+ * del panel relay, medido: un `attached(child, unit)` aplicado antes que su
926
+ * `moved(unit → orgB)` dejaba al editor de la org VIEJA autorizando sobre el
927
+ * hijo). `remaining` y `dead` siguen siendo GLOBALES. `only: []` es 500
928
+ * (un `filter` vacío del consumidor no puede acabar en drenaje ni en un
929
+ * reporte vacío en verde), y una outbox que no honre el filtro también.
930
+ *
931
+ * **Lo aparcado tapona por clave (alpha.5 · R2a)**: las claves de `dead()`
932
+ * siembran el bloqueo al inicio de la pasada para lo que CONCEDE o recuelga
933
+ * (`attached`/`moved`, `deferred` con `parked:<id>`) y dejan pasar lo que
934
+ * RETIRA (`detached`: una purga nunca queda bloqueada, invariante 11). Y un
935
+ * 503 del backend NO consume intento (`markFailed(..., { transient })`).
671
936
  */
672
937
  async relayScopeChanges(options = {}) {
938
+ _a.#assertKnownOptions('relayScopeChanges', options, RELAY_OPTIONS);
673
939
  // El relay ESCRIBE el árbol en el driver: durante una migración se aplaza
674
940
  // como cualquier otra escritura (lo que quede en la cola sigue ahí).
675
941
  await this.#assertNotFrozen('authz:scopes:relay');
@@ -681,138 +947,708 @@ export class AuthorizationManager {
681
947
  const limit = _a.#positive(options.limit, DEFAULT_RELAY_LIMIT, 'limit');
682
948
  const batchSize = _a.#positive(options.batchSize, DEFAULT_RELAY_BATCH, 'batchSize');
683
949
  const dryRun = options.dryRun === true;
684
- /** Lo aparcado por la outbox, si sabe aparcar: se reporta SIEMPRE. */
685
- const dead = await _a.#deadLetters(outbox, batchSize);
950
+ const waitForLeaseMs = _a.#waitMs(options.waitForLeaseMs);
951
+ if (options.deadDetail !== undefined && options.deadDetail !== 'full') {
952
+ throw new AuthorizationConfigError(`authz:scopes:relay: deadDetail solo admite 'full' (llegó ${JSON.stringify(options.deadDetail)}); sin él, bajo only, dead[] va sin error.`);
953
+ }
954
+ const inputs = await _a.#relayInputs(outbox, options.only, batchSize, options.deadDetail);
955
+ const { only, filter, dead, deadCount, deadEntries, unseenOf } = inputs;
686
956
  if (dryRun) {
687
- const batch = await outbox.pending(limit);
957
+ const batch = await outbox.pending(limit, undefined, filter);
958
+ _a.#assertOnlyHonoured(batch, only);
688
959
  const extra = batch.length >= limit ? true : false;
960
+ const seen = new Set([...batch.map((b) => String(b.id)), ...deadEntries.map((d) => String(d.id))]);
961
+ const unseen = await unseenOf(seen);
962
+ // La edad es GLOBAL: sin filtro la cabeza es `batch[0]`; con `only` hay que mirar la cola entera.
963
+ const queue = filter === undefined ? this.#queueOf(batch[0]) : (await this.#queueStatus(outbox)).queue;
689
964
  return {
690
965
  applied: [],
691
966
  failed: null,
692
967
  failures: [],
693
968
  deferred: [],
694
969
  dead,
970
+ deadCount,
971
+ queue,
695
972
  busy: false,
696
973
  remaining: batch.length > 0 || extra,
697
974
  dryRun: true,
698
975
  wouldApply: batch.map((item) => ({ id: item.id, change: item.change, attempts: item.attempts })),
976
+ entries: deadEntries,
977
+ ...unseen,
699
978
  };
700
979
  }
701
980
  // El lease del escritor ÚNICO. Una outbox que no sabe darlo se comporta
702
981
  // como hasta ahora (y el README dice que entonces el relay tiene que
703
- // correr de uno en uno).
704
- const lease = outbox.acquire ? await outbox.acquire() : null;
705
- if (outbox.acquire && lease === null) {
982
+ // correr de uno en uno). Con `waitForLeaseMs` se reintenta hasta el tope.
983
+ const lease = await this.#acquireLease(outbox, waitForLeaseMs);
984
+ if (outbox.acquire && lease === null)
985
+ return this.#busyReport(outbox, inputs);
986
+ try {
987
+ return await this.#drainScopeChanges(outbox, { limit, batchSize, ...inputs });
988
+ }
989
+ finally {
990
+ await lease?.release();
991
+ }
992
+ }
993
+ /**
994
+ * Lo que una pasada necesita saber de la cola antes de tomar el lease
995
+ * (`only` normalizado, el filtro para el puerto, lo APARCADO —siempre
996
+ * global— y sus entradas para el reporte). Compartido por `relayScopeChanges`
997
+ * y por `requeueScopeChanges({ apply })` (2.4.0-alpha.8), que releva bajo
998
+ * el MISMO lease y necesita releer lo aparcado DESPUÉS de reencolar.
999
+ */
1000
+ static async #relayInputs(outbox, onlyIds, batchSize, deadDetail) {
1001
+ const only = _a.#onlyOf(onlyIds);
1002
+ // Al puerto van los ids TAL CUAL los dio el llamante (el recibo); el
1003
+ // conjunto de cadenas es solo para comparar sin depender del tipo.
1004
+ const filter = only === undefined ? undefined : { ids: [...onlyIds] };
1005
+ /** Lo aparcado por la outbox, si sabe aparcar: se reporta SIEMPRE, y GLOBAL. */
1006
+ const read = await _a.#deadLetters(outbox, batchSize);
1007
+ // Bajo `only` (2.4.0-alpha.8, coordinador): la lista sigue siendo global
1008
+ // —ids, `change`, `attempts`— pero SIN `error` salvo `deadDetail: 'full'`:
1009
+ // la causa es texto del backend sobre el árbol de otro tenant, y la receta
1010
+ // inline corre dentro de una petición. Lo pedido (`deadEntries`) sí la
1011
+ // lleva: es lo suyo.
1012
+ const dead = only === undefined || deadDetail === 'full' ? read : read.map(({ error: _error, ...safe }) => safe);
1013
+ // El censo COMPLETO (cierre alpha.5, 🟠 2): lo que `#parkedKeys` siembra y,
1014
+ // desde 2.4.0-alpha.8, lo que cuenta `deadCount` (`dead.length` miente al
1015
+ // llegar a `batchSize`). Una sola lectura de más, y solo si no cabía.
1016
+ const census = dead.length < batchSize ? dead : await _a.#deadLetters(outbox, PARKED_CENSUS_MAX);
1017
+ const deadCount = census.length;
1018
+ /**
1019
+ * Lo aparcado que se pidió (con `only`) o todo lo aparcado, para `entries`.
1020
+ * Cierre-2 · C2 (gemelo): con `only` sale del CENSO completo si la lectura se
1021
+ * cortó en `batchSize` — si no, un id pedido aparcado más allá del primer lote
1022
+ * era `missing` («no estaba ni pendiente ni aparcado») estando aparcado.
1023
+ */
1024
+ const deadSource = only !== undefined && read.length >= batchSize ? census : read;
1025
+ const deadEntries = deadSource
1026
+ .filter((item) => only === undefined || only.has(String(item.id)))
1027
+ .map((item) => ({
1028
+ id: item.id,
1029
+ change: item.change,
1030
+ status: 'dead',
1031
+ attempts: item.attempts,
1032
+ ...(item.error === undefined ? {} : { reason: item.error }),
1033
+ }));
1034
+ /**
1035
+ * **Qué fue de los ids pedidos que la pasada NO vio** (cierre-2 de alpha.10 ·
1036
+ * C2). Hasta el cierre-2 todos eran `missing`; pero un id fuera del lote porque
1037
+ * la pasada alcanzó `limit` sigue PENDIENTE. Se pregunta a la cola —filtrada por
1038
+ * esos ids, sin límite propio, UNA lectura y solo si hay alguno sin ver—: lo que
1039
+ * sigue pendiente es `notEvaluated`; el resto, `missing` (no está en la cola: lo
1040
+ * aparcado ya está en `seen`).
1041
+ */
1042
+ const unseenOf = async (seen) => {
1043
+ if (only === undefined)
1044
+ return { missing: [], notEvaluated: [] };
1045
+ const unseen = [...only.values()].filter((id) => !seen.has(id)).map((id) => _a.#rawId(onlyIds, id));
1046
+ if (unseen.length === 0)
1047
+ return { missing: [], notEvaluated: [] };
1048
+ const stillPending = await outbox.pending(unseen.length, undefined, { ids: unseen });
1049
+ _a.#assertOnlyHonoured(stillPending, new Set(unseen.map(String)));
1050
+ const pendingIds = new Set(stillPending.map((item) => String(item.id)));
706
1051
  return {
707
- applied: [],
708
- failed: null,
709
- failures: [],
710
- deferred: [],
711
- dead,
712
- busy: true,
713
- remaining: (await outbox.pending(1)).length > 0,
714
- dryRun: false,
715
- wouldApply: [],
1052
+ missing: unseen.filter((id) => !pendingIds.has(String(id))),
1053
+ notEvaluated: unseen.filter((id) => pendingIds.has(String(id))),
716
1054
  };
1055
+ };
1056
+ return { only, filter, dead, census, deadCount, deadEntries, unseenOf };
1057
+ }
1058
+ /** El reporte de una pasada que NO entró: otra tiene el lease. Nada aplicado, nada tocado. */
1059
+ async #busyReport(outbox, inputs) {
1060
+ const { remaining, queue } = await this.#queueStatus(outbox);
1061
+ return {
1062
+ applied: [],
1063
+ failed: null,
1064
+ failures: [],
1065
+ deferred: [],
1066
+ dead: inputs.dead,
1067
+ deadCount: inputs.deadCount,
1068
+ queue,
1069
+ busy: true,
1070
+ remaining,
1071
+ dryRun: false,
1072
+ wouldApply: [],
1073
+ entries: inputs.deadEntries,
1074
+ missing: [],
1075
+ notEvaluated: [],
1076
+ };
1077
+ }
1078
+ /**
1079
+ * **La cabeza GLOBAL de la cola y su edad** (2.4.0-alpha.8; panel relay 2,
1080
+ * cuestión 5). Es el `pending(1)` que la pasada ya hacía al final para
1081
+ * `remaining` —cero consultas nuevas—, leído SIN filtro aunque haya `only`:
1082
+ * la edad, como `remaining` y `dead`, es de la cola entera. La edad la mide
1083
+ * el reloj INYECTABLE del manager (`#wallMs`), nunca `Date.now()`.
1084
+ */
1085
+ async #queueStatus(outbox) {
1086
+ const head = await outbox.pending(1);
1087
+ return { remaining: head.length > 0, queue: this.#queueOf(head[0]) };
1088
+ }
1089
+ /**
1090
+ * La edad de una cabeza: `Math.max(0, ahora − createdAt)` —nunca negativa:
1091
+ * el sello lo puso el reloj de OTRA réplica, con su skew—; sin cabeza o sin
1092
+ * `createdAt` (una outbox de terceros que no lo trae) ⇒ `null` en los dos
1093
+ * campos, jamás `0` ni «ahora».
1094
+ */
1095
+ #queueOf(head) {
1096
+ const createdAt = head?.createdAt;
1097
+ if (!(createdAt instanceof Date) || Number.isNaN(createdAt.getTime()))
1098
+ return { oldestPendingAt: null, pendingAgeMs: null };
1099
+ return { oldestPendingAt: createdAt, pendingAgeMs: Math.max(0, this.#root().#wallMs() - createdAt.getTime()) };
1100
+ }
1101
+ /**
1102
+ * **El lease del escritor único, con espera acotada** (2.4.0-alpha.8;
1103
+ * `waitForLeaseMs`, panel relay 2, cuestión 7). Con `0` (el default) es
1104
+ * UN intento y `null` = `busy`, como siempre — el caso «dos pasadas a la vez
1105
+ * ⇒ busy» no cambia. Con un tope, reintenta `acquire()` cada
1106
+ * `RELAY_LEASE_POLL_MS` hasta vencerlo: `busy` al vencer, jamás un cuelgue.
1107
+ * El reloj es el inyectable del manager (nunca `Date.now()` en `src/`).
1108
+ * Exige pool ≥ 2 (README): con pool 1 el que espera puede estar reteniendo
1109
+ * la única conexión que necesita el que tiene el lease.
1110
+ */
1111
+ async #acquireLease(outbox, waitForLeaseMs) {
1112
+ if (!outbox.acquire)
1113
+ return null;
1114
+ let lease = await outbox.acquire();
1115
+ if (lease !== null || waitForLeaseMs <= 0)
1116
+ return lease;
1117
+ const deadline = this.#root().#wallMs() + waitForLeaseMs;
1118
+ // Dos topes a la vez: el reloj de pared (inyectable) y el NÚMERO de sondeos —
1119
+ // con un reloj fijo el primero no vence jamás (medido por el auditor de alpha.8).
1120
+ const maxPolls = Math.ceil(waitForLeaseMs / RELAY_LEASE_POLL_MS);
1121
+ for (let polls = 0; lease === null && polls < maxPolls && this.#root().#wallMs() < deadline; polls++) {
1122
+ await new Promise((resolve) => setTimeout(resolve, RELAY_LEASE_POLL_MS));
1123
+ lease = await outbox.acquire();
1124
+ }
1125
+ return lease;
1126
+ }
1127
+ /** `waitForLeaseMs` normalizado: entero >= 0 (500 si no); ausente = 0. */
1128
+ static #waitMs(value) {
1129
+ if (value === undefined)
1130
+ return 0;
1131
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {
1132
+ throw new AuthorizationConfigError(`authz:scopes:relay: waitForLeaseMs debe ser un entero >= 0 en milisegundos (llegó ${String(value)})`);
717
1133
  }
718
- try {
719
- const driver = await this.driver();
720
- const applied = [];
721
- const deferred = [];
722
- const failures = [];
723
- /** Claves de scope contaminadas: lo que las toque se aplaza. */
724
- const blocked = new Set();
725
- // Una outbox que no marca lo aplicado devolvería el mismo pendiente
726
- // para siempre: el relay no puede quedarse dando vueltas ni
727
- // "arreglarlo" por su cuenta, así que lo denuncia (500) en cuanto
728
- // vuelve a ver un id que YA aplicó.
729
- const done = new Set();
730
- /** Ids que esta pasada dejó a propósito (fallo o aplazo): reaparecen. */
731
- const parked = new Set();
732
- /** El último id visto: la outbox pagina desde ahí (lo saltado se queda). */
733
- let after;
734
- outer: while (applied.length < limit) {
735
- // **La barrera del freeze se RE-AFIRMA por lote** (3b-8 · B3). La
736
- // mirada única de la entrada dejaba hasta `DEFAULT_RELAY_LIMIT`
737
- // (10.000) escrituras de árbol colándose DESPUÉS de que otra pasada
738
- // adquiriera el freeze durable: escrituras que no salen en ningún
739
- // contador de la pasada certificada y que pueden invalidar su
740
- // resultado. El trade-off documentado en freeze.ts cubre «una
741
- // escritura que ya pasó su barrera», no una pasada entera. El coste
742
- // (una lectura de la fila `id=2` por lote; 0,14 ms/escritura ya
743
- // medidos y aceptados) va fuera del camino caliente. Un freeze
744
- // adquirido a mitad corta AQUÍ con el 503 reintentable de siempre:
745
- // lo ya aplicado está marcado en la outbox (la pasada es reanudable)
746
- // y el resto sigue pendiente para después de la ventana.
747
- await this.#assertNotFrozen('authz:scopes:relay');
748
- const batch = await outbox.pending(Math.min(batchSize, limit - applied.length), after);
749
- if (batch.length === 0)
750
- break;
751
- let progress = false;
752
- for (const item of batch) {
753
- const id = String(item.id);
754
- if (done.has(id)) {
755
- throw new AuthorizationConfigError(`authz:scopes:relay: la outbox sigue devolviendo el cambio ${id} como pendiente después de markApplied. ` +
756
- 'Tu implementación de ScopeOutbox no marca lo aplicado; el relay para antes de dar vueltas para siempre.');
757
- }
758
- if (parked.has(id))
759
- continue;
760
- progress = true;
761
- after = item.id;
762
- if (applied.length >= limit)
763
- break outer;
764
- const keys = _a.#changeKeys(item.change);
765
- const collision = keys.find((key) => blocked.has(key));
766
- if (collision !== undefined) {
767
- for (const key of keys)
768
- blocked.add(key);
769
- parked.add(id);
770
- deferred.push({
771
- id: item.id,
772
- change: item.change,
773
- attempts: item.attempts,
774
- error: `aplazado: depende de ${collision}, que quedó sin aplicar en esta pasada`,
775
- });
1134
+ if (value > RELAY_WAIT_LEASE_MAX_MS) {
1135
+ throw new AuthorizationConfigError(`authz:scopes:relay: waitForLeaseMs = ${value} supera el tope RELAY_WAIT_LEASE_MAX_MS (${RELAY_WAIT_LEASE_MAX_MS} ms): ` +
1136
+ `una espera sin tope cuelga la petición del tenant; acorta la pasada (only/limit) en vez de esperar más.`);
1137
+ }
1138
+ return value;
1139
+ }
1140
+ /**
1141
+ * **La pasada, ya con el lease tomado**: lo que hasta 2.4.0-alpha.7 era el
1142
+ * cuerpo de `relayScopeChanges`. Compartida con `requeueScopeChanges({
1143
+ * apply })`, que la llama bajo SU lease con `only` = los ids reencolados
1144
+ * (auditor 🟠 5: tomar el lease dos veces era `busy` en verde).
1145
+ */
1146
+ async #drainScopeChanges(outbox, input) {
1147
+ const { limit, batchSize, only, filter, dead, census, deadCount, deadEntries, unseenOf } = input;
1148
+ const driver = await this.driver();
1149
+ const applied = [];
1150
+ const deferred = [];
1151
+ const failures = [];
1152
+ const entries = [];
1153
+ /**
1154
+ * Claves de scope contaminadas ⇒ el id que las contaminó y el ORIGEN del
1155
+ * bloqueo (alpha.5 · cierre, auditor 🟠 1). `parked: true` = la causa
1156
+ * raíz (transitiva) es una entrada APARCADA: tapona solo lo que concede
1157
+ * o recuelga (`attached`/`moved`), NUNCA un `detached` — si no, un
1158
+ * `attached` aplazado por aparcado congelaba la purga del nodo que el
1159
+ * tenant ya borró, para siempre (medido: el scope conservaba sus hechos
1160
+ * y su arista, y seguía concediendo; invariante 11). `parked: false` =
1161
+ * un FALLO real de esta pasada (o una pendiente anterior no pedida):
1162
+ * contamina todo, `detached` incluido — el orden entre un `attached`
1163
+ * que falló y la purga de ese mismo nodo sigue importando (3b-2h).
1164
+ */
1165
+ const blocked = new Map();
1166
+ /** Origen ya sondeado de entradas NO pedidas (alpha.6): `parked` = retenida solo por un aparcado. Se olvida en cuanto la pasada registra un bloqueo que no es de aparcado (podría contaminar una clave ya sondeada). */
1167
+ const origins = new Map();
1168
+ const block = (key, id, origin) => {
1169
+ const current = blocked.get(key);
1170
+ // Precedencia `failed > pending > parked` (2.4.0-alpha.8; desde el
1171
+ // cierre de alpha.5: un fallo real pisa a un aparcado, nunca al revés).
1172
+ if (current === undefined || BLOCK_RANK[origin] > BLOCK_RANK[current.origin])
1173
+ blocked.set(key, { id, origin });
1174
+ if (origin !== 'parked')
1175
+ origins.clear();
1176
+ };
1177
+ /**
1178
+ * **Las claves de lo APARCADO** (alpha.5 · R2a): taponan lo que concede
1179
+ * o recuelga y dejan pasar lo que retira. El censo es COMPLETO —no
1180
+ * depende de `limit` ni de `batchSize` (cierre, 🟠 2)— con su propia
1181
+ * cota; el reporte sigue enseñando las primeras `batchSize`.
1182
+ */
1183
+ const parkedKeys = _a.#parkedKeys(census);
1184
+ // Una outbox que no marca lo aplicado devolvería el mismo pendiente
1185
+ // para siempre: el relay no puede quedarse dando vueltas ni
1186
+ // "arreglarlo" por su cuenta, así que lo denuncia (500) en cuanto
1187
+ // vuelve a ver un id que YA aplicó.
1188
+ const done = new Set();
1189
+ /** Ids que esta pasada dejó a propósito (fallo o aplazo): reaparecen. */
1190
+ const parked = new Set();
1191
+ /** El último id visto: la outbox pagina desde ahí (lo saltado se queda). */
1192
+ let after;
1193
+ const defer = (item, keys, reason, error, origin) => {
1194
+ for (const key of keys)
1195
+ block(key, item.id, origin);
1196
+ parked.add(String(item.id));
1197
+ deferred.push({ id: item.id, change: item.change, attempts: item.attempts, error });
1198
+ // `origin` se DERIVA del bloqueo (2.4.0-alpha.8), jamás del prefijo de `reason`.
1199
+ entries.push({ id: item.id, change: item.change, status: 'deferred', reason, attempts: item.attempts, origin });
1200
+ };
1201
+ outer: while (applied.length < limit) {
1202
+ // **La barrera del freeze se RE-AFIRMA por lote** (3b-8 · B3). La
1203
+ // mirada única de la entrada dejaba hasta `DEFAULT_RELAY_LIMIT`
1204
+ // (10.000) escrituras de árbol colándose DESPUÉS de que otra pasada
1205
+ // adquiriera el freeze durable: escrituras que no salen en ningún
1206
+ // contador de la pasada certificada y que pueden invalidar su
1207
+ // resultado. El trade-off documentado en freeze.ts cubre «una
1208
+ // escritura que ya pasó su barrera», no una pasada entera. El coste
1209
+ // (una lectura de la fila `id=2` por lote; 0,14 ms/escritura ya
1210
+ // medidos y aceptados) va fuera del camino caliente. Un freeze
1211
+ // adquirido a mitad corta AQUÍ con el 503 reintentable de siempre:
1212
+ // lo ya aplicado está marcado en la outbox (la pasada es reanudable)
1213
+ // y el resto sigue pendiente para después de la ventana.
1214
+ await this.#assertNotFrozen('authz:scopes:relay');
1215
+ const batch = await outbox.pending(Math.min(batchSize, limit - applied.length), after, filter);
1216
+ if (batch.length === 0)
1217
+ break;
1218
+ _a.#assertOnlyHonoured(batch, only);
1219
+ let progress = false;
1220
+ for (const item of batch) {
1221
+ const id = String(item.id);
1222
+ if (done.has(id)) {
1223
+ throw new AuthorizationConfigError(`authz:scopes:relay: la outbox sigue devolviendo el cambio ${id} como pendiente después de markApplied. ` +
1224
+ 'Tu implementación de ScopeOutbox no marca lo aplicado; el relay para antes de dar vueltas para siempre.');
1225
+ }
1226
+ if (parked.has(id))
1227
+ continue;
1228
+ progress = true;
1229
+ after = item.id;
1230
+ if (applied.length >= limit)
1231
+ break outer;
1232
+ const keys = _a.#changeKeys(item.change);
1233
+ // Una purga solo la frena un bloqueo cuyo origen sea un FALLO real;
1234
+ // el que nace de un aparcado la deja pasar (invariante 11).
1235
+ const collision = keys.find((key) => {
1236
+ const hit = blocked.get(key);
1237
+ return hit !== undefined && !(item.change.op === 'detached' && hit.origin === 'parked');
1238
+ });
1239
+ if (collision !== undefined) {
1240
+ const by = blocked.get(collision);
1241
+ defer(item, keys, `depends-on:${by.id}`, `aplazado: depende de ${collision}, que quedó sin aplicar en esta pasada (#${by.id})`, by.origin);
1242
+ continue;
1243
+ }
1244
+ // Lo aparcado tapona lo que CONCEDE o recuelga; una purga pasa.
1245
+ if (item.change.op !== 'detached') {
1246
+ const parkedKey = keys.find((key) => parkedKeys.has(key));
1247
+ if (parkedKey !== undefined) {
1248
+ const by = parkedKeys.get(parkedKey);
1249
+ defer(item, keys, `parked:${by}`, `aplazado: depende de ${parkedKey}, que nombra la entrada APARCADA #${by} (parked:${by}); no se aplica hasta que alguien la mire`, 'parked');
776
1250
  continue;
777
1251
  }
778
- try {
779
- await this.#applyScopeChange(driver, item);
780
- }
781
- catch (error) {
782
- const message = error instanceof Error ? error.message : String(error);
783
- await outbox.markFailed(item.id, message);
784
- for (const key of keys)
785
- blocked.add(key);
786
- parked.add(id);
787
- failures.push({ id: item.id, change: item.change, error: message });
788
- continue;
1252
+ }
1253
+ // Con `only`, la sonda contra la cola ENTERA: una pendiente anterior
1254
+ // que comparta clave y no se pidió ⇒ aplazado, nunca fuera de orden.
1255
+ // **Y la sonda hereda el ORIGEN de esa anterior** (alpha.6, auditor 🟡 A
1256
+ // de alpha.5): si la anterior está —transitivamente— retenida solo por
1257
+ // un APARCADO, es el mismo bloqueo `parked:` que en la pasada completa
1258
+ // deja pasar la purga (invariante 11): el `detached` SE APLICA. Si la
1259
+ // anterior es un `attached`/`moved` pendiente de verdad (aplicable, o
1260
+ // retenido por un FALLO real), el `detached` sigue `deferred`: aplicarlo
1261
+ // antes que un `moved` pendiente del mismo scope dejaría deriva.
1262
+ if (only !== undefined) {
1263
+ const earlier = await this.#dependencyOf(outbox, item, batchSize);
1264
+ if (earlier !== null) {
1265
+ const origin = await this.#blockOriginOf(outbox, earlier, blocked, parkedKeys, batchSize, origins);
1266
+ if (!(item.change.op === 'detached' && origin === 'parked')) {
1267
+ const shared = _a.#changeKeys(earlier.change).find((key) => keys.includes(key)) ?? keys[0];
1268
+ defer(item, keys, `depends-on:${earlier.id}`, `aplazado: depende de ${shared}, que nombra la entrada anterior #${earlier.id} todavía sin aplicar (depends-on:${earlier.id}); ` +
1269
+ (origin === 'parked' ? 'espera a una entrada APARCADA; mira dead()' : 'corre el relay completo'), origin);
1270
+ continue;
1271
+ }
789
1272
  }
790
- await outbox.markApplied(item.id);
791
- done.add(id);
792
- applied.push({ id: item.id, change: item.change, attempts: item.attempts });
793
1273
  }
794
- // Una outbox que ignora `after` devuelve el mismo lote atascado: la
795
- // pasada termina aquí en vez de dar vueltas (lo que quede, y lo que
796
- // haya detrás, sigue pendiente para la siguiente).
797
- if (!progress)
798
- break;
1274
+ try {
1275
+ await this.#applyScopeChange(driver, item);
1276
+ }
1277
+ catch (error) {
1278
+ const message = error instanceof Error ? error.message : String(error);
1279
+ const transient = _a.#isTransientRelayFailure(error);
1280
+ await outbox.markFailed(item.id, message, { transient });
1281
+ for (const key of keys)
1282
+ block(key, item.id, 'failed');
1283
+ parked.add(id);
1284
+ failures.push({ id: item.id, change: item.change, error: message, transient });
1285
+ entries.push({ id: item.id, change: item.change, status: 'failed', reason: message, attempts: item.attempts });
1286
+ continue;
1287
+ }
1288
+ await outbox.markApplied(item.id);
1289
+ done.add(id);
1290
+ applied.push({ id: item.id, change: item.change, attempts: item.attempts });
1291
+ entries.push({ id: item.id, change: item.change, status: 'applied', attempts: item.attempts });
799
1292
  }
800
- return {
801
- applied,
802
- failed: failures[0] ?? null,
803
- failures,
804
- deferred,
805
- dead,
806
- busy: false,
807
- remaining: (await outbox.pending(1)).length > 0,
808
- dryRun: false,
809
- wouldApply: [],
810
- };
1293
+ // Una outbox que ignora `after` devuelve el mismo lote atascado: la
1294
+ // pasada termina aquí en vez de dar vueltas (lo que quede, y lo que
1295
+ // haya detrás, sigue pendiente para la siguiente).
1296
+ if (!progress)
1297
+ break;
1298
+ }
1299
+ entries.push(...deadEntries);
1300
+ // La cabeza global de la cola: `remaining` y, desde alpha.8, su EDAD.
1301
+ const { remaining, queue } = await this.#queueStatus(outbox);
1302
+ return {
1303
+ applied,
1304
+ failed: failures[0] ?? null,
1305
+ failures,
1306
+ deferred,
1307
+ dead,
1308
+ deadCount,
1309
+ queue,
1310
+ busy: false,
1311
+ remaining,
1312
+ dryRun: false,
1313
+ wouldApply: [],
1314
+ entries,
1315
+ ...(await unseenOf(new Set(entries.map((e) => String(e.id))))),
1316
+ };
1317
+ }
1318
+ /**
1319
+ * `only` normalizado (alpha.5 · R1): un conjunto de ids como cadena (el id
1320
+ * de `sqlScopeOutbox` es un entero que según el motor y el driver llega
1321
+ * como `number` o como `string`; comparar con `===` sería un no-op
1322
+ * silencioso). `undefined` = sin filtro. **`[]` es 500**, nunca «sin
1323
+ * filtro»: un `filter` vacío del consumidor —o una outbox de terceros sin
1324
+ * recibo, que deja `queued: []`— no puede acabar drenando la cola entera
1325
+ * bajo el lease de una petición HTTP, ni en un reporte vacío en verde.
1326
+ */
1327
+ static #onlyOf(only) {
1328
+ if (only === undefined)
1329
+ return undefined;
1330
+ if (!Array.isArray(only) || only.some((id) => typeof id !== 'number' && typeof id !== 'string')) {
1331
+ throw new AuthorizationConfigError(`authz:scopes:relay: 'only' debe ser una lista de ids (number | string); llegó ${JSON.stringify(only)}`);
1332
+ }
1333
+ if (only.length === 0) {
1334
+ throw new AuthorizationConfigError("authz:scopes:relay: 'only' está vacío. Un recibo vacío significa que no hay nada que relevar (sin outbox el cambio ya " +
1335
+ 'se aplicó en línea; con una outbox de terceros sin recibo no hay `only`): relevar «nada» no es relevar «todo». ' +
1336
+ 'Quita `only` para drenar la cola entera.');
1337
+ }
1338
+ return new Set(only.map(String));
1339
+ }
1340
+ /** El id tal como lo pasó el llamante en `only` (para `missing`). */
1341
+ static #rawId(only, key) {
1342
+ return only.find((id) => String(id) === key) ?? key;
1343
+ }
1344
+ /**
1345
+ * Una outbox que IGNORA `filter.ids` devuelve lo que no se pidió: el
1346
+ * backlog ajeno se aplicaría dentro de la petición del consumidor, en
1347
+ * silencio. Se detecta y se para (500), como el `markApplied` no honrado.
1348
+ */
1349
+ static #assertOnlyHonoured(batch, only) {
1350
+ if (only === undefined)
1351
+ return;
1352
+ const stranger = batch.find((item) => !only.has(String(item.id)));
1353
+ if (stranger !== undefined) {
1354
+ throw new AuthorizationConfigError(`authz:scopes:relay: la outbox devolvió el cambio ${String(stranger.id)}, que no está en 'only'. ` +
1355
+ 'Tu implementación de ScopeOutbox no honra `pending(limit, after, { ids })`: el relay para antes de aplicar ' +
1356
+ 'lo que no se pidió (filtra en el puerto, no en memoria).');
1357
+ }
1358
+ }
1359
+ /**
1360
+ * La caída del BACKEND no dice nada de la entrada (alpha.5 · R2a): un
1361
+ * `AuthorizationBackendError` (503; el timeout es uno), y en general
1362
+ * cualquier 503 (el freeze, el resolutor caído). Todo lo demás —422, 500,
1363
+ * `E_AUTHZ_SCOPE_TREE_DRIFT`, el padre inexistente, un error sin status de
1364
+ * un driver de terceros— es de la entrada y cuenta hacia el aparcado.
1365
+ */
1366
+ static #isTransientRelayFailure(error) {
1367
+ if (error instanceof AuthorizationBackendError)
1368
+ return true;
1369
+ return typeof error === 'object' && error !== null && error.status === 503;
1370
+ }
1371
+ /**
1372
+ * Las claves de scope de lo APARCADO ⇒ el id aparcado que las nombra
1373
+ * (alpha.5 · R2a). El censo lo lee `#relayInputs`: el `dead` ya leído si
1374
+ * cabe entero en un lote (menos de `batchSize` filas = no hay más); si no,
1375
+ * la cola ENTERA hasta `PARKED_CENSUS_MAX` — nunca acotado por `limit`
1376
+ * (cierre, 🟠 2: con `--limit 50` o `{ only, limit: 1 }` la aparcada 101 no
1377
+ * taponaba y el `attached` colgaba del padre viejo)—, y si la cota se
1378
+ * alcanza la pasada se niega (500): un tapón incompleto no es un tapón.
1379
+ */
1380
+ static #parkedKeys(census) {
1381
+ if (census.length >= PARKED_CENSUS_MAX) {
1382
+ throw new AuthorizationConfigError(`authz:scopes:relay: la outbox tiene ${PARKED_CENSUS_MAX} o más entradas APARCADAS y el censo de sus claves no cabe ` +
1383
+ `(PARKED_CENSUS_MAX): sin el censo entero el tapón por clave sería ciego y un attached podría colgarse del padre ` +
1384
+ `VIEJO. No se ha aplicado nada. Mira dead(), reencola o reconcilia antes de volver a relevar.`);
1385
+ }
1386
+ const keys = new Map();
1387
+ for (const item of census) {
1388
+ // **Solo la clave del HIJO** (2.4.0-alpha.8 · variante B; dueño: sí).
1389
+ // Una aparcada dice que la POSICIÓN DE SU HIJO en el store es
1390
+ // incorrecta; no dice nada de su padre nuevo: un `attached(otraUnit,
1391
+ // orgA)` detrás de un `moved(unit → orgA)` aparcado escribe otra arista
1392
+ // y conmuta (medido, `m7-tapon.ts`: se aplazaba sin motivo y el store
1393
+ // conservaba el padre VIEJO de todo lo que colgara de orgA). Lo que SÍ
1394
+ // nombra al hijo —`attached(child, unit)`, `moved(unit → orgC)`— sigue
1395
+ // taponado. Vive SOLO aquí, en la siembra: al COMPARAR (`#changeKeys`
1396
+ // en `blocked`, el respaldo de la sonda) van siempre las dos claves, o
1397
+ // el caso 1136 se pierde (trampa medida por el juez).
1398
+ const key = scopeKey(item.change.child);
1399
+ if (!keys.has(key))
1400
+ keys.set(key, item.id);
1401
+ }
1402
+ return keys;
1403
+ }
1404
+ /**
1405
+ * **La sonda de dependencia de `only`** (alpha.5 · R1): la pendiente más
1406
+ * antigua ANTERIOR a `item` que comparta clave de scope con ella, o `null`.
1407
+ * Con `earlierPending` es una consulta de existencia (0,69 ms medidos);
1408
+ * sin ella se pagina `pending()` desde el principio hasta `item.id` —
1409
+ * correcto, O(cola)— y si la cola no se deja recorrer en
1410
+ * `RELAY_WINDOW_MAX_PAGES` lotes se para (500 nombrando el método), nunca
1411
+ * se aplica sin saber.
1412
+ */
1413
+ async #dependencyOf(outbox, item, batchSize) {
1414
+ const keys = _a.#changeKeys(item.change);
1415
+ if (typeof outbox.earlierPending === 'function') {
1416
+ // La misma lista que `#changeKeys` (la raíz exenta, alpha.8): el SQL del
1417
+ // puerto sí sabe casar `parent_type = 'app'`; el manager no se lo pide.
1418
+ return outbox.earlierPending(item.id, _a.#changeScopes(item.change));
1419
+ }
1420
+ const mine = String(item.id);
1421
+ let after;
1422
+ for (let page = 0; page < RELAY_WINDOW_MAX_PAGES; page++) {
1423
+ const batch = await outbox.pending(batchSize, after);
1424
+ for (const earlier of batch) {
1425
+ if (String(earlier.id) === mine)
1426
+ return null;
1427
+ if (_a.#changeKeys(earlier.change).some((key) => keys.includes(key)))
1428
+ return earlier;
1429
+ }
1430
+ if (batch.length < batchSize)
1431
+ return null;
1432
+ after = batch[batch.length - 1].id;
1433
+ }
1434
+ throw new AuthorizationConfigError(`authz:scopes:relay: no se pudo sondear si el cambio ${mine} depende de una entrada anterior: la cola tiene más de ` +
1435
+ `${RELAY_WINDOW_MAX_PAGES * batchSize} pendientes por delante y tu ScopeOutbox no implementa earlierPending(). ` +
1436
+ 'Implementa la sonda, o releva sin `only`.');
1437
+ }
1438
+ /**
1439
+ * **El ORIGEN del bloqueo de una entrada NO pedida** (2.4.0-alpha.6): ¿en la
1440
+ * pasada COMPLETA saldría `deferred` por un APARCADO —directo (`parked:`) o
1441
+ * heredado (`depends-on:` de otra que lo está)— y no por un fallo real ni
1442
+ * porque simplemente se aplicaría? `true` = retenida solo por un aparcado.
1443
+ * Es la misma regla que aplica `block`/`defer` a lo que la pasada SÍ procesa,
1444
+ * simulada hacia atrás sobre lo que no procesa:
1445
+ * 1. una clave suya en `blocked` (esta pasada) con origen FALLO ⇒ fallo;
1446
+ * 2. `attached`/`moved` con una clave en `parkedKeys`, o en `blocked` con
1447
+ * origen aparcado ⇒ aparcado;
1448
+ * 3. si no, su propia anterior con clave compartida (`earlierPending`):
1449
+ * ninguna ⇒ se aplicaría ⇒ NO es aparcado (la pedida espera su turno);
1450
+ * alguna ⇒ el origen de ESA (recursivo, ids decrecientes, memoizado,
1451
+ * `RELAY_ORIGIN_MAX_HOPS`; pasada la cota ⇒ fallo, hacia lo seguro).
1452
+ * Un `detached` anterior nunca es «aparcado» (una purga pasa; si sigue
1453
+ * pendiente es porque se aplicará): la pedida espera detrás.
1454
+ */
1455
+ async #blockOriginOf(outbox, entry, blocked, parkedKeys, batchSize, memo, hops = 0) {
1456
+ const id = String(entry.id);
1457
+ const known = memo.get(id);
1458
+ if (known !== undefined)
1459
+ return known;
1460
+ const decide = (origin) => {
1461
+ memo.set(id, origin);
1462
+ return origin;
1463
+ };
1464
+ // Pasada la cota, hacia lo seguro: como un fallo real.
1465
+ if (hops >= RELAY_ORIGIN_MAX_HOPS)
1466
+ return decide('failed');
1467
+ const keys = _a.#changeKeys(entry.change);
1468
+ const hits = keys.map((key) => blocked.get(key)).filter((hit) => hit !== undefined);
1469
+ // Desde 2.4.0-alpha.8 el origen es TRI-estado (`failed > pending > parked`):
1470
+ // la decisión aplicar/aplazar no cambia (solo `parked` exime a la purga);
1471
+ // lo que cambia es que el reporte distingue «espera a una pendiente de
1472
+ // verdad» de «bloqueada por un fallo».
1473
+ if (hits.some((hit) => hit.origin === 'failed'))
1474
+ return decide('failed');
1475
+ if (hits.some((hit) => hit.origin === 'pending'))
1476
+ return decide('pending');
1477
+ if (entry.change.op !== 'detached') {
1478
+ if (hits.length > 0 || keys.some((key) => parkedKeys.has(key)))
1479
+ return decide('parked');
1480
+ }
1481
+ const earlier = await this.#dependencyOf(outbox, entry, batchSize);
1482
+ // Ninguna anterior: se aplicaría en la pasada completa ⇒ la pedida espera a una PENDIENTE de verdad.
1483
+ if (earlier === null)
1484
+ return decide('pending');
1485
+ return decide(await this.#blockOriginOf(outbox, earlier, blocked, parkedKeys, batchSize, memo, hops + 1));
1486
+ }
1487
+ /**
1488
+ * **Devuelve entradas APARCADAS a la cola** (2.4.0-alpha.5 · R2b; panel
1489
+ * relay, cuestión 2). Es lo que hay detrás de `node ace authz:scopes:requeue
1490
+ * --id`. Operación de PLATAFORMA como `relayScopeChanges` y `driver()`: sin
1491
+ * actor, sin `within`, **no se expone por HTTP**.
1492
+ *
1493
+ * **La sonda anti-reversión vive AQUÍ, no en la outbox**: reaplicar una
1494
+ * entrada ANTERIOR a otra ya APLICADA sobre la misma clave de scope
1495
+ * reescribe el árbol del store en un orden que nunca existió — el `moved`
1496
+ * viejo tras el nuevo deja el padre VIEJO con UN solo padre, así que
1497
+ * `assertOneParent` no lo delata y el tenant antiguo recupera el acceso
1498
+ * (auditor 🔴 2, `relay-auditor-5.ts`, medido). Por eso, antes de tocar la
1499
+ * cola, cada aparcada pedida se compara contra las aplicadas posteriores
1500
+ * que compartan clave (`outbox.laterApplied`, espejo de `earlierPending`)
1501
+ * y si hay alguna se NIEGA con 500 `E_AUTHZ_REQUEUE_REFUSED` nombrándola —
1502
+ * todo o nada, cero filas tocadas—. El remedio correcto es
1503
+ * `authz:reconcile --to=<driver>`, que recalcula con el árbol de HOY. La
1504
+ * sonda es del manager porque necesita las claves del cambio
1505
+ * (`#changeKeys`); `outbox.requeue` queda mecánico y una outbox de
1506
+ * terceros la hereda gratis.
1507
+ *
1508
+ * **`force`** salta la sonda y devuelve el diff en `overrides`. Existe para
1509
+ * el comando ace —que lo imprime y exige el flag explícito (decisión del
1510
+ * dueño del 2026-09-09 (2))— y para nada más: llamarlo con `force: true`
1511
+ * desde un endpoint es policy en una API sin policy.
1512
+ *
1513
+ * `attempts` queda en `maxAttempts − 1` (lo hace la outbox): si la causa
1514
+ * sigue rota la entrada vuelve a aparcarse en UNA pasada y contamina su
1515
+ * clave solo en esa pasada —un requeue automatizado de lo irreparable no
1516
+ * es un grifo de aplazamiento permanente (auditor 🔴 2, segunda mitad)—; si
1517
+ * el operador la arregló, entra a la primera. No hay bucle: lo aparcado no
1518
+ * vuelve solo a `pending()`.
1519
+ *
1520
+ * Exige `dead`, `requeue` y `laterApplied` en el puerto (500
1521
+ * `E_AUTHZ_UNSUPPORTED` nombrando el que falte): sin censo no se sabe qué
1522
+ * se reencola y sin sonda no hay requeue. Sin `apply` no pasa por la
1523
+ * barrera del freeze: no escribe en el backend; la pasada que aplique la
1524
+ * entrada sí la pasa.
1525
+ *
1526
+ * **`apply: true` — reencolar Y relevar en la misma llamada** (2.4.0-alpha.8;
1527
+ * panel relay 2, cuestión 4; decisión del dueño 2026-09-10 (4)). Medido
1528
+ * (tester P14): la reencolada vuelve con su id ORIGINAL (menor) y la
1529
+ * siguiente pasada `only` del tenant salía `deferred · depends-on:` — el
1530
+ * incidente no se cerraba con un comando. Con `apply`, tras reencolar se
1531
+ * releva SOLO eso (`only` = exactamente los ids reencolados: los
1532
+ * dependientes NO se arrastran, quedan `deferred`/pendientes y el reporte lo
1533
+ * dice —«`only` es mi recibo»—) y el reporte del relay viaja en
1534
+ * `report.relay`. Cuatro condiciones, todas fijadas con caso: (1) **un solo
1535
+ * lease** para toda la llamada (tomarlo dos veces era `busy` en verde,
1536
+ * auditor 🟠 5) — se toma PRIMERO, y si está ocupado es `relay.busy` con
1537
+ * CERO filas reencoladas (reencolar y decir `busy` dejaba `attempts = max −
1538
+ * 1` y la entrada se volvía a aparcar contaminando su clave); (2) **pasa la
1539
+ * barrera del freeze** ANTES de tocar nada (con `apply` escribe el árbol);
1540
+ * (3) **excluyente con `force`** (500 juntos: con las dos, revertir el árbol
1541
+ * pasaba de dos llamadas de plataforma a una, auditor 🟠 6); (4) la sonda
1542
+ * anti-reversión corre ANTES de reencolar, como siempre. `waitForLeaseMs`
1543
+ * (solo con `apply`) es la misma espera acotada que en el relay, para que
1544
+ * el comando que cierra el incidente no pierda siempre contra el cron.
1545
+ */
1546
+ async requeueScopeChanges(ids, options = {}) {
1547
+ _a.#assertKnownOptions('requeueScopeChanges', options, REQUEUE_OPTIONS);
1548
+ const apply = options.apply === true;
1549
+ if (apply && options.force === true) {
1550
+ throw new AuthorizationConfigError('authz:scopes:requeue: apply y force no se combinan: con las dos, revertir el árbol del store por encima de lo ya ' +
1551
+ 'aplicado sería UNA sola llamada. Reencola con --force y releva después a sabiendas, o usa --apply sin --force. ' +
1552
+ 'No se ha hecho nada.');
1553
+ }
1554
+ if (!apply && options.waitForLeaseMs !== undefined) {
1555
+ throw new AuthorizationConfigError('authz:scopes:requeue: waitForLeaseMs solo tiene sentido con apply (sin apply no se toma ningún lease). No se ha hecho nada.');
1556
+ }
1557
+ const waitForLeaseMs = _a.#waitMs(options.waitForLeaseMs);
1558
+ // Con `apply` se ESCRIBE el árbol: la barrera del freeze va antes de nada.
1559
+ if (apply)
1560
+ await this.#assertNotFrozen('authz:scopes:requeue --apply');
1561
+ const outbox = this.#outbox();
1562
+ if (!outbox) {
1563
+ throw new AuthorizationConfigError("authz:scopes:requeue necesita 'scopes.outbox' en config/authorization.ts: sin cola no hay nada aparcado que reencolar.");
1564
+ }
1565
+ const wanted = _a.#requeueIds(ids);
1566
+ for (const method of ['dead', 'requeue', 'laterApplied']) {
1567
+ if (typeof outbox[method] !== 'function') {
1568
+ throw new UnsupportedOperationError(method, 'authz:scopes:requeue', 'scopes.outbox', method === 'laterApplied'
1569
+ ? 'Sin la sonda anti-reversión (laterApplied: las entradas APLICADAS posteriores que comparten clave) no hay ' +
1570
+ 'requeue: reaplicar una entrada vieja por encima de una nueva revierte el árbol del store en silencio.'
1571
+ : method === 'dead'
1572
+ ? 'Sin dead() no se sabe qué está aparcado, y solo lo aparcado se reencola.'
1573
+ : 'Devuelve la entrada aparcada a pending() con attempts = maxAttempts − 1, todo o nada.');
1574
+ }
1575
+ }
1576
+ // Con `apply`, el lease PRIMERO (una vez para toda la llamada): ocupado ⇒
1577
+ // `busy` con cero filas tocadas. Sin `apply` no hay lease que tomar.
1578
+ const lease = apply ? await this.#acquireLease(outbox, waitForLeaseMs) : null;
1579
+ if (apply && outbox.acquire && lease === null) {
1580
+ const inputs = await _a.#relayInputs(outbox, [...wanted], DEFAULT_RELAY_BATCH);
1581
+ return { requeued: [], overrides: [], forced: false, relay: await this.#busyReport(outbox, inputs) };
1582
+ }
1583
+ try {
1584
+ return await this.#requeueHeld(outbox, wanted, options.force === true, apply);
811
1585
  }
812
1586
  finally {
813
1587
  await lease?.release();
814
1588
  }
815
1589
  }
1590
+ /** `requeueScopeChanges` con el lease (si `apply`) ya tomado: censo, sonda anti-reversión, requeue y —con `apply`— el relay de esos ids. */
1591
+ async #requeueHeld(outbox, wanted, force, apply) {
1592
+ // El censo de lo aparcado (acotado como el del relay): solo eso se reencola.
1593
+ const parked = await outbox.dead(DEFAULT_RELAY_LIMIT);
1594
+ const byId = new Map();
1595
+ for (const item of parked)
1596
+ byId.set(String(item.id), item);
1597
+ const unknown = wanted.filter((id) => !byId.has(String(id)));
1598
+ if (unknown.length > 0) {
1599
+ throw new AuthorizationConfigError(`authz:scopes:requeue: ${unknown.map(String).join(', ')} no está(n) aparcada(s) —no existe, ya se aplicó, o sigue ` +
1600
+ `pendiente con intentos por gastar—: no se ha reencolado NADA (todo o nada). Solo se reencola lo que sale por ` +
1601
+ `dead(); una entrada aplicada no revive jamás.`);
1602
+ }
1603
+ const conflicts = [];
1604
+ for (const id of wanted) {
1605
+ const item = byId.get(String(id));
1606
+ // La misma lista que `#changeKeys` (la raíz exenta, alpha.8): dos altas de
1607
+ // tenant no se revierten entre sí, y el conflicto que salía era falso.
1608
+ const scopes = _a.#changeScopes(item.change);
1609
+ // Se pide UNA de más para saber si el diff que va a leer el operador está cortado (cierre, ⚪ 6).
1610
+ const later = await outbox.laterApplied(item.id, scopes, DEFAULT_RELAY_BATCH + 1);
1611
+ const applied = later.slice(0, DEFAULT_RELAY_BATCH);
1612
+ if (applied.length > 0) {
1613
+ conflicts.push({ parked: item, applied, ...(later.length > DEFAULT_RELAY_BATCH ? { truncated: true } : {}) });
1614
+ }
1615
+ }
1616
+ if (conflicts.length > 0 && !force) {
1617
+ const detail = conflicts
1618
+ .map((conflict) => {
1619
+ const keys = _a.#changeKeys(conflict.parked.change);
1620
+ const shared = conflict.applied.map((entry) => {
1621
+ const key = _a.#changeKeys(entry.change).find((k) => keys.includes(k)) ?? keys[0];
1622
+ return `#${entry.id} (${entry.change.op}, clave ${key})`;
1623
+ });
1624
+ return `#${conflict.parked.id} (${conflict.parked.change.op}) revertiría ${shared.join(', ')}`;
1625
+ })
1626
+ .join('; ');
1627
+ throw new ScopeRequeueRefusedError(`authz:scopes:requeue: reencolar reaplicaría un cambio ANTERIOR a otro ya APLICADO sobre la misma clave de scope, ` +
1628
+ `y el árbol del store quedaría en un orden que nunca existió: ${detail}. No se ha reencolado nada. ` +
1629
+ `Para recalcular con el árbol de HOY: authz:reconcile --to=<driver>. Si de verdad quieres revertir, el comando ` +
1630
+ `ace admite --force (imprime este diff y entra igual).`, conflicts);
1631
+ }
1632
+ await outbox.requeue([...wanted]);
1633
+ const report = { requeued: wanted.map((id) => byId.get(String(id))), overrides: conflicts, forced: force };
1634
+ if (!apply)
1635
+ return report;
1636
+ // Bajo el MISMO lease: solo los ids reencolados, con lo aparcado releído
1637
+ // (las reencoladas ya no salen por `dead()`); la barrera del freeze se
1638
+ // re-afirma por lote dentro, como en toda pasada.
1639
+ const inputs = await _a.#relayInputs(outbox, [...wanted], DEFAULT_RELAY_BATCH);
1640
+ report.relay = await this.#drainScopeChanges(outbox, { limit: DEFAULT_RELAY_LIMIT, batchSize: DEFAULT_RELAY_BATCH, ...inputs });
1641
+ return report;
1642
+ }
1643
+ /** Los ids de `requeue`: lista NO vacía de números o cadenas, sin repetidos (500 si no). */
1644
+ static #requeueIds(ids) {
1645
+ if (!Array.isArray(ids) || ids.length === 0 || ids.some((id) => typeof id !== 'number' && typeof id !== 'string')) {
1646
+ throw new AuthorizationConfigError(`authz:scopes:requeue: 'ids' debe ser una lista NO vacía de ids (number | string); llegó ${JSON.stringify(ids)}. ` +
1647
+ 'Sin ids no se reencola nada: «nada» no es «todo».');
1648
+ }
1649
+ const seen = new Set();
1650
+ return ids.filter((id) => (seen.has(String(id)) ? false : (seen.add(String(id)), true)));
1651
+ }
816
1652
  /**
817
1653
  * **`authz:reconcile --to=<driver>`** (3b-3a): la ÚNICA primitiva de
818
1654
  * migración y verificación del paquete, y el motivo de la fase entera —
@@ -874,6 +1710,49 @@ export class AuthorizationManager {
874
1710
  throw new UnsupportedOperationError('reconcile', `authz:reconcile --to=${name}`, name, `El driver '${name}' no sabe reconstruirse desde 'authz_*' + el árbol del consumidor. ` +
875
1711
  `El driver 'database' es ese caso a propósito: sus tablas son el ORIGEN.`);
876
1712
  }
1713
+ // **La dirección RANCIA** (2.4.0-alpha.10; 🔴 4 del sexto informe de
1714
+ // COGNITIV y cuarta iteración del LINAJE «origen rancio del reconcile»:
1715
+ // 3b-5 cerró la pasada por defecto, el primer guard de alpha.10 cerró
1716
+ // `--from`, y quedaba abierto el origen elegido POR DESCARTE). Regla del
1717
+ // dueño: el tercer defecto de un linaje se arregla estructuralmente, así
1718
+ // que el guard NO mira la bandera, mira el ORIGEN RESUELTO — con la MISMA
1719
+ // decisión que `#factsOrigin` (`#isMaintenancePass`), no con una copia:
1720
+ // si `to` es el driver ACTIVO, el único origen legítimo es `to` mismo (la
1721
+ // pasada de MANTENIMIENTO); cualquier otro —`--from`, el driver elegido por
1722
+ // descarte o las tablas `authz_*`— dejó de ver los hechos en el cutover y la
1723
+ // pasada re-escribiría lo revocado después (con o sin --prune; el seguro de
1724
+ // borrado masivo no salta: el origen no está vacío). Se decide por `to`,
1725
+ // nunca por `from`: el cutover inicial y la vuelta atrás ANTES de cambiar
1726
+ // `default` tienen `to` ≠ activo y pasan. Antes de resolver el origen y de
1727
+ // congelar. `--dry-run` no lanza (es el verificador): lo marca y
1728
+ // `reconcileLines` lo hace deriva. `from === to` sigue siendo el 500
1729
+ // E_AUTHZ_CONFIG de 3b-3b.
1730
+ const stale = this.#sameDriver(name, this.#config.default) &&
1731
+ (options.from === undefined || !this.#sameDriver(options.from, name)) &&
1732
+ !this.#isMaintenancePass(name, target, options.from);
1733
+ const allowStale = options.allowStaleSource === true;
1734
+ if (stale && !allowStale && options.dryRun !== true) {
1735
+ const how = options.from !== undefined ? `--from=${options.from}` : 'sin --from';
1736
+ // Cierre del auditor (⚪ 8): con UN solo driver registrado no hay descarte que
1737
+ // hacer; la letra no puede hablar de un origen que no existe.
1738
+ const others = Object.keys(this.#config.drivers ?? {}).filter((key) => !this.#sameDriver(key, name));
1739
+ const originLabel = options.from !== undefined
1740
+ ? `el origen '${options.from}' no lo es`
1741
+ : others.length === 0
1742
+ ? `no hay ningún otro driver registrado ni pasada de mantenimiento para él (no guarda sus hechos en su ` +
1743
+ `backend: no declara hierarchyFacts), así que no hay nada de dónde reconciliar hacia '${name}'`
1744
+ : `sin --from el origen se elegiría POR DESCARTE (las tablas authz_* u otro driver registrado: ` +
1745
+ `${others.join(', ')}), y no es '${name}'`;
1746
+ throw new StaleSourceRefusedError(`authz:reconcile --to=${name} ${how}: '${name}' es el driver ACTIVO (config.default` +
1747
+ `${name === this.#config.default ? '' : `: la clave resuelve la MISMA factory que '${this.#config.default}'`}) y ${originLabel}. ` +
1748
+ `Esa dirección re-escribiría en el driver activo hechos que el origen ya no refleja: resucitaría las ` +
1749
+ `revocaciones hechas desde el cutover (y con --prune borraría lo concedido después). No se ha leído, ` +
1750
+ `congelado ni escrito nada. Hacia el driver activo el único origen es él mismo: la pasada de mantenimiento ` +
1751
+ `(authz:reconcile --to=${name} [--dry-run], sin --from) si su driver guarda sus hechos; y si lo que quieres ` +
1752
+ `es volver atrás, migra en la otra dirección ANTES de cambiar config.default. Si de verdad el origen sigue ` +
1753
+ `siendo la fuente de verdad (el motor aún no ha escrito desde '${name}'), la decisión humana es ` +
1754
+ `--allow-stale-source (allowStaleSource: true).`);
1755
+ }
877
1756
  // **De dónde salen los HECHOS** (3b-5): la decisión que faltaba, y la que
878
1757
  // el destino no puede tomar por su cuenta. Ver `#factsOrigin`.
879
1758
  const origin = await this.#factsOrigin(name, target, options.from);
@@ -885,6 +1764,18 @@ export class AuthorizationManager {
885
1764
  // UNA vez.
886
1765
  facts: origin.enumerate,
887
1766
  factsOrigin: { name: origin.name, authzTables: origin.authzTables },
1767
+ // El techo de profundidad del consumidor, si lo declaró (alpha.5 · R0):
1768
+ // el mismo mando que el gate de `scopes.*`.
1769
+ ...(this.#config.scopes?.maxDepth === undefined ? {} : { maxDepth: this.#config.scopes.maxDepth }),
1770
+ // **Los tipos del OTRO subsistema del mismo backend** (alpha.9 · B · S4),
1771
+ // si el consumidor declaró relaciones. Es SOLO para el reporte: parte lo
1772
+ // ajeno en «declarado» y «no lo reclama nadie». La decisión de BORRAR no
1773
+ // lo mira —la toma la lista blanca congelada del destino—, porque el
1774
+ // proceso que corre un cutover puede no tener esta config y aun así
1775
+ // tiene que respetar las tuplas de quien sí la tiene.
1776
+ ...(this.#config.relations?.config === undefined
1777
+ ? {}
1778
+ : { relationTypes: this.#config.relations.config.objectTypes.map((type) => type.type) }),
888
1779
  };
889
1780
  const pass = async () => {
890
1781
  const report = await target.reconcile(source, options);
@@ -894,6 +1785,8 @@ export class AuthorizationManager {
894
1785
  // Quién fue el origen se DICE, siempre: es la diferencia entre una
895
1786
  // migración y una pasada de mantenimiento contra el driver activo.
896
1787
  report.factsFrom = origin.resolved();
1788
+ if (stale)
1789
+ report.staleSource = { to: name, from: origin.resolved(), allowed: allowStale };
897
1790
  return report;
898
1791
  };
899
1792
  // La pasada que escribe congela; el verificador NO (ver el docblock).
@@ -917,6 +1810,8 @@ export class AuthorizationManager {
917
1810
  lapsed: await window.lapsed(),
918
1811
  leaseMs: window.leaseMs,
919
1812
  fence: window.fence,
1813
+ ...(window.untilMs === undefined ? {} : { untilMs: window.untilMs }),
1814
+ ...(window.window === undefined ? {} : { window: window.window }),
920
1815
  };
921
1816
  return report;
922
1817
  }
@@ -944,7 +1839,9 @@ export class AuthorizationManager {
944
1839
  * MANTENIMIENTO: los hechos se leen del propio destino por el puerto
945
1840
  * (`enumerateFacts`), se rehace lo DERIVADO (marcador, catálogo, árbol)
946
1841
  * y se aplica el barrido de visibilidad del invariante 18 con el árbol
947
- * y el catálogo de HOY. No se inventa ni se borra un solo hecho. Un
1842
+ * y el catálogo de HOY. No se inventa ningún hecho; con `--prune` se
1843
+ * borran los que ya no respaldan nada (scopes que ya no resuelven,
1844
+ * caducados, roles/permisos retirados, y los que sobran). Un
948
1845
  * destino activo con `hierarchyFacts` que no sepa enumerar sus hechos
949
1846
  * es 500 `E_AUTHZ_UNSUPPORTED`: leerle `authz_*` sería justo el defecto.
950
1847
  * 2. **`--from=<nombre>` manda**, y por eso se resuelve YA: de la
@@ -958,7 +1855,7 @@ export class AuthorizationManager {
958
1855
  * de 3b-3b (`#factsEnumerator`).
959
1856
  */
960
1857
  async #factsOrigin(to, target, from) {
961
- if (from === undefined && to === this.#config.default && target.capabilities?.hierarchyFacts === true) {
1858
+ if (this.#isMaintenancePass(to, target, from)) {
962
1859
  if (typeof target.enumerateFacts !== 'function') {
963
1860
  throw new UnsupportedOperationError('enumerateFacts', `authz:reconcile --to=${to}`, to, `El motor SIRVE desde '${to}' y ese driver declara que el árbol y los hechos viven en su backend ` +
964
1861
  `(hierarchyFacts), así que 'authz_assignments'/'authz_denies' NO son la fuente de verdad de sus ` +
@@ -993,10 +1890,44 @@ export class AuthorizationManager {
993
1890
  });
994
1891
  return { name: AUTHZ_TABLES_ORIGIN, authzTables: true, enumerate, resolved: () => resolvedName };
995
1892
  }
1893
+ /**
1894
+ * **¿Es la pasada de MANTENIMIENTO?** (3b-5; punto ÚNICO desde alpha.10): el
1895
+ * destino es el driver ACTIVO, sus hechos viven en su backend
1896
+ * (`hierarchyFacts`) y nadie nombró otro origen. Es la ÚNICA forma en la que
1897
+ * el origen resuelto de una pasada hacia el driver activo es el propio
1898
+ * destino, y la usan los dos que la necesitan: `#factsOrigin` (de dónde se
1899
+ * leen los hechos) y el guard de la dirección rancia de `reconcile` (si ese
1900
+ * origen es legítimo). Dos copias de esta condición eran dos respuestas.
1901
+ */
1902
+ #isMaintenancePass(to, target, from) {
1903
+ return from === undefined && this.#sameDriver(to, this.#config.default) && target.capabilities?.hierarchyFacts === true;
1904
+ }
1905
+ /**
1906
+ * **¿Dos claves de `drivers` son el MISMO driver?** (cierre del auditor de
1907
+ * alpha.10 · R4, gemelo de roles): misma clave o la MISMA factory registrada
1908
+ * bajo dos claves. El guard de la dirección rancia, el mantenimiento, el
1909
+ * `from === to` y el descarte juzgan la identidad RESUELTA, no la etiqueta:
1910
+ * con `drivers: { openfga: f, fga: f }` y `default: 'openfga'`, `--to=fga
1911
+ * --from=database` es la misma dirección rancia que `--to=openfga`.
1912
+ *
1913
+ * **Límite DECLARADO (cierre-2 · C1)**: la identidad es la REFERENCIA de la
1914
+ * factory, no el backend. Dos factories distintas que construyen el mismo
1915
+ * store (`openfga: () => new OpenFga…(…)`, `fga: () => new OpenFga…(…)`) NO son
1916
+ * el mismo driver para este predicado y el alias pasa el guard. Identidad de
1917
+ * backend declarada por el driver ⇒ roadmap 2.5.
1918
+ */
1919
+ #sameDriver(a, b) {
1920
+ if (a === b)
1921
+ return true;
1922
+ const drivers = this.#config.drivers ?? {};
1923
+ const own = (key) => (Object.prototype.hasOwnProperty.call(drivers, key) ? drivers[key] : undefined);
1924
+ const fa = own(a);
1925
+ return fa !== undefined && fa === own(b);
1926
+ }
996
1927
  /** El driver que `--from` nombra, con los dos errores de 3b-3b intactos. */
997
1928
  async #originDriver(from, to) {
998
1929
  const registered = Object.keys(this.#config.drivers ?? {});
999
- if (from === to) {
1930
+ if (this.#sameDriver(from, to)) {
1000
1931
  throw new AuthorizationConfigError(`authz:reconcile --from=${from} --to=${to}: el origen y el destino son el mismo driver. ` +
1001
1932
  `Si lo que quieres es VERIFICAR y reparar lo derivado del driver activo, no lo digas con --from: ` +
1002
1933
  `la pasada ya lee sus hechos de él cuando es el driver por defecto.`);
@@ -1032,7 +1963,7 @@ export class AuthorizationManager {
1032
1963
  const registered = Object.keys(this.#config.drivers ?? {});
1033
1964
  const candidates = [];
1034
1965
  for (const candidate of registered) {
1035
- if (candidate === to)
1966
+ if (this.#sameDriver(candidate, to))
1036
1967
  continue;
1037
1968
  const driver = await this.#config.drivers[candidate]();
1038
1969
  if (typeof driver.enumerateFacts === 'function')
@@ -1101,11 +2032,42 @@ export class AuthorizationManager {
1101
2032
  * comparten ninguna no pueden interactuar en el árbol —toda dependencia
1102
2033
  * (recolgar, cerrar un ciclo, purgar) viaja por un nodo nombrado—, así que
1103
2034
  * el orden RELATIVO que hay que conservar es exactamente este.
2035
+ *
2036
+ * **La raíz `app` NO es clave** (2.4.0-alpha.8; panel relay 2, cuestión 1;
2037
+ * decisión del dueño 2026-09-10 (4)). La raíz no se mueve, no se purga y
2038
+ * nunca es hijo (los tres 422 de `#assertEdge`/`scopes.detached`), así que
2039
+ * dos cambios cuyo ÚNICO cruce sea `app` conmutan: ningún orden que
2040
+ * conservar (el auditor lo buscó y no lo encontró; `m2-orden.ts`). Con
2041
+ * `[hijo, app]` toda alta de tenant compartía clave con todas las demás:
2042
+ * una aparcada bajo la raíz congelaba el registro entero (5 pasadas con
2043
+ * `applied = []`, medido en un consumidor), dos altas concurrentes con la
2044
+ * receta inline dejaban la segunda `deferred` SIN nada aparcado, y
2045
+ * `requeue` se negaba con un conflicto falso. Se decide por REF
2046
+ * (`#isRootRef`), nunca por la cadena `'app'`, y en los TRES sitios que
2047
+ * construyen la lista: aquí, `#dependencyOf` y `requeueScopeChanges` (los
2048
+ * dos por `#changeScopes`); también para el bloqueo por FALLO (una sola
2049
+ * regla). La lista nunca queda vacía: el hijo siempre va.
1104
2050
  */
1105
2051
  static #changeKeys(change) {
1106
- return change.op === 'detached'
1107
- ? [scopeKey(change.child)]
1108
- : [scopeKey(change.child), scopeKey(change.parent)];
2052
+ return _a.#changeScopes(change).map((scope) => scopeKey(scope));
2053
+ }
2054
+ /**
2055
+ * Los scopes de `#changeKeys` como REFERENCIAS: la lista que va al puerto
2056
+ * (`earlierPending`/`laterApplied`). Es la misma decisión que
2057
+ * `#changeKeys` y vive en un solo sitio a propósito (auditor 🟠 3 del panel
2058
+ * relay 2: exentar la raíz solo en las claves dejaba el mutex vivo bajo
2059
+ * `only`, porque el SQL de la sonda sí casa `parent_type = 'app'`).
2060
+ */
2061
+ static #changeScopes(change) {
2062
+ if (change.op === 'detached')
2063
+ return [change.child];
2064
+ if (_a.#isRootRef(change.parent))
2065
+ return [change.child];
2066
+ return [change.child, change.parent];
2067
+ }
2068
+ /** ¿Es la raíz? Por REF (`type === 'app' && uuid === null`), jamás comparando la cadena `'app'`. */
2069
+ static #isRootRef(scope) {
2070
+ return scope.type === APP_SCOPE_TYPE && (scope.uuid ?? null) === null;
1109
2071
  }
1110
2072
  /** Lo aparcado por la outbox (si sabe aparcar), listo para el reporte. */
1111
2073
  static async #deadLetters(outbox, limit) {
@@ -1148,6 +2110,28 @@ export class AuthorizationManager {
1148
2110
  await this.#notify(event);
1149
2111
  }
1150
2112
  /** Cota entera positiva de las opciones del relay (500 si llega otra cosa). */
2113
+ /**
2114
+ * **Una opción desconocida es 500, nunca silencio** (2.4.0-alpha.8 · X-1;
2115
+ * tester del panel relay 2, §10-d). Medido en alpha.7: `requeueScopeChanges(ids,
2116
+ * { apply: true })` y `relayScopeChanges({ waitForLeaseMs })` se IGNORABAN
2117
+ * —el objeto de opciones no se validaba— y un consumidor en JS (o con
2118
+ * `as any`) creía que aplicaba o esperaba cuando no; el síntoma era un
2119
+ * `busy` o un `deferred` achacado a otra cosa. Se comprueban las CLAVES
2120
+ * (un valor `undefined` en una clave conocida vale: es lo que pasan los
2121
+ * comandos ace), y el objeto tiene que serlo.
2122
+ */
2123
+ static #assertKnownOptions(operation, options, known) {
2124
+ if (options === undefined)
2125
+ return;
2126
+ if (options === null || typeof options !== 'object' || Array.isArray(options)) {
2127
+ throw new AuthorizationConfigError(`${operation}: las opciones deben ser un objeto { ${known.join(', ')} }; llegó ${Array.isArray(options) ? 'una lista' : String(options)}.`);
2128
+ }
2129
+ const unknown = Object.keys(options).filter((key) => !known.includes(key));
2130
+ if (unknown.length > 0) {
2131
+ throw new AuthorizationConfigError(`${operation}: opción(es) desconocida(s): ${unknown.join(', ')}. Las conocidas son ${known.join(', ')}. ` +
2132
+ 'Una opción que no existe no se ignora: en JS creerías que aplica (o espera) cuando no. No se ha hecho nada.');
2133
+ }
2134
+ }
1151
2135
  static #positive(value, fallback, name) {
1152
2136
  if (value === undefined)
1153
2137
  return fallback;
@@ -1163,7 +2147,10 @@ export class AuthorizationManager {
1163
2147
  * `{}` si no hay actor: el evento no inventa autores).
1164
2148
  */
1165
2149
  async #writeOptions(options, operation) {
1166
- await this.#assertNotFrozen(operation, options?.transaction);
2150
+ // Sin cast sobre `transaction` (L-1 · 🟠 8): la barrera no lee nada del
2151
+ // llamante, y `transaction` solo lo consumen los tipos que lo declaran
2152
+ // (`ScopeTreeWriteOptions`, para ENCOLAR — nunca para decidir).
2153
+ await this.#assertNotFrozen(operation);
1167
2154
  if (options?.actor !== undefined)
1168
2155
  assertSubject(options.actor);
1169
2156
  if (this.#config.requireActor === true && !options?.actor) {
@@ -1339,8 +2326,36 @@ export class AuthorizationManager {
1339
2326
  throw new ScopeCycleError(`${operation}: ${parent.type}:${parent.uuid} desciende de ${childKey.replace('\u001f', ':')} (o es él mismo); ` +
1340
2327
  `colgarlo cerraría un ciclo y la herencia dejaría de ser solo hacia abajo.`);
1341
2328
  }
2329
+ this.#assertDepth(chain, canonicalChild, parent, operation);
1342
2330
  return { chain, child: canonicalChild };
1343
2331
  }
2332
+ /**
2333
+ * **El gate de profundidad, opt-in** (2.4.0-alpha.5 · R0; `scopes.maxDepth`).
2334
+ * La cadena del padre incluye `app`, así que su longitud ES la profundidad
2335
+ * a la que quedaría el hijo (saltos `parent` hasta la raíz): cero I/O de
2336
+ * más. Se juzga ANTES de escribir o encolar. PARCIAL a propósito: no ve la
2337
+ * altura del subárbol que un `moved` arrastra (ver `define_config.ts`).
2338
+ */
2339
+ #assertDepth(chain, child, parent, operation) {
2340
+ const maxDepth = this.#config.scopes?.maxDepth;
2341
+ if (maxDepth === undefined)
2342
+ return;
2343
+ if (typeof maxDepth !== 'number' || !Number.isInteger(maxDepth) || maxDepth < 1) {
2344
+ throw new AuthorizationConfigError(`${operation}: scopes.maxDepth debe ser un entero >= 1 (llegó ${String(maxDepth)}); quítalo si no quieres techo.`);
2345
+ }
2346
+ if (chain.length > maxDepth) {
2347
+ // El `message` es para el TENANT (2.4.0-alpha.8, cuestión 7.2): sin el
2348
+ // nombre del backend ni sus constantes; eso va en `details`, para el log.
2349
+ throw new ScopeDepthLimitError(`${operation}: colgar ${child.type}:${child.uuid} de ${parent.type}:${parent.uuid ?? ''} lo dejaría a ${chain.length} ` +
2350
+ `saltos de la raíz y el techo es scopes.maxDepth=${maxDepth}. No se ha escrito ni encolado nada. Aplana el árbol ` +
2351
+ `(o pide al operador que suba el techo).`, {
2352
+ depth: chain.length,
2353
+ maxDepth,
2354
+ why: 'el modelo facts del driver openfga no resuelve de forma fiable más allá de FACTS_MAX_RESOLVE_DEPTH: pasado él, authorize es 503 para todo ese subárbol',
2355
+ server: 'para subir el techo hay que subir también OPENFGA_RESOLVE_NODE_LIMIT en el servidor',
2356
+ });
2357
+ }
2358
+ }
1344
2359
  // La identidad se valida AQUÍ, antes de resolver siquiera el driver: una
1345
2360
  // pregunta mal formada (uuid ausente, `{app, uuid}`, slug con `~`) es 422
1346
2361
  // sin tocar catálogo, árbol ni backend, y sin que el hook `onWrite` audite
@@ -1594,6 +2609,7 @@ export class AuthorizationManager {
1594
2609
  * Devuelve el rol y notifica `role_defined`.
1595
2610
  */
1596
2611
  async defineScopedRole(actor, ownerScope, spec, options) {
2612
+ this.#assertNoTransaction(options, 'defineScopedRole');
1597
2613
  const who = await this.#requireActor(actor, 'defineScopedRole');
1598
2614
  this.#assertOwnerScope(ownerScope, 'defineScopedRole');
1599
2615
  const parsed = this.#parseScopedRoleSpec(spec);
@@ -1693,6 +2709,7 @@ export class AuthorizationManager {
1693
2709
  * escribe ni notifica (idempotente). Notifica `role_updated`.
1694
2710
  */
1695
2711
  async updateScopedRole(actor, roleUuid, changes, options) {
2712
+ this.#assertNoTransaction(options, 'updateScopedRole');
1696
2713
  const who = await this.#requireActor(actor, 'updateScopedRole');
1697
2714
  assertCatalogUuid('rol', roleUuid);
1698
2715
  const parsed = this.#parseScopedRoleChanges(changes);
@@ -1786,6 +2803,7 @@ export class AuthorizationManager {
1786
2803
  * su owner. Notifica `role_purged`. No necesita `listDenies`.
1787
2804
  */
1788
2805
  async deleteScopedRole(actor, roleUuid, options) {
2806
+ this.#assertNoTransaction(options, 'deleteScopedRole');
1789
2807
  const who = await this.#requireActor(actor, 'deleteScopedRole');
1790
2808
  assertCatalogUuid('rol', roleUuid);
1791
2809
  const driver = await this.driver();
@@ -2397,13 +3415,15 @@ export class AuthorizationManager {
2397
3415
  * `E_AUTHZ_AMBIGUOUS_ROLE`, nunca «el más cercano gana»).
2398
3416
  */
2399
3417
  async grant(subject, role, scope, options) {
3418
+ await this.#assertTransactionalWrite(options, 'grant');
2400
3419
  const actor = await this.#writeOptions(options, 'grant');
2401
3420
  assertIdentity({ subject, role, scope, expiresAt: options?.expiresAt });
2402
3421
  await this.#assertWithin(scope, options, 'grant');
2403
3422
  // 3E · Q7: el evento lleva el rol RESUELTO (uuid + slug + nivel + owner),
2404
3423
  // no la pregunta cruda. Solo se resuelve si hay hook que lo vaya a leer.
2405
3424
  const roles = await this.#resolvedRoles(role, scope, 'grant');
2406
- const outcome = (await this.#write({ action: 'granted', subject, scope, roles, expiresAt: options?.expiresAt ?? null, ...actor }, async () => (await this.driver()).grant(subject, role, scope, options))) ??
3425
+ const transactional = this.#transactional(options);
3426
+ const outcome = (await this.#write({ action: 'granted', subject, scope, roles, expiresAt: options?.expiresAt ?? null, ...actor, ...transactional }, async () => (await this.driver()).grant(subject, role, scope, options))) ??
2407
3427
  // Un driver de terceros que aún devuelva `void`: la firma promete un
2408
3428
  // `GrantOutcome` y no miente (E1). Sin lectura previa no hay caducidad
2409
3429
  // anterior que contar: es lo que pidió el llamante, y `existed: false`.
@@ -2419,6 +3439,7 @@ export class AuthorizationManager {
2419
3439
  expiresAt: outcome.expiresAt,
2420
3440
  previousExpiresAt: outcome.previousExpiresAt,
2421
3441
  ...actor,
3442
+ ...transactional,
2422
3443
  });
2423
3444
  }
2424
3445
  else {
@@ -2429,6 +3450,7 @@ export class AuthorizationManager {
2429
3450
  roles,
2430
3451
  expiresAt: outcome.expiresAt,
2431
3452
  ...actor,
3453
+ ...transactional,
2432
3454
  });
2433
3455
  }
2434
3456
  return outcome;
@@ -2440,27 +3462,38 @@ export class AuthorizationManager {
2440
3462
  * rol.
2441
3463
  */
2442
3464
  async revoke(subject, role, scope, options) {
3465
+ await this.#assertTransactionalWrite(options, 'revoke');
2443
3466
  const actor = await this.#writeOptions(options, 'revoke');
2444
3467
  assertIdentity({ subject, role, scope });
2445
3468
  await this.#assertWithin(scope, options, 'revoke');
2446
- const event = { action: 'revoked', subject, scope, roles: await this.#resolvedRoles(role, scope, 'revoke'), ...actor };
2447
- await this.#write(event, async () => (await this.driver()).revoke(subject, role, scope));
3469
+ const event = {
3470
+ action: 'revoked',
3471
+ subject,
3472
+ scope,
3473
+ roles: await this.#resolvedRoles(role, scope, 'revoke'),
3474
+ ...actor,
3475
+ ...this.#transactional(options),
3476
+ };
3477
+ // L-2: las opciones viajan al driver (`transaction`, si la puerta la dejó pasar) — como en `grant`.
3478
+ await this.#write(event, async () => (await this.driver()).revoke(subject, role, scope, options));
2448
3479
  await this.#notify(event);
2449
3480
  }
2450
3481
  async deny(subject, permission, scope, options) {
3482
+ await this.#assertTransactionalWrite(options, 'deny');
2451
3483
  const actor = await this.#writeOptions(options, 'deny');
2452
3484
  assertIdentity({ subject, permission, scope });
2453
3485
  await this.#assertWithin(scope, options, 'deny');
2454
- const event = { action: 'denied', subject, scope, permission, ...actor };
2455
- await this.#write(event, async () => (await this.driver()).deny(subject, permission, scope));
3486
+ const event = { action: 'denied', subject, scope, permission, ...actor, ...this.#transactional(options) };
3487
+ await this.#write(event, async () => (await this.driver()).deny(subject, permission, scope, options));
2456
3488
  await this.#notify(event);
2457
3489
  }
2458
3490
  async removeDeny(subject, permission, scope, options) {
3491
+ await this.#assertTransactionalWrite(options, 'removeDeny');
2459
3492
  const actor = await this.#writeOptions(options, 'removeDeny');
2460
3493
  assertIdentity({ subject, permission, scope });
2461
3494
  await this.#assertWithin(scope, options, 'removeDeny');
2462
- const event = { action: 'deny_removed', subject, scope, permission, ...actor };
2463
- await this.#write(event, async () => (await this.driver()).removeDeny(subject, permission, scope));
3495
+ const event = { action: 'deny_removed', subject, scope, permission, ...actor, ...this.#transactional(options) };
3496
+ await this.#write(event, async () => (await this.driver()).removeDeny(subject, permission, scope, options));
2464
3497
  await this.#notify(event);
2465
3498
  }
2466
3499
  /**
@@ -2711,6 +3744,17 @@ export class AuthorizationManager {
2711
3744
  * `indeterminate: true`, para que quien audita registre "puede haber
2712
3745
  * ocurrido" en vez de nada. Cualquier otro fallo (422, conexión rechazada)
2713
3746
  * significa que la escritura no ocurrió y se propaga sin evento.
3747
+ *
3748
+ * **Dentro de la transacción del llamante (L-3) el deadline sigue siendo
3749
+ * `indeterminate: true`**, y el evento lleva además `transactional: true`.
3750
+ * El auditor del panel `{trx}` (🟡 12) objetó que ahí «el rollback la
3751
+ * determina»; pero lo que determina el rollback lo determina el LLAMANTE
3752
+ * y el paquete no lo ve: en el instante del 503 la sentencia puede haber
3753
+ * aterrizado en la transacción (SQLite no cancela; en PG la transacción
3754
+ * queda abortada y ya no puede confirmar; en MySQL la sentencia se mata y
3755
+ * la transacción sigue viva) y el llamante puede confirmar o no. Lo
3756
+ * honesto es el mismo «no lo sé» de siempre más la marca de que la última
3757
+ * palabra es su commit. Medido por motor en `database_transaction.spec.ts`.
2714
3758
  */
2715
3759
  async #write(event, fn) {
2716
3760
  try {