@jantstack/adonis-authz 2.4.0-alpha.7 → 2.4.0-alpha.9

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 (65) hide show
  1. package/README.md +26 -4
  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 +0 -1
  10. package/build/commands/authz_freeze.js.map +1 -1
  11. package/build/commands/authz_reconcile.d.ts +4 -1
  12. package/build/commands/authz_reconcile.d.ts.map +1 -1
  13. package/build/commands/authz_reconcile.js +39 -5
  14. package/build/commands/authz_reconcile.js.map +1 -1
  15. package/build/commands/authz_relations_reconcile.d.ts.map +1 -1
  16. package/build/commands/authz_relations_reconcile.js +19 -5
  17. package/build/commands/authz_relations_reconcile.js.map +1 -1
  18. package/build/commands/authz_scopes_relay.d.ts +32 -0
  19. package/build/commands/authz_scopes_relay.d.ts.map +1 -1
  20. package/build/commands/authz_scopes_relay.js +78 -3
  21. package/build/commands/authz_scopes_relay.js.map +1 -1
  22. package/build/commands/authz_scopes_requeue.d.ts +17 -0
  23. package/build/commands/authz_scopes_requeue.d.ts.map +1 -1
  24. package/build/commands/authz_scopes_requeue.js +82 -6
  25. package/build/commands/authz_scopes_requeue.js.map +1 -1
  26. package/build/index.d.ts +1 -1
  27. package/build/index.d.ts.map +1 -1
  28. package/build/index.js +1 -1
  29. package/build/index.js.map +1 -1
  30. package/build/src/drivers/database_relations_driver.d.ts +17 -0
  31. package/build/src/drivers/database_relations_driver.d.ts.map +1 -1
  32. package/build/src/drivers/database_relations_driver.js +48 -5
  33. package/build/src/drivers/database_relations_driver.js.map +1 -1
  34. package/build/src/drivers/openfga_driver.d.ts +46 -3
  35. package/build/src/drivers/openfga_driver.d.ts.map +1 -1
  36. package/build/src/drivers/openfga_driver.js +116 -8
  37. package/build/src/drivers/openfga_driver.js.map +1 -1
  38. package/build/src/errors.d.ts +21 -0
  39. package/build/src/errors.d.ts.map +1 -1
  40. package/build/src/errors.js +7 -0
  41. package/build/src/errors.js.map +1 -1
  42. package/build/src/manager.d.ts +47 -2
  43. package/build/src/manager.d.ts.map +1 -1
  44. package/build/src/manager.js +509 -226
  45. package/build/src/manager.js.map +1 -1
  46. package/build/src/relations/manager.d.ts +19 -3
  47. package/build/src/relations/manager.d.ts.map +1 -1
  48. package/build/src/relations/manager.js +33 -8
  49. package/build/src/relations/manager.js.map +1 -1
  50. package/build/src/scope_outbox.d.ts +14 -0
  51. package/build/src/scope_outbox.d.ts.map +1 -1
  52. package/build/src/scope_outbox.js +28 -0
  53. package/build/src/scope_outbox.js.map +1 -1
  54. package/build/src/shared/transaction_guard.d.ts +5 -2
  55. package/build/src/shared/transaction_guard.d.ts.map +1 -1
  56. package/build/src/shared/transaction_guard.js +16 -2
  57. package/build/src/shared/transaction_guard.js.map +1 -1
  58. package/build/src/testing/scope_outbox_contract.d.ts +10 -0
  59. package/build/src/testing/scope_outbox_contract.d.ts.map +1 -1
  60. package/build/src/testing/scope_outbox_contract.js +71 -1
  61. package/build/src/testing/scope_outbox_contract.js.map +1 -1
  62. package/build/src/types.d.ts +106 -1
  63. package/build/src/types.d.ts.map +1 -1
  64. package/build/src/types.js.map +1 -1
  65. package/package.json +1 -1
@@ -84,6 +84,27 @@ export const RELAY_ORIGIN_MAX_HOPS = 100;
84
84
  export const PARKED_CENSUS_MAX = 50_000;
85
85
  /** Vida por defecto de una vista de `forRequest()` para LEER (F9): un request, no un módulo. */
86
86
  export const DEFAULT_VIEW_MAX_AGE_MS = 30_000;
87
+ /**
88
+ * Cada cuánto reintenta `acquire()` una pasada con `waitForLeaseMs` (2.4.0-alpha.8;
89
+ * panel relay 2, cuestión 7). Medido en PG: 0,29 ms por sondeo ocupado, y el
90
+ * sondeo no retiene conexión entre intentos (la transacción del intento fallido
91
+ * se revierte). El default de `waitForLeaseMs` es 0: el `busy` inmediato de
92
+ * siempre.
93
+ */
94
+ export const RELAY_LEASE_POLL_MS = 25;
95
+ /**
96
+ * Tope de `waitForLeaseMs` (cierre alpha.8, 🟡 1 del auditor): una espera sin tope
97
+ * cuelga la petición de un tenant; por encima es 500 `E_AUTHZ_CONFIG`. Y la espera
98
+ * se cuenta en SONDEOS (`waitForLeaseMs / RELAY_LEASE_POLL_MS`), no solo con el
99
+ * reloj de pared: con `config.clock` fijo el sondeo por reloj no terminaba nunca.
100
+ */
101
+ export const RELAY_WAIT_LEASE_MAX_MS = 60_000;
102
+ /** Las opciones que `relayScopeChanges` conoce (2.4.0-alpha.8 · X-1: cualquier otra es 500). */
103
+ const RELAY_OPTIONS = ['limit', 'batchSize', 'dryRun', 'only', 'waitForLeaseMs', 'deadDetail'];
104
+ /** 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. */
105
+ const BLOCK_RANK = { parked: 0, pending: 1, failed: 2 };
106
+ /** Las opciones que `requeueScopeChanges` conoce (2.4.0-alpha.8 · X-1: cualquier otra es 500). */
107
+ const REQUEUE_OPTIONS = ['force', 'apply', 'waitForLeaseMs'];
87
108
  /** Reloj monótono del proceso: inmune a NTP, snapshots y `Date.now` parcheado. */
88
109
  const monotonicNow = () => performance.now();
89
110
  /**
@@ -819,6 +840,7 @@ export class AuthorizationManager {
819
840
  * 503 del backend NO consume intento (`markFailed(..., { transient })`).
820
841
  */
821
842
  async relayScopeChanges(options = {}) {
843
+ _a.#assertKnownOptions('relayScopeChanges', options, RELAY_OPTIONS);
822
844
  // El relay ESCRIBE el árbol en el driver: durante una migración se aplaza
823
845
  // como cualquier otra escritura (lo que quede en la cola sigue ahí).
824
846
  await this.#assertNotFrozen('authz:scopes:relay');
@@ -830,34 +852,27 @@ export class AuthorizationManager {
830
852
  const limit = _a.#positive(options.limit, DEFAULT_RELAY_LIMIT, 'limit');
831
853
  const batchSize = _a.#positive(options.batchSize, DEFAULT_RELAY_BATCH, 'batchSize');
832
854
  const dryRun = options.dryRun === true;
833
- const only = _a.#onlyOf(options.only);
834
- // Al puerto van los ids TAL CUAL los dio el llamante (el recibo); el
835
- // conjunto de cadenas es solo para comparar sin depender del tipo.
836
- const filter = only === undefined ? undefined : { ids: [...options.only] };
837
- /** Lo aparcado por la outbox, si sabe aparcar: se reporta SIEMPRE, y GLOBAL. */
838
- const dead = await _a.#deadLetters(outbox, batchSize);
839
- /** Lo aparcado que se pidió (con `only`) o todo lo aparcado, para `entries`. */
840
- const deadEntries = dead
841
- .filter((item) => only === undefined || only.has(String(item.id)))
842
- .map((item) => ({
843
- id: item.id,
844
- change: item.change,
845
- status: 'dead',
846
- attempts: item.attempts,
847
- ...(item.error === undefined ? {} : { reason: item.error }),
848
- }));
849
- const missingOf = (seen) => only === undefined ? [] : [...only.values()].filter((id) => !seen.has(String(id))).map((id) => _a.#rawId(options.only, id));
855
+ const waitForLeaseMs = _a.#waitMs(options.waitForLeaseMs);
856
+ if (options.deadDetail !== undefined && options.deadDetail !== 'full') {
857
+ throw new AuthorizationConfigError(`authz:scopes:relay: deadDetail solo admite 'full' (llegó ${JSON.stringify(options.deadDetail)}); sin él, bajo only, dead[] va sin error.`);
858
+ }
859
+ const inputs = await _a.#relayInputs(outbox, options.only, batchSize, options.deadDetail);
860
+ const { only, filter, dead, deadCount, deadEntries, missingOf } = inputs;
850
861
  if (dryRun) {
851
862
  const batch = await outbox.pending(limit, undefined, filter);
852
863
  _a.#assertOnlyHonoured(batch, only);
853
864
  const extra = batch.length >= limit ? true : false;
854
865
  const seen = new Set([...batch.map((b) => String(b.id)), ...deadEntries.map((d) => String(d.id))]);
866
+ // La edad es GLOBAL: sin filtro la cabeza es `batch[0]`; con `only` hay que mirar la cola entera.
867
+ const queue = filter === undefined ? this.#queueOf(batch[0]) : (await this.#queueStatus(outbox)).queue;
855
868
  return {
856
869
  applied: [],
857
870
  failed: null,
858
871
  failures: [],
859
872
  deferred: [],
860
873
  dead,
874
+ deadCount,
875
+ queue,
861
876
  busy: false,
862
877
  remaining: batch.length > 0 || extra,
863
878
  dryRun: true,
@@ -868,191 +883,313 @@ export class AuthorizationManager {
868
883
  }
869
884
  // El lease del escritor ÚNICO. Una outbox que no sabe darlo se comporta
870
885
  // como hasta ahora (y el README dice que entonces el relay tiene que
871
- // correr de uno en uno).
872
- const lease = outbox.acquire ? await outbox.acquire() : null;
873
- if (outbox.acquire && lease === null) {
874
- return {
875
- applied: [],
876
- failed: null,
877
- failures: [],
878
- deferred: [],
879
- dead,
880
- busy: true,
881
- remaining: (await outbox.pending(1)).length > 0,
882
- dryRun: false,
883
- wouldApply: [],
884
- entries: deadEntries,
885
- missing: [],
886
- };
887
- }
886
+ // correr de uno en uno). Con `waitForLeaseMs` se reintenta hasta el tope.
887
+ const lease = await this.#acquireLease(outbox, waitForLeaseMs);
888
+ if (outbox.acquire && lease === null)
889
+ return this.#busyReport(outbox, inputs);
888
890
  try {
889
- const driver = await this.driver();
890
- const applied = [];
891
- const deferred = [];
892
- const failures = [];
893
- const entries = [];
894
- /**
895
- * Claves de scope contaminadas ⇒ el id que las contaminó y el ORIGEN del
896
- * bloqueo (alpha.5 · cierre, auditor 🟠 1). `parked: true` = la causa
897
- * raíz (transitiva) es una entrada APARCADA: tapona solo lo que concede
898
- * o recuelga (`attached`/`moved`), NUNCA un `detached` — si no, un
899
- * `attached` aplazado por aparcado congelaba la purga del nodo que el
900
- * tenant ya borró, para siempre (medido: el scope conservaba sus hechos
901
- * y su arista, y seguía concediendo; invariante 11). `parked: false` =
902
- * un FALLO real de esta pasada (o una pendiente anterior no pedida):
903
- * contamina todo, `detached` incluido — el orden entre un `attached`
904
- * que falló y la purga de ese mismo nodo sigue importando (3b-2h).
905
- */
906
- const blocked = new Map();
907
- /** Origen ya sondeado de entradas NO pedidas (alpha.6): `true` = retenida solo por un aparcado. Se olvida en cuanto la pasada registra un FALLO real (podría contaminar una clave ya sondeada). */
908
- const origins = new Map();
909
- const block = (key, id, parked) => {
910
- const current = blocked.get(key);
911
- // Un fallo real pisa a un bloqueo por aparcado; nunca al revés.
912
- if (current === undefined || (current.parked && !parked))
913
- blocked.set(key, { id, parked });
914
- if (!parked)
915
- origins.clear();
916
- };
917
- /**
918
- * **Las claves de lo APARCADO** (alpha.5 · R2a): taponan lo que concede
919
- * o recuelga y dejan pasar lo que retira. El censo es COMPLETO —no
920
- * depende de `limit` ni de `batchSize` (cierre, 🟠 2)— con su propia
921
- * cota; el reporte sigue enseñando las primeras `batchSize`.
922
- */
923
- const parkedKeys = await _a.#parkedKeys(outbox, dead, batchSize);
924
- // Una outbox que no marca lo aplicado devolvería el mismo pendiente
925
- // para siempre: el relay no puede quedarse dando vueltas ni
926
- // "arreglarlo" por su cuenta, así que lo denuncia (500) en cuanto
927
- // vuelve a ver un id que YA aplicó.
928
- const done = new Set();
929
- /** Ids que esta pasada dejó a propósito (fallo o aplazo): reaparecen. */
930
- const parked = new Set();
931
- /** El último id visto: la outbox pagina desde ahí (lo saltado se queda). */
932
- let after;
933
- const defer = (item, keys, reason, error, byParked) => {
934
- for (const key of keys)
935
- block(key, item.id, byParked);
936
- parked.add(String(item.id));
937
- deferred.push({ id: item.id, change: item.change, attempts: item.attempts, error });
938
- entries.push({ id: item.id, change: item.change, status: 'deferred', reason, attempts: item.attempts });
939
- };
940
- outer: while (applied.length < limit) {
941
- // **La barrera del freeze se RE-AFIRMA por lote** (3b-8 · B3). La
942
- // mirada única de la entrada dejaba hasta `DEFAULT_RELAY_LIMIT`
943
- // (10.000) escrituras de árbol colándose DESPUÉS de que otra pasada
944
- // adquiriera el freeze durable: escrituras que no salen en ningún
945
- // contador de la pasada certificada y que pueden invalidar su
946
- // resultado. El trade-off documentado en freeze.ts cubre «una
947
- // escritura que ya pasó su barrera», no una pasada entera. El coste
948
- // (una lectura de la fila `id=2` por lote; 0,14 ms/escritura ya
949
- // medidos y aceptados) va fuera del camino caliente. Un freeze
950
- // adquirido a mitad corta AQUÍ con el 503 reintentable de siempre:
951
- // lo ya aplicado está marcado en la outbox (la pasada es reanudable)
952
- // y el resto sigue pendiente para después de la ventana.
953
- await this.#assertNotFrozen('authz:scopes:relay');
954
- const batch = await outbox.pending(Math.min(batchSize, limit - applied.length), after, filter);
955
- if (batch.length === 0)
956
- break;
957
- _a.#assertOnlyHonoured(batch, only);
958
- let progress = false;
959
- for (const item of batch) {
960
- const id = String(item.id);
961
- if (done.has(id)) {
962
- throw new AuthorizationConfigError(`authz:scopes:relay: la outbox sigue devolviendo el cambio ${id} como pendiente después de markApplied. ` +
963
- 'Tu implementación de ScopeOutbox no marca lo aplicado; el relay para antes de dar vueltas para siempre.');
964
- }
965
- if (parked.has(id))
966
- continue;
967
- progress = true;
968
- after = item.id;
969
- if (applied.length >= limit)
970
- break outer;
971
- const keys = _a.#changeKeys(item.change);
972
- // Una purga solo la frena un bloqueo cuyo origen sea un FALLO real;
973
- // el que nace de un aparcado la deja pasar (invariante 11).
974
- const collision = keys.find((key) => {
975
- const hit = blocked.get(key);
976
- return hit !== undefined && !(item.change.op === 'detached' && hit.parked);
977
- });
978
- if (collision !== undefined) {
979
- const by = blocked.get(collision);
980
- defer(item, keys, `depends-on:${by.id}`, `aplazado: depende de ${collision}, que quedó sin aplicar en esta pasada (#${by.id})`, by.parked);
891
+ return await this.#drainScopeChanges(outbox, { limit, batchSize, ...inputs });
892
+ }
893
+ finally {
894
+ await lease?.release();
895
+ }
896
+ }
897
+ /**
898
+ * Lo que una pasada necesita saber de la cola antes de tomar el lease
899
+ * (`only` normalizado, el filtro para el puerto, lo APARCADO —siempre
900
+ * global— y sus entradas para el reporte). Compartido por `relayScopeChanges`
901
+ * y por `requeueScopeChanges({ apply })` (2.4.0-alpha.8), que releva bajo
902
+ * el MISMO lease y necesita releer lo aparcado DESPUÉS de reencolar.
903
+ */
904
+ static async #relayInputs(outbox, onlyIds, batchSize, deadDetail) {
905
+ const only = _a.#onlyOf(onlyIds);
906
+ // Al puerto van los ids TAL CUAL los dio el llamante (el recibo); el
907
+ // conjunto de cadenas es solo para comparar sin depender del tipo.
908
+ const filter = only === undefined ? undefined : { ids: [...onlyIds] };
909
+ /** Lo aparcado por la outbox, si sabe aparcar: se reporta SIEMPRE, y GLOBAL. */
910
+ const read = await _a.#deadLetters(outbox, batchSize);
911
+ // Bajo `only` (2.4.0-alpha.8, coordinador): la lista sigue siendo global
912
+ // —ids, `change`, `attempts`— pero SIN `error` salvo `deadDetail: 'full'`:
913
+ // la causa es texto del backend sobre el árbol de otro tenant, y la receta
914
+ // inline corre dentro de una petición. Lo pedido (`deadEntries`) sí la
915
+ // lleva: es lo suyo.
916
+ const dead = only === undefined || deadDetail === 'full' ? read : read.map(({ error: _error, ...safe }) => safe);
917
+ // El censo COMPLETO (cierre alpha.5, 🟠 2): lo que `#parkedKeys` siembra y,
918
+ // desde 2.4.0-alpha.8, lo que cuenta `deadCount` (`dead.length` miente al
919
+ // llegar a `batchSize`). Una sola lectura de más, y solo si no cabía.
920
+ const census = dead.length < batchSize ? dead : await _a.#deadLetters(outbox, PARKED_CENSUS_MAX);
921
+ const deadCount = census.length;
922
+ /** Lo aparcado que se pidió (con `only`) o todo lo aparcado, para `entries`. */
923
+ const deadEntries = read
924
+ .filter((item) => only === undefined || only.has(String(item.id)))
925
+ .map((item) => ({
926
+ id: item.id,
927
+ change: item.change,
928
+ status: 'dead',
929
+ attempts: item.attempts,
930
+ ...(item.error === undefined ? {} : { reason: item.error }),
931
+ }));
932
+ const missingOf = (seen) => only === undefined ? [] : [...only.values()].filter((id) => !seen.has(String(id))).map((id) => _a.#rawId(onlyIds, id));
933
+ return { only, filter, dead, census, deadCount, deadEntries, missingOf };
934
+ }
935
+ /** El reporte de una pasada que NO entró: otra tiene el lease. Nada aplicado, nada tocado. */
936
+ async #busyReport(outbox, inputs) {
937
+ const { remaining, queue } = await this.#queueStatus(outbox);
938
+ return {
939
+ applied: [],
940
+ failed: null,
941
+ failures: [],
942
+ deferred: [],
943
+ dead: inputs.dead,
944
+ deadCount: inputs.deadCount,
945
+ queue,
946
+ busy: true,
947
+ remaining,
948
+ dryRun: false,
949
+ wouldApply: [],
950
+ entries: inputs.deadEntries,
951
+ missing: [],
952
+ };
953
+ }
954
+ /**
955
+ * **La cabeza GLOBAL de la cola y su edad** (2.4.0-alpha.8; panel relay 2,
956
+ * cuestión 5). Es el `pending(1)` que la pasada ya hacía al final para
957
+ * `remaining` —cero consultas nuevas—, leído SIN filtro aunque haya `only`:
958
+ * la edad, como `remaining` y `dead`, es de la cola entera. La edad la mide
959
+ * el reloj INYECTABLE del manager (`#wallMs`), nunca `Date.now()`.
960
+ */
961
+ async #queueStatus(outbox) {
962
+ const head = await outbox.pending(1);
963
+ return { remaining: head.length > 0, queue: this.#queueOf(head[0]) };
964
+ }
965
+ /**
966
+ * La edad de una cabeza: `Math.max(0, ahora − createdAt)` —nunca negativa:
967
+ * el sello lo puso el reloj de OTRA réplica, con su skew—; sin cabeza o sin
968
+ * `createdAt` (una outbox de terceros que no lo trae) ⇒ `null` en los dos
969
+ * campos, jamás `0` ni «ahora».
970
+ */
971
+ #queueOf(head) {
972
+ const createdAt = head?.createdAt;
973
+ if (!(createdAt instanceof Date) || Number.isNaN(createdAt.getTime()))
974
+ return { oldestPendingAt: null, pendingAgeMs: null };
975
+ return { oldestPendingAt: createdAt, pendingAgeMs: Math.max(0, this.#root().#wallMs() - createdAt.getTime()) };
976
+ }
977
+ /**
978
+ * **El lease del escritor único, con espera acotada** (2.4.0-alpha.8;
979
+ * `waitForLeaseMs`, panel relay 2, cuestión 7). Con `0` (el default) es
980
+ * UN intento y `null` = `busy`, como siempre — el caso «dos pasadas a la vez
981
+ * ⇒ busy» no cambia. Con un tope, reintenta `acquire()` cada
982
+ * `RELAY_LEASE_POLL_MS` hasta vencerlo: `busy` al vencer, jamás un cuelgue.
983
+ * El reloj es el inyectable del manager (nunca `Date.now()` en `src/`).
984
+ * Exige pool ≥ 2 (README): con pool 1 el que espera puede estar reteniendo
985
+ * la única conexión que necesita el que tiene el lease.
986
+ */
987
+ async #acquireLease(outbox, waitForLeaseMs) {
988
+ if (!outbox.acquire)
989
+ return null;
990
+ let lease = await outbox.acquire();
991
+ if (lease !== null || waitForLeaseMs <= 0)
992
+ return lease;
993
+ const deadline = this.#root().#wallMs() + waitForLeaseMs;
994
+ // Dos topes a la vez: el reloj de pared (inyectable) y el NÚMERO de sondeos —
995
+ // con un reloj fijo el primero no vence jamás (medido por el auditor de alpha.8).
996
+ const maxPolls = Math.ceil(waitForLeaseMs / RELAY_LEASE_POLL_MS);
997
+ for (let polls = 0; lease === null && polls < maxPolls && this.#root().#wallMs() < deadline; polls++) {
998
+ await new Promise((resolve) => setTimeout(resolve, RELAY_LEASE_POLL_MS));
999
+ lease = await outbox.acquire();
1000
+ }
1001
+ return lease;
1002
+ }
1003
+ /** `waitForLeaseMs` normalizado: entero >= 0 (500 si no); ausente = 0. */
1004
+ static #waitMs(value) {
1005
+ if (value === undefined)
1006
+ return 0;
1007
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {
1008
+ throw new AuthorizationConfigError(`authz:scopes:relay: waitForLeaseMs debe ser un entero >= 0 en milisegundos (llegó ${String(value)})`);
1009
+ }
1010
+ if (value > RELAY_WAIT_LEASE_MAX_MS) {
1011
+ throw new AuthorizationConfigError(`authz:scopes:relay: waitForLeaseMs = ${value} supera el tope RELAY_WAIT_LEASE_MAX_MS (${RELAY_WAIT_LEASE_MAX_MS} ms): ` +
1012
+ `una espera sin tope cuelga la petición del tenant; acorta la pasada (only/limit) en vez de esperar más.`);
1013
+ }
1014
+ return value;
1015
+ }
1016
+ /**
1017
+ * **La pasada, ya con el lease tomado**: lo que hasta 2.4.0-alpha.7 era el
1018
+ * cuerpo de `relayScopeChanges`. Compartida con `requeueScopeChanges({
1019
+ * apply })`, que la llama bajo SU lease con `only` = los ids reencolados
1020
+ * (auditor 🟠 5: tomar el lease dos veces era `busy` en verde).
1021
+ */
1022
+ async #drainScopeChanges(outbox, input) {
1023
+ const { limit, batchSize, only, filter, dead, census, deadCount, deadEntries, missingOf } = input;
1024
+ const driver = await this.driver();
1025
+ const applied = [];
1026
+ const deferred = [];
1027
+ const failures = [];
1028
+ const entries = [];
1029
+ /**
1030
+ * Claves de scope contaminadas ⇒ el id que las contaminó y el ORIGEN del
1031
+ * bloqueo (alpha.5 · cierre, auditor 🟠 1). `parked: true` = la causa
1032
+ * raíz (transitiva) es una entrada APARCADA: tapona solo lo que concede
1033
+ * o recuelga (`attached`/`moved`), NUNCA un `detached` — si no, un
1034
+ * `attached` aplazado por aparcado congelaba la purga del nodo que el
1035
+ * tenant ya borró, para siempre (medido: el scope conservaba sus hechos
1036
+ * y su arista, y seguía concediendo; invariante 11). `parked: false` =
1037
+ * un FALLO real de esta pasada (o una pendiente anterior no pedida):
1038
+ * contamina todo, `detached` incluido — el orden entre un `attached`
1039
+ * que falló y la purga de ese mismo nodo sigue importando (3b-2h).
1040
+ */
1041
+ const blocked = new Map();
1042
+ /** 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). */
1043
+ const origins = new Map();
1044
+ const block = (key, id, origin) => {
1045
+ const current = blocked.get(key);
1046
+ // Precedencia `failed > pending > parked` (2.4.0-alpha.8; desde el
1047
+ // cierre de alpha.5: un fallo real pisa a un aparcado, nunca al revés).
1048
+ if (current === undefined || BLOCK_RANK[origin] > BLOCK_RANK[current.origin])
1049
+ blocked.set(key, { id, origin });
1050
+ if (origin !== 'parked')
1051
+ origins.clear();
1052
+ };
1053
+ /**
1054
+ * **Las claves de lo APARCADO** (alpha.5 · R2a): taponan lo que concede
1055
+ * o recuelga y dejan pasar lo que retira. El censo es COMPLETO —no
1056
+ * depende de `limit` ni de `batchSize` (cierre, 🟠 2)— con su propia
1057
+ * cota; el reporte sigue enseñando las primeras `batchSize`.
1058
+ */
1059
+ const parkedKeys = _a.#parkedKeys(census);
1060
+ // Una outbox que no marca lo aplicado devolvería el mismo pendiente
1061
+ // para siempre: el relay no puede quedarse dando vueltas ni
1062
+ // "arreglarlo" por su cuenta, así que lo denuncia (500) en cuanto
1063
+ // vuelve a ver un id que YA aplicó.
1064
+ const done = new Set();
1065
+ /** Ids que esta pasada dejó a propósito (fallo o aplazo): reaparecen. */
1066
+ const parked = new Set();
1067
+ /** El último id visto: la outbox pagina desde ahí (lo saltado se queda). */
1068
+ let after;
1069
+ const defer = (item, keys, reason, error, origin) => {
1070
+ for (const key of keys)
1071
+ block(key, item.id, origin);
1072
+ parked.add(String(item.id));
1073
+ deferred.push({ id: item.id, change: item.change, attempts: item.attempts, error });
1074
+ // `origin` se DERIVA del bloqueo (2.4.0-alpha.8), jamás del prefijo de `reason`.
1075
+ entries.push({ id: item.id, change: item.change, status: 'deferred', reason, attempts: item.attempts, origin });
1076
+ };
1077
+ outer: while (applied.length < limit) {
1078
+ // **La barrera del freeze se RE-AFIRMA por lote** (3b-8 · B3). La
1079
+ // mirada única de la entrada dejaba hasta `DEFAULT_RELAY_LIMIT`
1080
+ // (10.000) escrituras de árbol colándose DESPUÉS de que otra pasada
1081
+ // adquiriera el freeze durable: escrituras que no salen en ningún
1082
+ // contador de la pasada certificada y que pueden invalidar su
1083
+ // resultado. El trade-off documentado en freeze.ts cubre «una
1084
+ // escritura que ya pasó su barrera», no una pasada entera. El coste
1085
+ // (una lectura de la fila `id=2` por lote; 0,14 ms/escritura ya
1086
+ // medidos y aceptados) va fuera del camino caliente. Un freeze
1087
+ // adquirido a mitad corta AQUÍ con el 503 reintentable de siempre:
1088
+ // lo ya aplicado está marcado en la outbox (la pasada es reanudable)
1089
+ // y el resto sigue pendiente para después de la ventana.
1090
+ await this.#assertNotFrozen('authz:scopes:relay');
1091
+ const batch = await outbox.pending(Math.min(batchSize, limit - applied.length), after, filter);
1092
+ if (batch.length === 0)
1093
+ break;
1094
+ _a.#assertOnlyHonoured(batch, only);
1095
+ let progress = false;
1096
+ for (const item of batch) {
1097
+ const id = String(item.id);
1098
+ if (done.has(id)) {
1099
+ throw new AuthorizationConfigError(`authz:scopes:relay: la outbox sigue devolviendo el cambio ${id} como pendiente después de markApplied. ` +
1100
+ 'Tu implementación de ScopeOutbox no marca lo aplicado; el relay para antes de dar vueltas para siempre.');
1101
+ }
1102
+ if (parked.has(id))
1103
+ continue;
1104
+ progress = true;
1105
+ after = item.id;
1106
+ if (applied.length >= limit)
1107
+ break outer;
1108
+ const keys = _a.#changeKeys(item.change);
1109
+ // Una purga solo la frena un bloqueo cuyo origen sea un FALLO real;
1110
+ // el que nace de un aparcado la deja pasar (invariante 11).
1111
+ const collision = keys.find((key) => {
1112
+ const hit = blocked.get(key);
1113
+ return hit !== undefined && !(item.change.op === 'detached' && hit.origin === 'parked');
1114
+ });
1115
+ if (collision !== undefined) {
1116
+ const by = blocked.get(collision);
1117
+ defer(item, keys, `depends-on:${by.id}`, `aplazado: depende de ${collision}, que quedó sin aplicar en esta pasada (#${by.id})`, by.origin);
1118
+ continue;
1119
+ }
1120
+ // Lo aparcado tapona lo que CONCEDE o recuelga; una purga pasa.
1121
+ if (item.change.op !== 'detached') {
1122
+ const parkedKey = keys.find((key) => parkedKeys.has(key));
1123
+ if (parkedKey !== undefined) {
1124
+ const by = parkedKeys.get(parkedKey);
1125
+ 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');
981
1126
  continue;
982
1127
  }
983
- // Lo aparcado tapona lo que CONCEDE o recuelga; una purga pasa.
984
- if (item.change.op !== 'detached') {
985
- const parkedKey = keys.find((key) => parkedKeys.has(key));
986
- if (parkedKey !== undefined) {
987
- const by = parkedKeys.get(parkedKey);
988
- 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`, true);
1128
+ }
1129
+ // Con `only`, la sonda contra la cola ENTERA: una pendiente anterior
1130
+ // que comparta clave y no se pidió ⇒ aplazado, nunca fuera de orden.
1131
+ // **Y la sonda hereda el ORIGEN de esa anterior** (alpha.6, auditor 🟡 A
1132
+ // de alpha.5): si la anterior está —transitivamente— retenida solo por
1133
+ // un APARCADO, es el mismo bloqueo `parked:` que en la pasada completa
1134
+ // deja pasar la purga (invariante 11): el `detached` SE APLICA. Si la
1135
+ // anterior es un `attached`/`moved` pendiente de verdad (aplicable, o
1136
+ // retenido por un FALLO real), el `detached` sigue `deferred`: aplicarlo
1137
+ // antes que un `moved` pendiente del mismo scope dejaría deriva.
1138
+ if (only !== undefined) {
1139
+ const earlier = await this.#dependencyOf(outbox, item, batchSize);
1140
+ if (earlier !== null) {
1141
+ const origin = await this.#blockOriginOf(outbox, earlier, blocked, parkedKeys, batchSize, origins);
1142
+ if (!(item.change.op === 'detached' && origin === 'parked')) {
1143
+ const shared = _a.#changeKeys(earlier.change).find((key) => keys.includes(key)) ?? keys[0];
1144
+ 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}); ` +
1145
+ (origin === 'parked' ? 'espera a una entrada APARCADA; mira dead()' : 'corre el relay completo'), origin);
989
1146
  continue;
990
1147
  }
991
1148
  }
992
- // Con `only`, la sonda contra la cola ENTERA: una pendiente anterior
993
- // que comparta clave y no se pidió ⇒ aplazado, nunca fuera de orden.
994
- // **Y la sonda hereda el ORIGEN de esa anterior** (alpha.6, auditor 🟡 A
995
- // de alpha.5): si la anterior está —transitivamente— retenida solo por
996
- // un APARCADO, es el mismo bloqueo `parked:` que en la pasada completa
997
- // deja pasar la purga (invariante 11): el `detached` SE APLICA. Si la
998
- // anterior es un `attached`/`moved` pendiente de verdad (aplicable, o
999
- // retenido por un FALLO real), el `detached` sigue `deferred`: aplicarlo
1000
- // antes que un `moved` pendiente del mismo scope dejaría deriva.
1001
- if (only !== undefined) {
1002
- const earlier = await this.#dependencyOf(outbox, item, batchSize);
1003
- if (earlier !== null) {
1004
- const byParked = await this.#blockOriginOf(outbox, earlier, blocked, parkedKeys, batchSize, origins);
1005
- if (!(item.change.op === 'detached' && byParked)) {
1006
- const shared = _a.#changeKeys(earlier.change).find((key) => keys.includes(key)) ?? keys[0];
1007
- 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}); ` +
1008
- (byParked ? 'espera a una entrada APARCADA; mira dead()' : 'corre el relay completo'), byParked);
1009
- continue;
1010
- }
1011
- }
1012
- }
1013
- try {
1014
- await this.#applyScopeChange(driver, item);
1015
- }
1016
- catch (error) {
1017
- const message = error instanceof Error ? error.message : String(error);
1018
- const transient = _a.#isTransientRelayFailure(error);
1019
- await outbox.markFailed(item.id, message, { transient });
1020
- for (const key of keys)
1021
- block(key, item.id, false);
1022
- parked.add(id);
1023
- failures.push({ id: item.id, change: item.change, error: message, transient });
1024
- entries.push({ id: item.id, change: item.change, status: 'failed', reason: message, attempts: item.attempts });
1025
- continue;
1026
- }
1027
- await outbox.markApplied(item.id);
1028
- done.add(id);
1029
- applied.push({ id: item.id, change: item.change, attempts: item.attempts });
1030
- entries.push({ id: item.id, change: item.change, status: 'applied', attempts: item.attempts });
1031
1149
  }
1032
- // Una outbox que ignora `after` devuelve el mismo lote atascado: la
1033
- // pasada termina aquí en vez de dar vueltas (lo que quede, y lo que
1034
- // haya detrás, sigue pendiente para la siguiente).
1035
- if (!progress)
1036
- break;
1150
+ try {
1151
+ await this.#applyScopeChange(driver, item);
1152
+ }
1153
+ catch (error) {
1154
+ const message = error instanceof Error ? error.message : String(error);
1155
+ const transient = _a.#isTransientRelayFailure(error);
1156
+ await outbox.markFailed(item.id, message, { transient });
1157
+ for (const key of keys)
1158
+ block(key, item.id, 'failed');
1159
+ parked.add(id);
1160
+ failures.push({ id: item.id, change: item.change, error: message, transient });
1161
+ entries.push({ id: item.id, change: item.change, status: 'failed', reason: message, attempts: item.attempts });
1162
+ continue;
1163
+ }
1164
+ await outbox.markApplied(item.id);
1165
+ done.add(id);
1166
+ applied.push({ id: item.id, change: item.change, attempts: item.attempts });
1167
+ entries.push({ id: item.id, change: item.change, status: 'applied', attempts: item.attempts });
1037
1168
  }
1038
- entries.push(...deadEntries);
1039
- return {
1040
- applied,
1041
- failed: failures[0] ?? null,
1042
- failures,
1043
- deferred,
1044
- dead,
1045
- busy: false,
1046
- remaining: (await outbox.pending(1)).length > 0,
1047
- dryRun: false,
1048
- wouldApply: [],
1049
- entries,
1050
- missing: missingOf(new Set(entries.map((e) => String(e.id)))),
1051
- };
1052
- }
1053
- finally {
1054
- await lease?.release();
1169
+ // Una outbox que ignora `after` devuelve el mismo lote atascado: la
1170
+ // pasada termina aquí en vez de dar vueltas (lo que quede, y lo que
1171
+ // haya detrás, sigue pendiente para la siguiente).
1172
+ if (!progress)
1173
+ break;
1055
1174
  }
1175
+ entries.push(...deadEntries);
1176
+ // La cabeza global de la cola: `remaining` y, desde alpha.8, su EDAD.
1177
+ const { remaining, queue } = await this.#queueStatus(outbox);
1178
+ return {
1179
+ applied,
1180
+ failed: failures[0] ?? null,
1181
+ failures,
1182
+ deferred,
1183
+ dead,
1184
+ deadCount,
1185
+ queue,
1186
+ busy: false,
1187
+ remaining,
1188
+ dryRun: false,
1189
+ wouldApply: [],
1190
+ entries,
1191
+ missing: missingOf(new Set(entries.map((e) => String(e.id)))),
1192
+ };
1056
1193
  }
1057
1194
  /**
1058
1195
  * `only` normalizado (alpha.5 · R1): un conjunto de ids como cadena (el id
@@ -1109,15 +1246,14 @@ export class AuthorizationManager {
1109
1246
  }
1110
1247
  /**
1111
1248
  * Las claves de scope de lo APARCADO ⇒ el id aparcado que las nombra
1112
- * (alpha.5 · R2a). El censo es el `dead` ya leído si cabe entero en un
1113
- * lote (menos de `batchSize` filas = no hay más); si no, se relee ENTERO
1114
- * hasta `PARKED_CENSUS_MAX` — nunca acotado por `limit` (cierre, 🟠 2:
1115
- * con `--limit 50` o `{ only, limit: 1 }` la aparcada 101 no taponaba y el
1116
- * `attached` colgaba del padre viejo)—, y si la cota se alcanza la pasada
1117
- * se niega (500): un tapón incompleto no es un tapón.
1118
- */
1119
- static async #parkedKeys(outbox, dead, batchSize) {
1120
- const census = dead.length < batchSize ? dead : await _a.#deadLetters(outbox, PARKED_CENSUS_MAX);
1249
+ * (alpha.5 · R2a). El censo lo lee `#relayInputs`: el `dead` ya leído si
1250
+ * cabe entero en un lote (menos de `batchSize` filas = no hay más); si no,
1251
+ * la cola ENTERA hasta `PARKED_CENSUS_MAX` — nunca acotado por `limit`
1252
+ * (cierre, 🟠 2: con `--limit 50` o `{ only, limit: 1 }` la aparcada 101 no
1253
+ * taponaba y el `attached` colgaba del padre viejo)—, y si la cota se
1254
+ * alcanza la pasada se niega (500): un tapón incompleto no es un tapón.
1255
+ */
1256
+ static #parkedKeys(census) {
1121
1257
  if (census.length >= PARKED_CENSUS_MAX) {
1122
1258
  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 ` +
1123
1259
  `(PARKED_CENSUS_MAX): sin el censo entero el tapón por clave sería ciego y un attached podría colgarse del padre ` +
@@ -1125,9 +1261,19 @@ export class AuthorizationManager {
1125
1261
  }
1126
1262
  const keys = new Map();
1127
1263
  for (const item of census) {
1128
- for (const key of _a.#changeKeys(item.change))
1129
- if (!keys.has(key))
1130
- keys.set(key, item.id);
1264
+ // **Solo la clave del HIJO** (2.4.0-alpha.8 · variante B; dueño: sí).
1265
+ // Una aparcada dice que la POSICIÓN DE SU HIJO en el store es
1266
+ // incorrecta; no dice nada de su padre nuevo: un `attached(otraUnit,
1267
+ // orgA)` detrás de un `moved(unit → orgA)` aparcado escribe otra arista
1268
+ // y conmuta (medido, `m7-tapon.ts`: se aplazaba sin motivo y el store
1269
+ // conservaba el padre VIEJO de todo lo que colgara de orgA). Lo que SÍ
1270
+ // nombra al hijo —`attached(child, unit)`, `moved(unit → orgC)`— sigue
1271
+ // taponado. Vive SOLO aquí, en la siembra: al COMPARAR (`#changeKeys`
1272
+ // en `blocked`, el respaldo de la sonda) van siempre las dos claves, o
1273
+ // el caso 1136 se pierde (trampa medida por el juez).
1274
+ const key = scopeKey(item.change.child);
1275
+ if (!keys.has(key))
1276
+ keys.set(key, item.id);
1131
1277
  }
1132
1278
  return keys;
1133
1279
  }
@@ -1143,8 +1289,9 @@ export class AuthorizationManager {
1143
1289
  async #dependencyOf(outbox, item, batchSize) {
1144
1290
  const keys = _a.#changeKeys(item.change);
1145
1291
  if (typeof outbox.earlierPending === 'function') {
1146
- const scopes = item.change.op === 'detached' ? [item.change.child] : [item.change.child, item.change.parent];
1147
- return outbox.earlierPending(item.id, scopes);
1292
+ // La misma lista que `#changeKeys` (la raíz exenta, alpha.8): el SQL del
1293
+ // puerto sí sabe casar `parent_type = 'app'`; el manager no se lo pide.
1294
+ return outbox.earlierPending(item.id, _a.#changeScopes(item.change));
1148
1295
  }
1149
1296
  const mine = String(item.id);
1150
1297
  let after;
@@ -1186,23 +1333,31 @@ export class AuthorizationManager {
1186
1333
  const known = memo.get(id);
1187
1334
  if (known !== undefined)
1188
1335
  return known;
1189
- const decide = (byParked) => {
1190
- memo.set(id, byParked);
1191
- return byParked;
1336
+ const decide = (origin) => {
1337
+ memo.set(id, origin);
1338
+ return origin;
1192
1339
  };
1340
+ // Pasada la cota, hacia lo seguro: como un fallo real.
1193
1341
  if (hops >= RELAY_ORIGIN_MAX_HOPS)
1194
- return decide(false);
1342
+ return decide('failed');
1195
1343
  const keys = _a.#changeKeys(entry.change);
1196
1344
  const hits = keys.map((key) => blocked.get(key)).filter((hit) => hit !== undefined);
1197
- if (hits.some((hit) => !hit.parked))
1198
- return decide(false);
1345
+ // Desde 2.4.0-alpha.8 el origen es TRI-estado (`failed > pending > parked`):
1346
+ // la decisión aplicar/aplazar no cambia (solo `parked` exime a la purga);
1347
+ // lo que cambia es que el reporte distingue «espera a una pendiente de
1348
+ // verdad» de «bloqueada por un fallo».
1349
+ if (hits.some((hit) => hit.origin === 'failed'))
1350
+ return decide('failed');
1351
+ if (hits.some((hit) => hit.origin === 'pending'))
1352
+ return decide('pending');
1199
1353
  if (entry.change.op !== 'detached') {
1200
1354
  if (hits.length > 0 || keys.some((key) => parkedKeys.has(key)))
1201
- return decide(true);
1355
+ return decide('parked');
1202
1356
  }
1203
1357
  const earlier = await this.#dependencyOf(outbox, entry, batchSize);
1358
+ // Ninguna anterior: se aplicaría en la pasada completa ⇒ la pedida espera a una PENDIENTE de verdad.
1204
1359
  if (earlier === null)
1205
- return decide(false);
1360
+ return decide('pending');
1206
1361
  return decide(await this.#blockOriginOf(outbox, earlier, blocked, parkedKeys, batchSize, memo, hops + 1));
1207
1362
  }
1208
1363
  /**
@@ -1240,10 +1395,45 @@ export class AuthorizationManager {
1240
1395
  *
1241
1396
  * Exige `dead`, `requeue` y `laterApplied` en el puerto (500
1242
1397
  * `E_AUTHZ_UNSUPPORTED` nombrando el que falte): sin censo no se sabe qué
1243
- * se reencola y sin sonda no hay requeue. No pasa por la barrera del freeze:
1244
- * no escribe en el backend; la pasada que aplique la entrada sí la pasa.
1398
+ * se reencola y sin sonda no hay requeue. Sin `apply` no pasa por la
1399
+ * barrera del freeze: no escribe en el backend; la pasada que aplique la
1400
+ * entrada sí la pasa.
1401
+ *
1402
+ * **`apply: true` — reencolar Y relevar en la misma llamada** (2.4.0-alpha.8;
1403
+ * panel relay 2, cuestión 4; decisión del dueño 2026-09-10 (4)). Medido
1404
+ * (tester P14): la reencolada vuelve con su id ORIGINAL (menor) y la
1405
+ * siguiente pasada `only` del tenant salía `deferred · depends-on:` — el
1406
+ * incidente no se cerraba con un comando. Con `apply`, tras reencolar se
1407
+ * releva SOLO eso (`only` = exactamente los ids reencolados: los
1408
+ * dependientes NO se arrastran, quedan `deferred`/pendientes y el reporte lo
1409
+ * dice —«`only` es mi recibo»—) y el reporte del relay viaja en
1410
+ * `report.relay`. Cuatro condiciones, todas fijadas con caso: (1) **un solo
1411
+ * lease** para toda la llamada (tomarlo dos veces era `busy` en verde,
1412
+ * auditor 🟠 5) — se toma PRIMERO, y si está ocupado es `relay.busy` con
1413
+ * CERO filas reencoladas (reencolar y decir `busy` dejaba `attempts = max −
1414
+ * 1` y la entrada se volvía a aparcar contaminando su clave); (2) **pasa la
1415
+ * barrera del freeze** ANTES de tocar nada (con `apply` escribe el árbol);
1416
+ * (3) **excluyente con `force`** (500 juntos: con las dos, revertir el árbol
1417
+ * pasaba de dos llamadas de plataforma a una, auditor 🟠 6); (4) la sonda
1418
+ * anti-reversión corre ANTES de reencolar, como siempre. `waitForLeaseMs`
1419
+ * (solo con `apply`) es la misma espera acotada que en el relay, para que
1420
+ * el comando que cierra el incidente no pierda siempre contra el cron.
1245
1421
  */
1246
1422
  async requeueScopeChanges(ids, options = {}) {
1423
+ _a.#assertKnownOptions('requeueScopeChanges', options, REQUEUE_OPTIONS);
1424
+ const apply = options.apply === true;
1425
+ if (apply && options.force === true) {
1426
+ 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 ' +
1427
+ 'aplicado sería UNA sola llamada. Reencola con --force y releva después a sabiendas, o usa --apply sin --force. ' +
1428
+ 'No se ha hecho nada.');
1429
+ }
1430
+ if (!apply && options.waitForLeaseMs !== undefined) {
1431
+ 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.');
1432
+ }
1433
+ const waitForLeaseMs = _a.#waitMs(options.waitForLeaseMs);
1434
+ // Con `apply` se ESCRIBE el árbol: la barrera del freeze va antes de nada.
1435
+ if (apply)
1436
+ await this.#assertNotFrozen('authz:scopes:requeue --apply');
1247
1437
  const outbox = this.#outbox();
1248
1438
  if (!outbox) {
1249
1439
  throw new AuthorizationConfigError("authz:scopes:requeue necesita 'scopes.outbox' en config/authorization.ts: sin cola no hay nada aparcado que reencolar.");
@@ -1259,6 +1449,22 @@ export class AuthorizationManager {
1259
1449
  : 'Devuelve la entrada aparcada a pending() con attempts = maxAttempts − 1, todo o nada.');
1260
1450
  }
1261
1451
  }
1452
+ // Con `apply`, el lease PRIMERO (una vez para toda la llamada): ocupado ⇒
1453
+ // `busy` con cero filas tocadas. Sin `apply` no hay lease que tomar.
1454
+ const lease = apply ? await this.#acquireLease(outbox, waitForLeaseMs) : null;
1455
+ if (apply && outbox.acquire && lease === null) {
1456
+ const inputs = await _a.#relayInputs(outbox, [...wanted], DEFAULT_RELAY_BATCH);
1457
+ return { requeued: [], overrides: [], forced: false, relay: await this.#busyReport(outbox, inputs) };
1458
+ }
1459
+ try {
1460
+ return await this.#requeueHeld(outbox, wanted, options.force === true, apply);
1461
+ }
1462
+ finally {
1463
+ await lease?.release();
1464
+ }
1465
+ }
1466
+ /** `requeueScopeChanges` con el lease (si `apply`) ya tomado: censo, sonda anti-reversión, requeue y —con `apply`— el relay de esos ids. */
1467
+ async #requeueHeld(outbox, wanted, force, apply) {
1262
1468
  // El censo de lo aparcado (acotado como el del relay): solo eso se reencola.
1263
1469
  const parked = await outbox.dead(DEFAULT_RELAY_LIMIT);
1264
1470
  const byId = new Map();
@@ -1273,7 +1479,9 @@ export class AuthorizationManager {
1273
1479
  const conflicts = [];
1274
1480
  for (const id of wanted) {
1275
1481
  const item = byId.get(String(id));
1276
- const scopes = item.change.op === 'detached' ? [item.change.child] : [item.change.child, item.change.parent];
1482
+ // La misma lista que `#changeKeys` (la raíz exenta, alpha.8): dos altas de
1483
+ // tenant no se revierten entre sí, y el conflicto que salía era falso.
1484
+ const scopes = _a.#changeScopes(item.change);
1277
1485
  // Se pide UNA de más para saber si el diff que va a leer el operador está cortado (cierre, ⚪ 6).
1278
1486
  const later = await outbox.laterApplied(item.id, scopes, DEFAULT_RELAY_BATCH + 1);
1279
1487
  const applied = later.slice(0, DEFAULT_RELAY_BATCH);
@@ -1281,7 +1489,6 @@ export class AuthorizationManager {
1281
1489
  conflicts.push({ parked: item, applied, ...(later.length > DEFAULT_RELAY_BATCH ? { truncated: true } : {}) });
1282
1490
  }
1283
1491
  }
1284
- const force = options.force === true;
1285
1492
  if (conflicts.length > 0 && !force) {
1286
1493
  const detail = conflicts
1287
1494
  .map((conflict) => {
@@ -1299,7 +1506,15 @@ export class AuthorizationManager {
1299
1506
  `ace admite --force (imprime este diff y entra igual).`, conflicts);
1300
1507
  }
1301
1508
  await outbox.requeue([...wanted]);
1302
- return { requeued: wanted.map((id) => byId.get(String(id))), overrides: conflicts, forced: force };
1509
+ const report = { requeued: wanted.map((id) => byId.get(String(id))), overrides: conflicts, forced: force };
1510
+ if (!apply)
1511
+ return report;
1512
+ // Bajo el MISMO lease: solo los ids reencolados, con lo aparcado releído
1513
+ // (las reencoladas ya no salen por `dead()`); la barrera del freeze se
1514
+ // re-afirma por lote dentro, como en toda pasada.
1515
+ const inputs = await _a.#relayInputs(outbox, [...wanted], DEFAULT_RELAY_BATCH);
1516
+ report.relay = await this.#drainScopeChanges(outbox, { limit: DEFAULT_RELAY_LIMIT, batchSize: DEFAULT_RELAY_BATCH, ...inputs });
1517
+ return report;
1303
1518
  }
1304
1519
  /** Los ids de `requeue`: lista NO vacía de números o cadenas, sin repetidos (500 si no). */
1305
1520
  static #requeueIds(ids) {
@@ -1385,6 +1600,15 @@ export class AuthorizationManager {
1385
1600
  // El techo de profundidad del consumidor, si lo declaró (alpha.5 · R0):
1386
1601
  // el mismo mando que el gate de `scopes.*`.
1387
1602
  ...(this.#config.scopes?.maxDepth === undefined ? {} : { maxDepth: this.#config.scopes.maxDepth }),
1603
+ // **Los tipos del OTRO subsistema del mismo backend** (alpha.9 · B · S4),
1604
+ // si el consumidor declaró relaciones. Es SOLO para el reporte: parte lo
1605
+ // ajeno en «declarado» y «no lo reclama nadie». La decisión de BORRAR no
1606
+ // lo mira —la toma la lista blanca congelada del destino—, porque el
1607
+ // proceso que corre un cutover puede no tener esta config y aun así
1608
+ // tiene que respetar las tuplas de quien sí la tiene.
1609
+ ...(this.#config.relations?.config === undefined
1610
+ ? {}
1611
+ : { relationTypes: this.#config.relations.config.objectTypes.map((type) => type.type) }),
1388
1612
  };
1389
1613
  const pass = async () => {
1390
1614
  const report = await target.reconcile(source, options);
@@ -1601,11 +1825,42 @@ export class AuthorizationManager {
1601
1825
  * comparten ninguna no pueden interactuar en el árbol —toda dependencia
1602
1826
  * (recolgar, cerrar un ciclo, purgar) viaja por un nodo nombrado—, así que
1603
1827
  * el orden RELATIVO que hay que conservar es exactamente este.
1828
+ *
1829
+ * **La raíz `app` NO es clave** (2.4.0-alpha.8; panel relay 2, cuestión 1;
1830
+ * decisión del dueño 2026-09-10 (4)). La raíz no se mueve, no se purga y
1831
+ * nunca es hijo (los tres 422 de `#assertEdge`/`scopes.detached`), así que
1832
+ * dos cambios cuyo ÚNICO cruce sea `app` conmutan: ningún orden que
1833
+ * conservar (el auditor lo buscó y no lo encontró; `m2-orden.ts`). Con
1834
+ * `[hijo, app]` toda alta de tenant compartía clave con todas las demás:
1835
+ * una aparcada bajo la raíz congelaba el registro entero (5 pasadas con
1836
+ * `applied = []`, medido en un consumidor), dos altas concurrentes con la
1837
+ * receta inline dejaban la segunda `deferred` SIN nada aparcado, y
1838
+ * `requeue` se negaba con un conflicto falso. Se decide por REF
1839
+ * (`#isRootRef`), nunca por la cadena `'app'`, y en los TRES sitios que
1840
+ * construyen la lista: aquí, `#dependencyOf` y `requeueScopeChanges` (los
1841
+ * dos por `#changeScopes`); también para el bloqueo por FALLO (una sola
1842
+ * regla). La lista nunca queda vacía: el hijo siempre va.
1604
1843
  */
1605
1844
  static #changeKeys(change) {
1606
- return change.op === 'detached'
1607
- ? [scopeKey(change.child)]
1608
- : [scopeKey(change.child), scopeKey(change.parent)];
1845
+ return _a.#changeScopes(change).map((scope) => scopeKey(scope));
1846
+ }
1847
+ /**
1848
+ * Los scopes de `#changeKeys` como REFERENCIAS: la lista que va al puerto
1849
+ * (`earlierPending`/`laterApplied`). Es la misma decisión que
1850
+ * `#changeKeys` y vive en un solo sitio a propósito (auditor 🟠 3 del panel
1851
+ * relay 2: exentar la raíz solo en las claves dejaba el mutex vivo bajo
1852
+ * `only`, porque el SQL de la sonda sí casa `parent_type = 'app'`).
1853
+ */
1854
+ static #changeScopes(change) {
1855
+ if (change.op === 'detached')
1856
+ return [change.child];
1857
+ if (_a.#isRootRef(change.parent))
1858
+ return [change.child];
1859
+ return [change.child, change.parent];
1860
+ }
1861
+ /** ¿Es la raíz? Por REF (`type === 'app' && uuid === null`), jamás comparando la cadena `'app'`. */
1862
+ static #isRootRef(scope) {
1863
+ return scope.type === APP_SCOPE_TYPE && (scope.uuid ?? null) === null;
1609
1864
  }
1610
1865
  /** Lo aparcado por la outbox (si sabe aparcar), listo para el reporte. */
1611
1866
  static async #deadLetters(outbox, limit) {
@@ -1648,6 +1903,28 @@ export class AuthorizationManager {
1648
1903
  await this.#notify(event);
1649
1904
  }
1650
1905
  /** Cota entera positiva de las opciones del relay (500 si llega otra cosa). */
1906
+ /**
1907
+ * **Una opción desconocida es 500, nunca silencio** (2.4.0-alpha.8 · X-1;
1908
+ * tester del panel relay 2, §10-d). Medido en alpha.7: `requeueScopeChanges(ids,
1909
+ * { apply: true })` y `relayScopeChanges({ waitForLeaseMs })` se IGNORABAN
1910
+ * —el objeto de opciones no se validaba— y un consumidor en JS (o con
1911
+ * `as any`) creía que aplicaba o esperaba cuando no; el síntoma era un
1912
+ * `busy` o un `deferred` achacado a otra cosa. Se comprueban las CLAVES
1913
+ * (un valor `undefined` en una clave conocida vale: es lo que pasan los
1914
+ * comandos ace), y el objeto tiene que serlo.
1915
+ */
1916
+ static #assertKnownOptions(operation, options, known) {
1917
+ if (options === undefined)
1918
+ return;
1919
+ if (options === null || typeof options !== 'object' || Array.isArray(options)) {
1920
+ throw new AuthorizationConfigError(`${operation}: las opciones deben ser un objeto { ${known.join(', ')} }; llegó ${Array.isArray(options) ? 'una lista' : String(options)}.`);
1921
+ }
1922
+ const unknown = Object.keys(options).filter((key) => !known.includes(key));
1923
+ if (unknown.length > 0) {
1924
+ throw new AuthorizationConfigError(`${operation}: opción(es) desconocida(s): ${unknown.join(', ')}. Las conocidas son ${known.join(', ')}. ` +
1925
+ 'Una opción que no existe no se ignora: en JS creerías que aplica (o espera) cuando no. No se ha hecho nada.');
1926
+ }
1927
+ }
1651
1928
  static #positive(value, fallback, name) {
1652
1929
  if (value === undefined)
1653
1930
  return fallback;
@@ -1860,10 +2137,16 @@ export class AuthorizationManager {
1860
2137
  throw new AuthorizationConfigError(`${operation}: scopes.maxDepth debe ser un entero >= 1 (llegó ${String(maxDepth)}); quítalo si no quieres techo.`);
1861
2138
  }
1862
2139
  if (chain.length > maxDepth) {
2140
+ // El `message` es para el TENANT (2.4.0-alpha.8, cuestión 7.2): sin el
2141
+ // nombre del backend ni sus constantes; eso va en `details`, para el log.
1863
2142
  throw new ScopeDepthLimitError(`${operation}: colgar ${child.type}:${child.uuid} de ${parent.type}:${parent.uuid ?? ''} lo dejaría a ${chain.length} ` +
1864
- `saltos de app y scopes.maxDepth=${maxDepth}. No se ha escrito ni encolado nada. El techo existe porque el modelo ` +
1865
- `facts de openfga no resuelve de forma fiable más allá de FACTS_MAX_RESOLVE_DEPTH (22): pasado él, authorize es ` +
1866
- `503 para todo ese subárbol. Aplana el árbol, o sube el techo (y OPENFGA_RESOLVE_NODE_LIMIT en el servidor).`);
2143
+ `saltos de la raíz y el techo es scopes.maxDepth=${maxDepth}. No se ha escrito ni encolado nada. Aplana el árbol ` +
2144
+ `(o pide al operador que suba el techo).`, {
2145
+ depth: chain.length,
2146
+ maxDepth,
2147
+ 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',
2148
+ server: 'para subir el techo hay que subir también OPENFGA_RESOLVE_NODE_LIMIT en el servidor',
2149
+ });
1867
2150
  }
1868
2151
  }
1869
2152
  // La identidad se valida AQUÍ, antes de resolver siquiera el driver: una