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

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 (103) hide show
  1. package/README.md +515 -22
  2. package/build/commands/authz_relations_reconcile.d.ts +13 -62
  3. package/build/commands/authz_relations_reconcile.d.ts.map +1 -1
  4. package/build/commands/authz_relations_reconcile.js +202 -116
  5. package/build/commands/authz_relations_reconcile.js.map +1 -1
  6. package/build/commands/openfga_provision.d.ts +62 -0
  7. package/build/commands/openfga_provision.d.ts.map +1 -1
  8. package/build/commands/openfga_provision.js +193 -1
  9. package/build/commands/openfga_provision.js.map +1 -1
  10. package/build/index.d.ts +6 -6
  11. package/build/index.d.ts.map +1 -1
  12. package/build/index.js +3 -3
  13. package/build/index.js.map +1 -1
  14. package/build/providers/authz_provider.d.ts.map +1 -1
  15. package/build/providers/authz_provider.js +4 -1
  16. package/build/providers/authz_provider.js.map +1 -1
  17. package/build/src/define_config.d.ts +32 -0
  18. package/build/src/define_config.d.ts.map +1 -1
  19. package/build/src/define_config.js +109 -0
  20. package/build/src/define_config.js.map +1 -1
  21. package/build/src/drivers/database_relations_driver.d.ts +36 -1
  22. package/build/src/drivers/database_relations_driver.d.ts.map +1 -1
  23. package/build/src/drivers/database_relations_driver.js +213 -46
  24. package/build/src/drivers/database_relations_driver.js.map +1 -1
  25. package/build/src/drivers/openfga_driver.d.ts +30 -4
  26. package/build/src/drivers/openfga_driver.d.ts.map +1 -1
  27. package/build/src/drivers/openfga_driver.js +151 -45
  28. package/build/src/drivers/openfga_driver.js.map +1 -1
  29. package/build/src/drivers/openfga_facts.d.ts +21 -0
  30. package/build/src/drivers/openfga_facts.d.ts.map +1 -1
  31. package/build/src/drivers/openfga_facts.js +74 -8
  32. package/build/src/drivers/openfga_facts.js.map +1 -1
  33. package/build/src/drivers/openfga_relations_driver.d.ts +140 -10
  34. package/build/src/drivers/openfga_relations_driver.d.ts.map +1 -1
  35. package/build/src/drivers/openfga_relations_driver.js +743 -49
  36. package/build/src/drivers/openfga_relations_driver.js.map +1 -1
  37. package/build/src/errors.d.ts +148 -0
  38. package/build/src/errors.d.ts.map +1 -1
  39. package/build/src/errors.js +166 -0
  40. package/build/src/errors.js.map +1 -1
  41. package/build/src/manager.d.ts.map +1 -1
  42. package/build/src/manager.js +15 -5
  43. package/build/src/manager.js.map +1 -1
  44. package/build/src/openfga.d.ts +1 -1
  45. package/build/src/openfga.d.ts.map +1 -1
  46. package/build/src/openfga.js +1 -1
  47. package/build/src/openfga.js.map +1 -1
  48. package/build/src/relations/check_many.d.ts +1 -1
  49. package/build/src/relations/check_many.d.ts.map +1 -1
  50. package/build/src/relations/check_many.js +6 -4
  51. package/build/src/relations/check_many.js.map +1 -1
  52. package/build/src/relations/define_relations_config.d.ts +187 -1
  53. package/build/src/relations/define_relations_config.d.ts.map +1 -1
  54. package/build/src/relations/define_relations_config.js +395 -7
  55. package/build/src/relations/define_relations_config.js.map +1 -1
  56. package/build/src/relations/manager.d.ts +33 -2
  57. package/build/src/relations/manager.d.ts.map +1 -1
  58. package/build/src/relations/manager.js +123 -4
  59. package/build/src/relations/manager.js.map +1 -1
  60. package/build/src/relations/reconcile.d.ts +295 -13
  61. package/build/src/relations/reconcile.d.ts.map +1 -1
  62. package/build/src/relations/reconcile.js +886 -85
  63. package/build/src/relations/reconcile.js.map +1 -1
  64. package/build/src/relations/tuple_order.d.ts +126 -0
  65. package/build/src/relations/tuple_order.d.ts.map +1 -0
  66. package/build/src/relations/tuple_order.js +598 -0
  67. package/build/src/relations/tuple_order.js.map +1 -0
  68. package/build/src/relations_config_store.d.ts.map +1 -1
  69. package/build/src/relations_config_store.js +29 -7
  70. package/build/src/relations_config_store.js.map +1 -1
  71. package/build/src/relations_reconcile.d.ts +79 -0
  72. package/build/src/relations_reconcile.d.ts.map +1 -0
  73. package/build/src/relations_reconcile.js +85 -0
  74. package/build/src/relations_reconcile.js.map +1 -0
  75. package/build/src/scope_outbox.d.ts +9 -5
  76. package/build/src/scope_outbox.d.ts.map +1 -1
  77. package/build/src/scope_outbox.js +7 -4
  78. package/build/src/scope_outbox.js.map +1 -1
  79. package/build/src/shared/backend_guard.d.ts +8 -0
  80. package/build/src/shared/backend_guard.d.ts.map +1 -1
  81. package/build/src/shared/backend_guard.js +15 -0
  82. package/build/src/shared/backend_guard.js.map +1 -1
  83. package/build/src/testing/main.d.ts +1 -1
  84. package/build/src/testing/main.d.ts.map +1 -1
  85. package/build/src/testing/main.js +1 -1
  86. package/build/src/testing/main.js.map +1 -1
  87. package/build/src/testing/relations_contract.d.ts +18 -0
  88. package/build/src/testing/relations_contract.d.ts.map +1 -1
  89. package/build/src/testing/relations_contract.js +895 -45
  90. package/build/src/testing/relations_contract.js.map +1 -1
  91. package/build/src/testing/relations_reconcile_contract.d.ts +9 -0
  92. package/build/src/testing/relations_reconcile_contract.d.ts.map +1 -1
  93. package/build/src/testing/relations_reconcile_contract.js +207 -0
  94. package/build/src/testing/relations_reconcile_contract.js.map +1 -1
  95. package/build/src/testing/scope_outbox_contract.d.ts +14 -1
  96. package/build/src/testing/scope_outbox_contract.d.ts.map +1 -1
  97. package/build/src/testing/scope_outbox_contract.js +126 -48
  98. package/build/src/testing/scope_outbox_contract.js.map +1 -1
  99. package/build/src/types.d.ts +90 -6
  100. package/build/src/types.d.ts.map +1 -1
  101. package/build/src/types.js +2 -0
  102. package/build/src/types.js.map +1 -1
  103. package/package.json +2 -1
@@ -27,12 +27,14 @@
27
27
  * clave, niega la dirección rancia y congela; la guardia de superficie de
28
28
  * `command_flags.spec.ts` impide re-exportarlo por ningún subpath.
29
29
  */
30
- import { AuthorizationConfigError, InvalidIdentityError, MassReconcileRefusedError, RelationTypeUnknownError, RelationUnknownError, UnsupportedOperationError, } from '../errors.js';
30
+ import { ActorRequiredError, AuthorizationConfigError, CheckManyBatchError, DenyMigrationRefusedError, InvalidIdentityError, InvalidSlugError, MassReconcileRefusedError, NoScopeResolverError, RelationConfigError, RelationConfigUnknownKeyError, RelationDenyExpiryError, RelationNotDenyableError, RelationTypeUnknownError, RelationUnknownError, RelationUsersetUnsupportedError, UnknownScopeError, UnsupportedOperationError, isPartialWrite, markPartialWrite, } from '../errors.js';
31
31
  import { systemClock } from '../clock.js';
32
32
  import { isActiveExpiry, sameInstant } from '../expiry.js';
33
33
  import { assertPartition, scopeKey } from '../identity.js';
34
34
  import { isRelUserset } from '../types.js';
35
+ import { assertRelationDeclared, assertSupportedUserset, RELATION_DENY_PREFIX, RELATION_USERSET_TYPE, relationsUp } from './define_relations_config.js';
35
36
  import { relationTupleKey } from './tuple_key.js';
37
+ import { carriedDenies, denyCarrierGroups, relationTransitionOrder } from './tuple_order.js';
36
38
  /**
37
39
  * La clave con la que se casan origen y destino es `relationTupleKey` (plan
38
40
  * 2.5 · lote A, P3-4): la MISMA función pura y exportada que un consumidor
@@ -43,6 +45,101 @@ import { relationTupleKey } from './tuple_key.js';
43
45
  * (`tests/relation_tuple_key.spec.ts` lo vigila por fuente).
44
46
  */
45
47
  const tupleKey = relationTupleKey;
48
+ /**
49
+ * **Una tupla ENUMERADA por un store con una identidad fuera de la gramática**
50
+ * (⚪ R2 del re-ataque al cierre del lote A, decisión del dueño 2026-10-15 (2)):
51
+ * `document:<key>|ABC` escrita a mano con el SDK, una fila con `object_uuid`
52
+ * en mayúsculas insertada por otro proceso… `relationTupleKey` valida cada
53
+ * componente (⚪ A9) y hasta el cierre-2 `planPartition` la aplicaba tal cual a
54
+ * lo enumerado, así que un dato malo del BACKEND salía como 422
55
+ * `E_AUTHZ_INVALID_IDENTITY` —«pregunta inválida» del llamante, invariante 5—
56
+ * y bloqueaba el reconcile de la partición entera, también con `--dry-run`.
57
+ * Ahora se CLASIFICA y se LISTA: en el destino es `extra` con motivo
58
+ * (`--prune` pide al driver que la borre; `--dry-run` la lista con su línea),
59
+ * en el origen no se migra (la API no puede escribirla) y es deriva. Nunca un
60
+ * 422 por un dato que no vino del llamante.
61
+ */
62
+ /**
63
+ * **Los motivos de `RelationsReconcileDetail`, en RUNTIME** (cierre 2 de D2 · ⚪ N6): la guardia L9
64
+ * (`tests/relations_reconcile_l9.spec.ts`) los recorre y exige que cada uno tenga su entrada en `RELATION_RESIDUES`
65
+ * (dónde se clasifica: el PLAN o la PASADA) y su caso. `renewal-unsafe` (cierre 2 de D2 · 🟠 N1, solo `destino`): la
66
+ * renovación (otra caducidad) de una membresía `group:*#member` que TRANSPORTA un deny, en un destino que no declara
67
+ * `atomicRenewal` (renueva con delete + write, `openfga`): entre los dos `Write` el deny no alcanzaría al sujeto. Desde el
68
+ * cierre 5 de D2 (opción A) NIEGA SU PARTICIÓN (`partitionsRefused`: cero escrituras y cero borrados en ella, el resto
69
+ * migra); la línea la nombra con su receta y es deriva. `withheld-by-unsafe-deny` (cierre 4) se fue con la retención.
70
+ */
71
+ export const RELATIONS_RECONCILE_DETAIL_REASONS = Object.freeze([
72
+ 'invalid-identity',
73
+ 'userset-unsupported',
74
+ 'undeclared',
75
+ 'deny-conditional',
76
+ 'renewal-unsafe',
77
+ ]);
78
+ /**
79
+ * **Una receta POR MOTIVO** (cierre 1 de D2 · 🟡 6, ⚪ 11). `honor` = el deny ya está en el destino y lo que falta es
80
+ * que lo honre (opt-in), no que lo escriba. La de `undeclared` está EJECUTADA en `relations_reconcile_deny.spec`
81
+ * (re-declarar → `removeDeny` → retirar ⇒ la pasada entra).
82
+ */
83
+ export function denyRefusalRecipe(reason, objectType, relation, honor = false) {
84
+ const pair = `${objectType}#${relation}`;
85
+ switch (reason) {
86
+ case 'no-capability':
87
+ return (`el destino no sabe escribir ni honrar denies: migra a un destino que declare relationDenies (los dos drivers del ` +
88
+ `paquete lo hacen desde 2.5.0-alpha.3), o retira esos denies en el ORIGEN con removeDeny, y repite.`);
89
+ case 'no-deny-phase':
90
+ return (`un driver de terceros que declara relationDenies tiene que traer denyPhase(type, relation) (puerto, 2.5.0-alpha.3): ` +
91
+ `actualízalo; o retira esos denies en el ORIGEN con removeDeny, y repite.`);
92
+ case 'phase':
93
+ return honor
94
+ ? `declara el opt-in de '${objectType}' en la config del DESTINO (deny: 'read' basta para honrar un deny que ya está) ` +
95
+ `—provision → fijar modelId → desplegar—, o retira ese deny del destino con removeDeny, y repite.`
96
+ : `pon '${objectType}' en deny: 'write' en la config del DESTINO —provision → fijar modelId → desplegar, README ` +
97
+ `«Upgrading to 2.5.0-alpha.3»—, o retira esos denies en el ORIGEN con removeDeny, y repite.`;
98
+ case 'undeclared':
99
+ return (`${pair} no está declarado. Si es una relación RETIRADA con denies vivos: vuelve a declarar la relación ` +
100
+ `TEMPORALMENTE en la config del proceso que escribe en el ORIGEN (la relación basta: removeDeny no exige el opt-in), ` +
101
+ `retira esos denies con removeDeny, quítala otra vez y repite — removeDeny sobre un par NO declarado es 422 (F-05). ` +
102
+ `Si el destino debe tenerla: declárala en la config del DESTINO.`);
103
+ case 'model':
104
+ return (`el modelo FIJADO del destino no declara ${objectType}#denied_${relation}: publica el modelo desde la config del ` +
105
+ `destino (openfga:provision --store-id) y fija el OPENFGA_MODEL_ID nuevo en el proceso que corre la pasada (y en la ` +
106
+ `flota), y repite.`);
107
+ case 'unmarked':
108
+ return (`el driver que enumera esa tupla (origen o destino: lo dice el mensaje) no marca kind: 'deny' — un driver de terceros ` +
109
+ `o un proceso < 2.5.0-alpha.3 —: actualízalo y repite. La pasada no la toca: su dueño es ese driver.`);
110
+ case 'invalid':
111
+ return (`la identidad o el sujeto del deny no entra en la gramática del destino, y removeDeny también la rechazaría: bórralo ` +
112
+ `a mano en el ORIGEN (database: DELETE de esa fila de authz_relations; openfga: un Write con delete) y repite.`);
113
+ }
114
+ }
115
+ /**
116
+ * **La receta de una partición NEGADA** (cierre 5 de D2, opción A), por el conjunto de causas. HONESTA a propósito: en
117
+ * un destino sin `atomicRenewal` (`openfga`: FGA no reemplaza la misma clave en un `Write` —no hay upsert,
118
+ * openfga/openfga#2151—) NO existe forma sin ventana de cambiar la caducidad de una membresía que transporta un deny, ni
119
+ * de rehacer permanente un deny con caducidad. Ninguna de las salidas de abajo se presenta como segura.
120
+ */
121
+ export function partitionRefusalRecipe(reasons) {
122
+ const set = new Set(reasons);
123
+ const out = [];
124
+ if (set.has('renewal-unsafe')) {
125
+ out.push(`renewal-unsafe: en este destino NO hay forma sin ventana de cambiar la caducidad de una membresía cuyo grupo ` +
126
+ `transporta un deny (la renovación son dos Write; openfga/openfga#2151, sin upsert). Salidas para ESTA partición ` +
127
+ `(ninguna sin precio): (1) unrelate de esa membresía en el ORIGEN, y repite (sin --prune el destino la conserva hasta ` +
128
+ `su caducidad; con --prune se borra, como en el origen). (2) unrelate + relate de esa membresía en el DESTINO con la ` +
129
+ `caducidad del ORIGEN (o SIN caducidad, si el origen no la tiene) aceptando la ventana de forma EXPLÍCITA — mientras ` +
130
+ `dura, el sujeto no recibe el deny del grupo y concede lo que otro camino le dé y ese deny corte —, y repite la pasada. ` +
131
+ `(3) Esperar a que venza la caducidad vieja del DESTINO: entonces la tupla cuenta como ausente, la reescritura es una ` +
132
+ `escritura normal y la partición migra sola — y en ese intervalo el destino concede lo que ese deny corta (la línea ` +
133
+ `lo fecha). Cambiar la membresía a SIN caducidad en el ORIGEN NO desbloquea la partición (la del destino caduca: sigue ` +
134
+ `siendo un cambio de caducidad). Para el FUTURO, el patrón recomendado: membresías SIN caducidad en los grupos con ` +
135
+ `deny, que se terminan con unrelate.`);
136
+ }
137
+ if (set.has('deny-conditional')) {
138
+ out.push(`deny-conditional: un deny no caduca (D3) y este destino no puede rehacerlo permanente en una escritura. removeDeny + ` +
139
+ `deny de esa tupla en el DESTINO aceptando la ventana de forma EXPLÍCITA — entre los dos el deny no niega —, y repite.`);
140
+ }
141
+ return out.join(' ');
142
+ }
46
143
  /**
47
144
  * Enumera TODAS las tuplas de las particiones pedidas, repartidas por clave de
48
145
  * partición. UNA pasada con `enumerateRelationsMany` si el driver lo trae
@@ -104,16 +201,29 @@ export async function reconcileRelations(options) {
104
201
  // alpha.6: origen y destino se enumeran UNA vez para todas las particiones.
105
202
  const enumeratedByKey = await enumerateAllMany(from, partitions, 'origen');
106
203
  const destByKeyAll = await enumerateAllMany(to, partitions, 'destino');
107
- // Primero se DECIDE todo (K diffs, K seguros R-17) y luego se escribe: un
108
- // seguro que salte en la partición 3 no deja las dos primeras a medias.
204
+ // Primero se DECIDE todo (K diffs, K seguros R-17, la puerta del deny) y luego se escribe: un seguro que salte en la
205
+ // partición 3 no deja las dos primeras a medias.
206
+ // Cierre 2 de D2 · 🟡 N3: el auto-corte del orden (un deny de `d` corta un allow de `a` sii `d ∈ up*(a)`, D5) sale de la
207
+ // config del DESTINO; sin ella el orden no da ningún allow por cortado (lo conservador). Cierre 4: también la retención.
208
+ const cuts = toConfig
209
+ ? (objectType, denied, allowed) => {
210
+ try {
211
+ return relationsUp(toConfig, objectType, allowed).includes(denied);
212
+ }
213
+ catch {
214
+ return false;
215
+ }
216
+ }
217
+ : undefined;
109
218
  const plans = [];
110
219
  for (const partition of partitions) {
111
220
  const enumerated = enumeratedByKey.get(scopeKey(partition)) ?? [];
112
221
  const destTuples = destByKeyAll.get(scopeKey(partition)) ?? [];
113
- plans.push(planPartition(partition, enumerated, destTuples, { now, prune, toConfig }));
222
+ plans.push(planPartition(partition, enumerated, destTuples, { now, prune, toConfig, atomicRenewal: to.capabilities?.atomicRenewal === true, cuts }));
114
223
  }
115
224
  const report = {
116
225
  dryRun,
226
+ prune,
117
227
  written: 0,
118
228
  updated: 0,
119
229
  deleted: 0,
@@ -122,8 +232,12 @@ export async function reconcileRelations(options) {
122
232
  modelDrift: [],
123
233
  massDelete: false,
124
234
  allowMassDelete,
125
- skipped: { expired: 0, undeclared: 0, invalid: 0 },
235
+ skipped: { expired: 0, undeclared: 0, invalid: 0, usersetUnsupported: 0 },
126
236
  details: [],
237
+ denies: { written: 0, updated: 0, deleted: 0, unchanged: 0, extra: 0 },
238
+ partitions: plans.map((plan) => emptyPartitionReport(plan.partition)),
239
+ orderResidue: [],
240
+ partitionsRefused: [],
127
241
  };
128
242
  for (const plan of plans) {
129
243
  // **El seguro de borrado masivo** (R-17, paridad con `authz:reconcile` de
@@ -132,13 +246,11 @@ export async function reconcileRelations(options) {
132
246
  // vacío: vaciar el destino entero se rechaza con 500 antes de tocar nada,
133
247
  // salvo `allowMassDelete`. En `--dry-run` no lanza, lo marca. «Utilizable» =
134
248
  // una tupla VIVA del origen que cabe en el destino (las que `skipped`
135
- // recoge no migran, así que no cuentan como respaldo).
249
+ // recoge no migran, así que no cuentan como respaldo) — **denies incluidos** (D2).
136
250
  if (plan.massDelete && !dryRun && !allowMassDelete) {
137
- throw new MassReconcileRefusedError(`authz:relations:reconcile --prune borraría ${plan.toDelete.length} tupla(s) de relación del destino` +
251
+ throw new MassReconcileRefusedError(`authz:relations:reconcile --prune borraría ${plan.allowDelete.length + plan.denyDelete.length} tupla(s) de relación del destino` +
138
252
  `${partitions.length > 1 ? ` (partición ${scopeKey(plan.partition)})` : ''} y el ` +
139
- `ORIGEN no ha aportado NI UNA utilizable (${plan.enumerated} leídas` +
140
- `${toConfig ? ', todas de tipos que el destino no declara' : ''}` +
141
- `${plan.skipped.expired ? `, ${plan.skipped.expired} caducada(s)` : ''}). Eso es la firma de un --from ` +
253
+ `ORIGEN no ha aportado NI UNA utilizable (${plan.enumerated} leídas${describeSkipped(plan.skipped)}). Eso es la firma de un --from ` +
142
254
  `equivocado o de un store vacío, no de un destino que sobra: esta pasada dejaría la partición sin ` +
143
255
  `una sola relación. Comprueba el --from y el store; si de verdad quieres vaciarlo, --allow-mass-delete.` +
144
256
  `${partitions.length > 1 ? ' No se ha escrito nada en ninguna partición.' : ''}`);
@@ -147,76 +259,347 @@ export async function reconcileRelations(options) {
147
259
  report.skipped.expired += plan.skipped.expired;
148
260
  report.skipped.undeclared += plan.skipped.undeclared;
149
261
  report.skipped.invalid += plan.skipped.invalid;
262
+ report.skipped.usersetUnsupported += plan.skipped.usersetUnsupported;
150
263
  for (const type of plan.modelDrift)
151
264
  if (!report.modelDrift.includes(type))
152
265
  report.modelDrift.push(type);
153
266
  }
154
- for (const plan of plans) {
155
- let written = plan.toWrite.length;
156
- let updated = plan.toUpdate.length;
157
- if (!dryRun) {
158
- // La caducidad viaja EXPLÍCITA (`Date` la fija, `null` la quita): un
159
- // `relate` sin opciones PRESERVARÍA la del destino (invariante 10).
160
- // L-0: el DRIVER del destino aplica F-05 (`assertRelationDeclared`), así que
161
- // sin `toConfig` —o con una que no coincida con la del driver— es SU 422 el
162
- // que embuda la tupla no declarada: se cuenta en `skipped.undeclared` y se
163
- // descuenta de `written`/`updated`. Nunca un throw a mitad de pasada por
164
- // una tupla que el reporte puede contar, y nunca un silencio.
165
- for (const tuple of plan.toWrite) {
166
- if (await relateOrSkip(to, tuple))
267
+ // **La puerta del deny, por el DRIVER DESTINO y en la fase de PLAN** (lote D · D2; S6, R2-5): antes de la primera
268
+ // escritura en CUALQUIER partición. Un deny que no se puede escribir (o que el destino no honraría) ⇒ 500 con CERO
269
+ // escrituras; jamás `skipped` (escribir los allows sin él concedería lo que el origen niega). `--dry-run` lo marca.
270
+ const refusals = await denyGate(to, plans);
271
+ if (refusals.length > 0) {
272
+ if (!dryRun)
273
+ throw denyRefusedError(refusals, partitions.length);
274
+ report.denyRefused = refusals;
275
+ }
276
+ // Lo escrito en TODA la pasada: decide la marca de escritura parcial si algo revienta (invariante 13).
277
+ const progress = { writes: 0 };
278
+ for (let i = 0; i < plans.length; i++) {
279
+ const plan = plans[i];
280
+ const row = report.partitions[i];
281
+ // Cierre 5 de D2 (opción A): la partición NEGADA por el plan no se toca —cero escrituras y cero borrados—; las demás
282
+ // siguen. Mismo resultado en `--dry-run` y en la pasada real.
283
+ if (plan.refusal) {
284
+ refusePartition(plan, row, report, prune);
285
+ continue;
286
+ }
287
+ try {
288
+ await applyPartition(plan, row, report, { to, dryRun, prune, progress, cuts });
289
+ row.status = 'done';
290
+ }
291
+ catch (error) {
292
+ row.status = 'failed';
293
+ throw partialReconcileError(error, report, progress.writes);
294
+ }
295
+ addPartition(report, row);
296
+ }
297
+ return report;
298
+ }
299
+ /**
300
+ * **Una partición NEGADA** (cierre 5 de D2, opción A): cero escrituras, cero borrados. El reporte dice lo que había
301
+ * (`unchanged`), lo que sobra y se queda (`extra`, también con `--prune`: no se borra nada) y, en `partitionsRefused`, por
302
+ * qué, la receta y la deriva pendiente. Los residuos de la partición (`details`) se listan igual que en una aplicada.
303
+ */
304
+ function refusePartition(plan, row, report, prune) {
305
+ const refusal = plan.refusal;
306
+ const PRUNABLE = new Set(['invalid-identity', 'userset-unsupported', 'undeclared']);
307
+ for (const detail of plan.details) {
308
+ report.details.push(prune && detail.side === 'destino' && PRUNABLE.has(detail.reason) ? { ...detail, pruned: false } : detail);
309
+ }
310
+ row.status = 'refused';
311
+ row.unchanged = plan.unchanged + plan.denyUnchanged;
312
+ row.denies.unchanged = plan.denyUnchanged;
313
+ row.denies.extra = plan.denyDelete.length + plan.denyConditional;
314
+ row.extra = plan.allowDelete.length + plan.denyDelete.length + plan.destResidue + plan.denyConditional;
315
+ report.partitionsRefused.push(refusal);
316
+ addPartition(report, row);
317
+ }
318
+ /** Un reporte de partición a cero, `pending` hasta que se aplica. */
319
+ function emptyPartitionReport(partition) {
320
+ return {
321
+ partition: scopeKey(partition),
322
+ status: 'pending',
323
+ written: 0,
324
+ updated: 0,
325
+ deleted: 0,
326
+ unchanged: 0,
327
+ extra: 0,
328
+ denies: { written: 0, updated: 0, deleted: 0, unchanged: 0, extra: 0 },
329
+ };
330
+ }
331
+ /** Suma una partición al total del reporte. */
332
+ function addPartition(report, row) {
333
+ report.written += row.written;
334
+ report.updated += row.updated;
335
+ report.deleted += row.deleted;
336
+ report.unchanged += row.unchanged;
337
+ report.extra += row.extra;
338
+ for (const k of ['written', 'updated', 'deleted', 'unchanged', 'extra'])
339
+ report.denies[k] += row.denies[k];
340
+ }
341
+ /**
342
+ * **El reporte PARCIAL viaja en el error** (roadmap 2.5): las particiones `done` y lo que la `failed` llegó a escribir,
343
+ * sumados; `partial: true` si escribió algo (⚪ 10: `false` con cero escrituras); y la marca de escritura parcial (`markPartialWrite`, invariante 13) si la pasada escribió
344
+ * algo. El error, su clase y su `code` no cambian (un 503 sigue siendo un 503).
345
+ */
346
+ function partialReconcileError(error, report, writes) {
347
+ const failed = report.partitions.find((p) => p.status === 'failed');
348
+ if (failed)
349
+ addPartition(report, failed);
350
+ // ⚪ 10 (cierre 1 de D2): «parcial» solo si la pasada YA escribió algo; con cero escrituras el reporte viaja igual
351
+ // (`partial: false`) y el comando dice que no se escribió nada — la marca `markPartialWrite` y la letra, de acuerdo.
352
+ // Cierre 2 de D2 · 🟠 N1: o lo dice el DRIVER — un `relate` de renovación de `openfga` (delete + write) cuyo 2.º
353
+ // `Write` falla ya BORRÓ: el error llega marcado (`isPartialWrite`) aunque la pasada no hubiera completado ninguna
354
+ // escritura, y «sin escribir nada» sería falso.
355
+ const partial = writes > 0 || isPartialWrite(error);
356
+ report.partial = partial;
357
+ const out = partial && error && typeof error === 'object' ? markPartialWrite(error) : error;
358
+ try {
359
+ if (out && typeof out === 'object' && Object.isExtensible(out))
360
+ out.report = report;
361
+ }
362
+ catch {
363
+ /* un error congelado no lleva el reporte; el original no se sustituye */
364
+ }
365
+ return out;
366
+ }
367
+ /**
368
+ * **Las fases de una partición** (lote D · D2; analista §4.7, juez ronda 2 · R2-5, tester S4/S5), en el ORDEN del punto
369
+ * de paso ÚNICO `relationTransitionOrder` (`src/relations/tuple_order.ts`, compartido con las purgas de `openfga`). Desde
370
+ * el cierre 2 de D2 (🟡 N3) el orden es el de la TRANSICIÓN entera —escrituras, renovaciones y poda en UNA secuencia—:
371
+ * (1) denies nuevos y rehechos (`to.deny`), (2) con `prune` allows sobrantes (`unrelate`), (3) membresías
372
+ * `group:*#member` que entran y que salen por el grafo de precedencias, (4) allows nuevos y renovaciones (`relate`), (5)
373
+ * con `prune` denies sobrantes (`to.removeDeny`), los ÚLTIMOS. Hasta el cierre 2 TODAS las escrituras iban antes que
374
+ * TODA la poda, y un ex-miembro componía con un allow nuevo (U ∈ g sobra y `g#member viewer X` es nuevo: cortada tras la
375
+ * primera escritura, U concedía sobre X). Cortada en cualquier punto, ningún estado concede lo que no concedían ni el
376
+ * estado inicial ni el final, salvo el RESIDUO que `relationTransitionOrder` devuelve (un ciclo del grafo de precedencias;
377
+ * se lista en `report.orderResidue`). **Ningún deny pasa por `relateOrSkip`**: un error al escribir un deny se propaga
378
+ * (la puerta del plan ya decidió que se puede escribir; si el driver dice otra cosa, es una contradicción que aborta).
379
+ * Son las propiedades `relations.reconcile.order` y `relations.order.exhaustive` de la guardia de superficie (L10).
380
+ */
381
+ async function applyPartition(plan, row, report, context) {
382
+ const { to, dryRun, prune, progress, cuts } = context;
383
+ // Cierre 4 de D2 · 🟡 C3-2: el deny condicional que el destino no puede rehacer se decide en el PLAN (dry == real).
384
+ const conditional = plan.denyConditional;
385
+ row.unchanged = plan.unchanged + plan.denyUnchanged;
386
+ row.denies.unchanged = plan.denyUnchanged;
387
+ // ⚪ R2 + cierres 1, 2 y 3 del lote B (una regla por FILA): el residuo del destino y lo enumerado fuera de la
388
+ // gramática se LISTA siempre, en los dos lados; con `prune` el del destino lleva `pruned: false` desde el plan. El
389
+ // `renewal-unsafe` (cierre 2 de D2 · 🟠 N1) no es un borrado: no lleva `pruned`.
390
+ // Cierre 4 de D2: `deny-conditional` (ya del PLAN) lleva `pruned: false` siempre.
391
+ const PRUNABLE = new Set(['invalid-identity', 'userset-unsupported', 'undeclared']);
392
+ for (const detail of plan.details) {
393
+ report.details.push(prune && detail.side === 'destino' && PRUNABLE.has(detail.reason) ? { ...detail, pruned: false } : detail);
394
+ }
395
+ // Cierre 2 de D2 · 🟡 N3: UNA secuencia para la transición entera (el mismo cálculo en `--dry-run` y en la real).
396
+ // Cierre 3 de D2 · 🟡 C2-2: las sobrantes VENCIDAS no conectan nada: se borran PRIMERO y FUERA del orden. Dentro, un deny
397
+ // vencido que se poda contaba en el grafo unión como un deny que «ya niega» y quitaba una precedencia (medido por la
398
+ // fuerza bruta con vigencia: U ∈ h entraba antes de que h ⊂ g saliera).
399
+ const liveDeletes = [...plan.allowDelete, ...plan.denyDelete].filter((tuple) => !plan.expiredDelete.has(tuple));
400
+ const transition = relationTransitionOrder({
401
+ writes: [...plan.denyWrite, ...plan.toWrite],
402
+ deletes: prune ? liveDeletes : [],
403
+ renewals: [...plan.denyUpdate, ...plan.toUpdate],
404
+ // Cierre 4 de D2 · 🟡 horizonte: las renovaciones que ACORTAN salen en su caducidad nueva (y las que alargan entran
405
+ // en la vieja): el orden las trata así para que un corte que dure hasta entonces no conceda.
406
+ shortening: plan.shortening,
407
+ context: plan.orderContext,
408
+ }, orderView, cuts ? { cuts } : {});
409
+ for (const tuple of transition.residue)
410
+ report.orderResidue.push(describeTuple(tuple));
411
+ let refused = 0;
412
+ if (dryRun) {
413
+ row.written = plan.toWrite.length + plan.denyWrite.length;
414
+ row.updated = plan.toUpdate.length + plan.denyUpdate.length;
415
+ row.denies.written = plan.denyWrite.length;
416
+ row.denies.updated = plan.denyUpdate.length;
417
+ if (prune) {
418
+ row.deleted = plan.allowDelete.length + plan.denyDelete.length;
419
+ row.denies.deleted = plan.denyDelete.length;
420
+ }
421
+ }
422
+ else {
423
+ // La caducidad de un allow viaja EXPLÍCITA (`Date` la fija, `null` la quita): un `relate` sin opciones PRESERVARÍA
424
+ // la del destino (invariante 10). L-0: el DRIVER del destino aplica F-05 (`assertRelationDeclared`), así que sin
425
+ // `toConfig` —o con una que no coincida con la del driver— es SU 422 el que embuda la tupla no declarada: se cuenta en
426
+ // `skipped.undeclared` y se descuenta de `written`/`updated` (capa 2 del registro `RELATION_REJECTIONS`). La poda
427
+ // NUNCA lanza por un 422 de la familia de relaciones (🟠 N9, capa 2): se clasifica por `code` y se sigue.
428
+ const steps = [
429
+ ...(prune ? [...plan.expiredDelete].map((item) => ({ item, op: 'delete' })) : []),
430
+ ...transition.steps,
431
+ ];
432
+ for (const step of steps) {
433
+ const tuple = step.item;
434
+ if (step.op === 'delete') {
435
+ const op = kindOf(tuple) === 'deny' ? 'removeDeny' : 'unrelate';
436
+ const refusal = await deleteOrClassify(to, tuple, op);
437
+ if (refusal === null) {
438
+ progress.writes++;
439
+ row.deleted += 1;
440
+ if (op === 'removeDeny')
441
+ row.denies.deleted += 1;
167
442
  continue;
168
- report.skipped.undeclared += 1;
169
- written -= 1;
443
+ }
444
+ report.details.push({ side: 'destino', reason: refusal.reason, tuple: describeTuple(tuple), message: refusal.message, pruned: false });
445
+ refused += 1;
446
+ if (op === 'removeDeny')
447
+ row.denies.extra += 1;
448
+ continue;
170
449
  }
171
- for (const tuple of plan.toUpdate) {
172
- if (await relateOrSkip(to, tuple))
173
- continue;
174
- report.skipped.undeclared += 1;
175
- updated -= 1;
450
+ const counter = step.op === 'write' ? 'written' : 'updated';
451
+ if (kindOf(tuple) === 'deny') {
452
+ // Cierre 4 de D2 · 🟡 C3-2: el deny condicional que el destino no puede rehacer ya lo apartó el PLAN (sin
453
+ // `atomicRenewal`): desde el cierre 5 NIEGA su partición entera, que no llega aquí. Si el driver lo dice igual (500 `denyConditional` de un
454
+ // driver que declara `atomicRenewal`), es una contradicción con su capacidad: se propaga (hasta el cierre 4 la
455
+ // pasada lo listaba y SEGUÍA escribiendo los allows que ese deny cortaba).
456
+ await to.deny(tuple.subject, tuple.relation, tuple.object, tuple.partition);
457
+ progress.writes++;
458
+ row[counter] += 1;
459
+ row.denies[counter] += 1;
460
+ continue;
461
+ }
462
+ if (await relateOrSkip(to, tuple)) {
463
+ progress.writes++;
464
+ row[counter] += 1;
465
+ continue;
466
+ }
467
+ report.skipped.undeclared += 1;
468
+ }
469
+ }
470
+ if (!prune)
471
+ row.denies.extra += plan.denyDelete.length;
472
+ row.denies.extra += conditional;
473
+ // El residuo del destino (los tres motivos) sobra SIEMPRE, con o sin `prune`: no está en `toDelete`, así que se suma
474
+ // aparte — más lo que el driver rechazó con un 422 de la familia en la pasada real (capa 2 de N9). (Desde el cierre 5
475
+ // una partición con `deny-conditional` está NEGADA y no llega aquí: `conditional` es 0 en una partición aplicada.)
476
+ row.extra = (prune ? 0 : plan.allowDelete.length + plan.denyDelete.length) + plan.destResidue + refused + conditional;
477
+ }
478
+ /**
479
+ * **La puerta del deny** (lote D · D2): las negativas de TODAS las particiones, decididas por el DRIVER DESTINO
480
+ * (`capabilities.relationDenies` + `denyPhase`, que en `openfga` comprueba además el modelo fijado, D-31) y por lo que
481
+ * el plan ya vio (denies sin `kind`, F-05 con `toConfig`, identidad/sujeto que el destino no admite). Un par
482
+ * tipo/relación se pregunta UNA vez. Para ESCRIBIR un deny el tipo tiene que estar en `'write'`; para uno que el
483
+ * destino YA tiene basta con que lo honre (opt-in en cualquier fase). Nunca escribe.
484
+ */
485
+ async function denyGate(to, plans) {
486
+ const refusals = [];
487
+ const seen = new Set();
488
+ const push = (refusal) => {
489
+ const id = `${refusal.objectType}#${refusal.relation}:${refusal.reason}`;
490
+ if (seen.has(id))
491
+ return;
492
+ seen.add(id);
493
+ refusals.push(refusal);
494
+ };
495
+ for (const plan of plans)
496
+ for (const refusal of plan.denyRefusals)
497
+ push(refusal);
498
+ // Qué fase hace falta por par: 'write' si hay algo que escribir; 'honored' si solo hay denies que ya están.
499
+ const needed = new Map();
500
+ for (const plan of plans) {
501
+ // Cierre 5 de D2: una partición NEGADA por el plan no escribe nada — sus denies no piden fase.
502
+ if (plan.refusal)
503
+ continue;
504
+ for (const [list, write] of [
505
+ [[...plan.denyWrite, ...plan.denyUpdate], true],
506
+ [plan.denyPresent, false],
507
+ ]) {
508
+ for (const tuple of list) {
509
+ const id = `${tuple.object.type}#${tuple.relation}`;
510
+ const prev = needed.get(id);
511
+ needed.set(id, { objectType: tuple.object.type, relation: tuple.relation, write: (prev?.write ?? false) || write });
176
512
  }
177
513
  }
178
- // ⚪ R2: lo enumerado fuera de la gramática se LISTA siempre (origen y
179
- // destino), con el veredicto de la poda cuando la hay.
180
- let refused = 0;
181
- for (const invalid of plan.invalid) {
182
- const detail = {
183
- side: invalid.side,
184
- reason: 'invalid-identity',
185
- tuple: invalid.tuple,
186
- message: invalid.message,
187
- };
188
- if (invalid.side === 'destino' && prune)
189
- detail.pruned = true;
190
- report.details.push(detail);
514
+ }
515
+ if (needed.size === 0)
516
+ return refusals;
517
+ const capable = to.capabilities?.relationDenies === true && typeof to.deny === 'function' && typeof to.removeDeny === 'function';
518
+ for (const { objectType, relation, write } of needed.values()) {
519
+ if (!capable) {
520
+ push({
521
+ objectType,
522
+ relation,
523
+ reason: 'no-capability',
524
+ message: `el destino no declara relationDenies (o no trae deny/removeDeny): no puede escribir ni honrar el deny de ` +
525
+ `${objectType}#${relation}`,
526
+ recipe: denyRefusalRecipe('no-capability', objectType, relation),
527
+ });
528
+ continue;
191
529
  }
192
- if (!dryRun && prune) {
193
- for (const tuple of plan.toDelete) {
194
- // La tupla fuera de la gramática se le pide al DRIVER tal como él la
195
- // enumeró. Si la rechaza por el puerto (422 de su propia gramática, R-16
196
- // en `unrelate`) NO es la pregunta del llamante la que está mal: se
197
- // apunta `pruned: false`, sigue contando como `extra` y la pasada lo
198
- // dice (exit ≠ 0). Nunca un throw a mitad de pasada por un dato ajeno.
199
- const invalid = plan.invalid.find((i) => i.side === 'destino' && i.ref === tuple);
200
- try {
201
- await to.unrelate(tuple.subject, tuple.relation, tuple.object, tuple.partition);
202
- }
203
- catch (error) {
204
- if (!invalid || error?.status !== 422)
205
- throw error;
206
- refused += 1;
207
- const detail = report.details.find((d) => d.side === 'destino' && d.tuple === invalid.tuple);
208
- if (detail)
209
- detail.pruned = false;
210
- }
530
+ if (typeof to.denyPhase !== 'function') {
531
+ push({
532
+ objectType,
533
+ relation,
534
+ reason: 'no-deny-phase',
535
+ message: `el destino declara relationDenies pero no trae denyPhase: no puede decir si ${objectType} admite deny (opt-in por tipo)`,
536
+ recipe: denyRefusalRecipe('no-deny-phase', objectType, relation),
537
+ });
538
+ continue;
539
+ }
540
+ let phase;
541
+ try {
542
+ phase = await to.denyPhase({ type: objectType }, relation);
543
+ }
544
+ catch (error) {
545
+ const rejection = error?.status === 422 ? RELATION_REJECTIONS[String(error?.code)] : undefined;
546
+ if (rejection?.kind === 'classified' && rejection.reason === 'undeclared') {
547
+ push({ objectType, relation, reason: 'undeclared', message: String(error?.message ?? error), recipe: denyRefusalRecipe('undeclared', objectType, relation) });
548
+ continue;
549
+ }
550
+ if (error?.status === 500 && error?.code === AuthorizationConfigError.code) {
551
+ push({ objectType, relation, reason: 'model', message: String(error?.message ?? error), recipe: denyRefusalRecipe('model', objectType, relation) });
552
+ continue;
211
553
  }
554
+ throw error;
555
+ }
556
+ if (write ? phase !== 'write' : phase === null) {
557
+ push({
558
+ objectType,
559
+ relation,
560
+ reason: 'phase',
561
+ message: `en el DESTINO el tipo '${objectType}' está ${phase === null ? 'SIN opt-in de deny' : `en la fase '${phase}'`} y ` +
562
+ `${write ? "escribir un deny exige deny: 'write' (en TODO destino)" : 'un deny que ya está exige que el destino lo honre (opt-in)'} ` +
563
+ `(su config, no la spec persistida)` +
564
+ // ⚪ 11 (cierre 1 de D2): por qué se niega aunque un destino `database` honre la fila igual (D8).
565
+ (write
566
+ ? ''
567
+ : '. Un destino database honra un denied_* existente también sin opt-in (D8), pero la pasada no puede saberlo por ' +
568
+ 'el puerto: denyPhase = null significa «sin opt-in», y certificar un deny que el destino no declara honrar ' +
569
+ 'convertiría un detalle de implementación en la garantía'),
570
+ recipe: denyRefusalRecipe('phase', objectType, relation, !write),
571
+ });
212
572
  }
213
- report.written += written;
214
- report.updated += updated;
215
- report.deleted += prune ? plan.toDelete.length - refused : 0;
216
- report.unchanged += plan.unchanged;
217
- report.extra += prune ? refused : plan.toDelete.length;
218
573
  }
219
- return report;
574
+ return refusals;
575
+ }
576
+ /** El 500 de la puerta (S6/R2-5): la letra empieza por el prefijo ESTABLE «deny NO migrable:» (punto 7). */
577
+ function denyRefusedError(refusals, partitions) {
578
+ const error = new DenyMigrationRefusedError(`deny NO migrable: authz:relations:reconcile se NIEGA — ${refusals.length} caso(s): ` +
579
+ refusals.map((r) => `(${r.reason}) ${r.objectType}#${r.relation}: ${r.message}`).join('; ') +
580
+ `. Perder un deny no es una pérdida que se pueda declarar (escribir los allows sin él concedería lo que el origen niega). ` +
581
+ `No se ha escrito nada${partitions > 1 ? ' en ninguna partición' : ''}. Recetas (una por motivo): ` +
582
+ refusals.map((r) => `(${r.reason}) ${r.recipe}`).join(' '));
583
+ error.refusals = Object.freeze(refusals.map((r) => Object.freeze({ ...r })));
584
+ return error;
585
+ }
586
+ /**
587
+ * ⚪ N8 (cierre 2 del lote B): el desglose del seguro R-17, cada motivo POR SU
588
+ * NOMBRE y solo si cuenta (> 0). Hasta el cierre decía «todas de tipos que el
589
+ * destino no declara» siempre que había `toConfig`, también cuando el único
590
+ * aporte era residuo B3 — y sin `toConfig` no nombraba nada.
591
+ */
592
+ function describeSkipped(skipped) {
593
+ const parts = [];
594
+ if (skipped.undeclared)
595
+ parts.push(`${skipped.undeclared} de tipo o relación que el destino no declara (skipped.undeclared)`);
596
+ if (skipped.usersetUnsupported)
597
+ parts.push(`${skipped.usersetUnsupported} con sujeto userset no admitido (B3, skipped.usersetUnsupported)`);
598
+ if (skipped.invalid)
599
+ parts.push(`${skipped.invalid} con identidad fuera de la gramática (skipped.invalid)`);
600
+ if (skipped.expired)
601
+ parts.push(`${skipped.expired} caducada(s)`);
602
+ return parts.length ? `: ${parts.join(', ')}` : '';
220
603
  }
221
604
  /** Una tupla enumerada, legible, para la línea del reporte (sin validar nada: puede estar fuera de la gramática). */
222
605
  function describeTuple(tuple) {
@@ -224,12 +607,12 @@ function describeTuple(tuple) {
224
607
  const subject = isRelUserset(s) ? `${s.object?.type}:${s.object?.id}#${s.relation}` : `${s?.type}:${s?.uuid}`;
225
608
  const p = tuple?.partition;
226
609
  const partition = p?.type === 'app' ? 'app' : `${p?.type}|${p?.uuid}`;
227
- return `${partition} ${tuple?.object?.type}:${tuple?.object?.id}#${tuple?.relation}@${subject}`;
610
+ return `${tuple?.kind === 'deny' ? '[deny] ' : ''}${partition} ${tuple?.object?.type}:${tuple?.object?.id}#${tuple?.relation}@${subject}`;
228
611
  }
229
612
  /** `tupleKey` de una tupla ENUMERADA: la clave, o el motivo por el que su identidad no entra en la gramática (⚪ R2). */
230
613
  function enumeratedKey(tuple) {
231
614
  try {
232
- return { key: tupleKey({ ...tuple, kind: 'allow' }) };
615
+ return { key: tupleKey({ ...tuple, kind: kindOf(tuple) }) };
233
616
  }
234
617
  catch (error) {
235
618
  if (error instanceof InvalidIdentityError)
@@ -237,6 +620,38 @@ function enumeratedKey(tuple) {
237
620
  throw error;
238
621
  }
239
622
  }
623
+ /**
624
+ * **El sujeto de una tupla ENUMERADA, clasificado** (cierre 1 del lote B · 🟠 2;
625
+ * cierre 2 · 🟡 N2): `ok`, `userset-unsupported` (B3: userset ≠ `group#member`,
626
+ * con o sin config — `assertSupportedUserset` no necesita F-05), o `undeclared`
627
+ * (solo con `toConfig`: un `group#member` cuyo tipo/relación el destino no
628
+ * declara — F-05 del par; sin config esa cara la embuda el 422 del driver, como
629
+ * la del objeto). **La FORMA se juzga ANTES que F-05** (N2; decisión 3 del
630
+ * cierre 1 rechazada): un residuo B3 es inadmisible con cualquier config, así
631
+ * que un userset `folder#viewer` o `document#reader` que `toConfig` no declara
632
+ * es residuo con receta, nunca `undeclared` con el consejo falso «decláralo en
633
+ * el destino» (declarado, la pasada siguiente lo reclasificaba como residuo).
634
+ * Mutante «F-05 primero» ⇒ rojo. Nunca lanza por un dato del store
635
+ * (invariante 5).
636
+ */
637
+ function classifySubject(tuple, toConfig) {
638
+ const subject = tuple?.subject;
639
+ if (!isRelUserset(subject))
640
+ return { kind: 'ok' };
641
+ try {
642
+ assertSupportedUserset(subject);
643
+ if (toConfig)
644
+ assertRelationDeclared(toConfig, subject.object, subject.relation);
645
+ return { kind: 'ok' };
646
+ }
647
+ catch (error) {
648
+ if (error instanceof RelationUsersetUnsupportedError)
649
+ return { kind: 'userset-unsupported', message: error.message };
650
+ if (error instanceof RelationTypeUnknownError || error instanceof RelationUnknownError)
651
+ return { kind: 'undeclared' };
652
+ throw error;
653
+ }
654
+ }
240
655
  /** Exactamente uno de `partition` / `partitions` (y `partitions` no vacío): lo demás es 500 antes de leer nada. */
241
656
  function partitionsOf(options) {
242
657
  const { partition, partitions } = options;
@@ -273,7 +688,7 @@ function partitionsOf(options) {
273
688
  });
274
689
  }
275
690
  function planPartition(partition, enumerated, destTuples, context) {
276
- const { now, prune, toConfig } = context;
691
+ const { now, prune, toConfig, atomicRenewal, cuts } = context;
277
692
  // R-15: la caducada LLEGA del origen (el puerto lo exige) y aquí se DESCARTA
278
693
  // contándola: no se migra ni respalda nada en el destino. Decidido con UN
279
694
  // `now` para toda la pasada.
@@ -282,31 +697,85 @@ function planPartition(partition, enumerated, destTuples, context) {
282
697
  // pasada real dan los mismos números— y tampoco respalda nada en el destino
283
698
  // (una que ya estuviera allí es `extra`, y `--prune` la barre). El tipo se
284
699
  // reporta además en `modelDrift` (deriva del modelo fusionado).
285
- const skipped = { expired: 0, undeclared: 0, invalid: 0 };
700
+ // Lote D · D2: un DENY nunca se descarta así — lo que impide escribirlo es una NEGATIVA de la pasada (500), jamás
701
+ // `skipped` (perder un deny no es una pérdida que se pueda declarar). Solo la caducidad lo descarta (no niega nada).
702
+ const skipped = { expired: 0, undeclared: 0, invalid: 0, usersetUnsupported: 0 };
286
703
  const modelDrift = [];
287
704
  const sourceTuples = [];
288
705
  const invalid = [];
706
+ const usersetResidue = [];
707
+ const undeclaredResidue = [];
708
+ const denyRefusals = [];
709
+ const refuse = (tuple, reason, message) => {
710
+ const objectType = String(tuple?.object?.type);
711
+ const relation = String(tuple?.relation);
712
+ denyRefusals.push({ objectType, relation, reason, message, recipe: denyRefusalRecipe(reason, objectType, relation) });
713
+ };
289
714
  for (const tuple of enumerated) {
290
715
  if (!isActiveExpiry(tuple.expiresAt ?? null, now)) {
291
716
  skipped.expired += 1;
292
717
  continue;
293
718
  }
719
+ const kind = kindOf(tuple);
720
+ // Lote D · D2: una tupla con la relación ALMACENADA de un deny (`denied_*`, prefijo reservado) y sin `kind: 'deny'`
721
+ // es un deny que el driver de ORIGEN no marcó: migrarla como allow escribiría `relate(denied_viewer)` (422) o la
722
+ // contaría como no declarada — y el deny se perdería. Negativa (500), nunca `skipped`.
723
+ if (kind === 'allow' && typeof tuple.relation === 'string' && tuple.relation.startsWith(RELATION_DENY_PREFIX)) {
724
+ refuse(tuple, 'unmarked', `el ORIGEN enumera ${describeTuple(tuple)} sin kind: 'deny' (un driver que declara relationDenies tiene que marcar kind; ` +
725
+ `un proceso < 2.5.0-alpha.3 no lo hace)`);
726
+ continue;
727
+ }
294
728
  // ⚪ R2: una identidad fuera de la gramática en el ORIGEN no es una pregunta
295
729
  // del llamante (invariante 5): no se migra —la API del destino no podría
296
730
  // escribirla—, se lista y se cuenta. Nunca un 422 por un dato del store.
297
731
  const keyed = enumeratedKey(tuple);
298
732
  if ('message' in keyed) {
733
+ if (kind === 'deny') {
734
+ refuse(tuple, 'invalid', `identidad fuera de la gramática: ${keyed.message}`);
735
+ continue;
736
+ }
299
737
  skipped.invalid += 1;
300
- invalid.push({ side: 'origen', tuple: describeTuple(tuple), message: keyed.message });
738
+ invalid.push({ side: 'origen', reason: 'invalid-identity', tuple: describeTuple(tuple), message: keyed.message });
739
+ continue;
740
+ }
741
+ // Cierre 1 del lote B · 🟠 2: el residuo pre-2.5 de B3 en el ORIGEN se
742
+ // clasifica AQUÍ, con o sin `toConfig` — no se migra (ninguna puerta del
743
+ // destino lo admite), se lista con su receta y se cuenta aparte. Hasta el
744
+ // cierre llegaba al driver, que lo rechazaba con 422, y `relateOrSkip` lo
745
+ // tragaba como `undeclared` (consejo falso; `--dry-run` decía `written`).
746
+ const subject = classifySubject(tuple, toConfig);
747
+ if (subject.kind === 'userset-unsupported') {
748
+ if (kind === 'deny') {
749
+ refuse(tuple, 'invalid', `sujeto que el destino no admite: ${subject.message}`);
750
+ continue;
751
+ }
752
+ skipped.usersetUnsupported += 1;
753
+ usersetResidue.push({ side: 'origen', reason: 'userset-unsupported', tuple: describeTuple(tuple), message: subject.message });
754
+ continue;
755
+ }
756
+ if (subject.kind === 'undeclared') {
757
+ if (kind === 'deny') {
758
+ refuse(tuple, 'undeclared', `el sujeto userset de ${describeTuple(tuple)} no está declarado en el destino`);
759
+ continue;
760
+ }
761
+ skipped.undeclared += 1;
301
762
  continue;
302
763
  }
303
764
  if (toConfig && !declaredIn(toConfig, tuple.object.type)) {
765
+ if (kind === 'deny') {
766
+ refuse(tuple, 'undeclared', `el destino no declara el tipo '${tuple.object.type}'`);
767
+ continue;
768
+ }
304
769
  skipped.undeclared += 1;
305
770
  if (!modelDrift.includes(tuple.object.type))
306
771
  modelDrift.push(tuple.object.type);
307
772
  continue;
308
773
  }
309
774
  if (toConfig && !toConfig.isDeclared(tuple.object.type, tuple.relation)) {
775
+ if (kind === 'deny') {
776
+ refuse(tuple, 'undeclared', `el destino no declara la relación '${tuple.relation}' en '${tuple.object.type}'`);
777
+ continue;
778
+ }
310
779
  skipped.undeclared += 1;
311
780
  continue;
312
781
  }
@@ -314,26 +783,115 @@ function planPartition(partition, enumerated, destTuples, context) {
314
783
  }
315
784
  const sourceByKey = new Map();
316
785
  for (const tuple of sourceTuples)
317
- sourceByKey.set(tupleKey({ ...tuple, kind: 'allow' }), tuple);
318
- // ⚪ R2: una tupla del DESTINO fuera de la gramática es `extra` con motivo
319
- // (`invalid-identity`): nadie del origen puede respaldarla (la clave no se
320
- // puede ni componer), `--prune` pide borrarla y `--dry-run` la lista.
786
+ sourceByKey.set(tupleKey({ ...tuple, kind: kindOf(tuple) }), tuple);
787
+ // ⚪ R2 + cierre 2 · N3: una tupla del DESTINO fuera de la gramática es
788
+ // `extra` con motivo (`invalid-identity`): nadie del origen puede respaldarla
789
+ // (la clave no se puede ni componer) y NADIE la borra — ni `toDelete` (los dos
790
+ // drivers del paquete la rechazan por el puerto, R-16; hasta el cierre 2 se les
791
+ // pedía igual) ni un DELETE de plataforma—: se lista con `pruned: false` y es
792
+ // deriva en los cuatro modos, exactamente como el residuo de B3 de abajo.
321
793
  const destByKey = new Map();
322
- const destInvalid = new Set();
794
+ /** Cierre 4 de D2 · 🟡 C3-2: los denies del destino VENCIDOS (no cuentan como presentes, pero el destino los tiene). */
795
+ const expiredDenyByKey = new Map();
796
+ const residue = new Set();
323
797
  for (const tuple of destTuples) {
798
+ // ⚪ 8 (cierre 1 de D2): un deny del DESTINO sin `kind` (un driver de terceros que no marca) es la MISMA negativa
799
+ // `unmarked` que en el origen, decidida en el PLAN: planificarlo como allow lo podaría con `unrelate(denied_*)` (que
800
+ // D-04 prohíbe y F-05 rechaza con un motivo equivocado) o lo contaría como allow que sobra.
801
+ if (kindOf(tuple) === 'allow' && typeof tuple?.relation === 'string' && tuple.relation.startsWith(RELATION_DENY_PREFIX)) {
802
+ residue.add(tuple);
803
+ refuse(tuple, 'unmarked', `el DESTINO enumera ${describeTuple(tuple)} sin kind: 'deny' (un driver que declara relationDenies tiene que marcar kind)`);
804
+ continue;
805
+ }
324
806
  const keyed = enumeratedKey(tuple);
325
807
  if ('message' in keyed) {
326
- destInvalid.add(tuple);
327
- invalid.push({ side: 'destino', tuple: describeTuple(tuple), message: keyed.message, ref: tuple });
808
+ residue.add(tuple);
809
+ invalid.push({ side: 'destino', reason: 'invalid-identity', tuple: describeTuple(tuple), message: keyed.message });
810
+ continue;
811
+ }
812
+ // Cierre 1 del lote B · 🟠 1: el residuo pre-2.5 de B3 en el DESTINO sobra
813
+ // (nadie del origen puede respaldarlo: el origen lo descarta arriba) pero
814
+ // NO entra en un borrado: la pasada no se lo pide al driver ni con `--prune`
815
+ // —`unrelate` lo rechazaría con 422 y hasta el cierre eso ABORTABA la pasada
816
+ // a mitad, con los `toWrite` ya escritos—. Se lista con `pruned: false` y
817
+ // la receta, cuenta en `extra` y es deriva.
818
+ const subject = classifySubject(tuple, undefined);
819
+ if (subject.kind === 'userset-unsupported') {
820
+ residue.add(tuple);
821
+ usersetResidue.push({ side: 'destino', reason: 'userset-unsupported', tuple: describeTuple(tuple), message: subject.message });
822
+ continue;
823
+ }
824
+ // Cierre 3 del lote B · 🟠 N9 (capa 1): la fila cuya RELACIÓN o TIPO el
825
+ // destino (`toConfig`) ya no declara — una relación retirada de la config con
826
+ // filas vivas — es la tercera fila que la API no admite: `unrelate`/`removeDeny`
827
+ // la rechazan por F-05, así que lleva la MISMA regla que las dos de arriba.
828
+ const undeclared = toConfig ? classifyDestinationRow(toConfig, tuple) : null;
829
+ if (undeclared !== null) {
830
+ residue.add(tuple);
831
+ undeclaredResidue.push({ side: 'destino', reason: 'undeclared', tuple: describeTuple(tuple), message: undeclared });
832
+ continue;
833
+ }
834
+ // Cierre 3 de D2 · 🟠 C2-1 / 🟡 C2-2: una tupla del destino VENCIDA en `now` (el MISMO `now` con el que el origen
835
+ // descarta las suyas) no conecta nada —ni en `database` ni en FGA— y el plan la trata como AUSENTE: su reescritura es
836
+ // una escritura normal (nunca `toUpdate`, nunca `renewal-unsafe`) y no entra en el contexto del orden (ni portadora,
837
+ // ni arista del auto-corte, ni «renovación» de la fase 4). Hasta el cierre 3 contaba como viva: una membresía
838
+ // portadora caducada se clasificaba `renewal-unsafe` mientras el allow nuevo SÍ se escribía, y el destino concedía a
839
+ // un denegado para siempre (R4 del auditor). Sin `--prune` sigue en el destino (sobra, vencida) y con él se barre.
840
+ if (!liveInDestination(tuple, now)) {
841
+ if (kindOf(tuple) === 'deny')
842
+ expiredDenyByKey.set(keyed.key, tuple);
328
843
  continue;
329
844
  }
330
845
  destByKey.set(keyed.key, tuple);
331
846
  }
332
847
  const toWrite = [];
333
848
  const toUpdate = [];
849
+ const denyWrite = [];
850
+ const denyUpdate = [];
851
+ const denyPresent = [];
852
+ const denyConditional = [];
334
853
  let unchanged = 0;
854
+ let denyUnchanged = 0;
335
855
  for (const [key, tuple] of sourceByKey) {
336
856
  const present = destByKey.get(key);
857
+ if (kindOf(tuple) === 'deny') {
858
+ // Cierre 4 de D2 · 🟡 C3-2: un deny del destino CON caducidad —vivo con otra, o ya VENCIDO— que el origen tiene, en
859
+ // un destino que no declara `atomicRenewal` (`openfga`: no reemplaza la misma clave en un `Write`, M5, y lo dice con
860
+ // 500 `conditionalDeny`), no se puede rehacer permanente. Se decide AQUÍ (mismos números en `--dry-run` y real;
861
+ // hasta el cierre 4 lo decidía el driver en la pasada real, y la pasada escribía los allows que ese deny corta):
862
+ // residuo `deny-conditional` y, desde el cierre 5 (opción A), su PARTICIÓN queda NEGADA (cero escrituras en ella).
863
+ // Para la puerta cuenta como un deny que el destino tiene (tiene que honrarlo).
864
+ const stale = present ?? expiredDenyByKey.get(key);
865
+ if (!atomicRenewal && stale && stale.expiresAt != null && !sameInstant(stale.expiresAt, tuple.expiresAt ?? null)) {
866
+ const expired = !present;
867
+ denyPresent.push(tuple);
868
+ denyConditional.push({
869
+ side: 'destino',
870
+ reason: 'deny-conditional',
871
+ tuple: describeTuple(stale),
872
+ message: `el deny del destino tiene caducidad (${stale.expiresAt.toISOString()}, ${expired ? 'YA VENCIDO: no niega nada' : 'vivo: niega hasta entonces'}) ` +
873
+ `y el origen lo tiene ${tuple.expiresAt ? `hasta ${tuple.expiresAt.toISOString()}` : 'permanente'}; el destino no declara atomicRenewal ` +
874
+ `(openfga no reemplaza la misma clave en un Write, M5) y no puede rehacerlo en una escritura. No se reescribe y la ` +
875
+ `PARTICIÓN ${scopeKey(partition)} queda NEGADA: cero escrituras y cero borrados en ella (su destino queda como estaba, ` +
876
+ `con su deriva a la vista); el resto de particiones migra. Receta: ${partitionRefusalRecipe(['deny-conditional'])}`,
877
+ pruned: false,
878
+ });
879
+ continue;
880
+ }
881
+ // D3: un deny no caduca. Uno del destino SIN caducidad es el mismo deny (el del origen, con o sin una caducidad
882
+ // escrita a mano, deniega lo mismo o menos tiempo). Uno del destino CON caducidad distinta se rehace permanente
883
+ // (`to.deny`): dejarlo caducar concedería lo que el origen niega. Nunca delete+write (R2-8: no hay `toUpdate` de
884
+ // denies por `relate`).
885
+ if (!present)
886
+ denyWrite.push(tuple);
887
+ else if (present.expiresAt != null && !sameInstant(present.expiresAt, tuple.expiresAt ?? null))
888
+ denyUpdate.push(tuple);
889
+ else {
890
+ denyUnchanged += 1;
891
+ denyPresent.push(tuple);
892
+ }
893
+ continue;
894
+ }
337
895
  if (!present)
338
896
  toWrite.push(tuple);
339
897
  // Misma clave, OTRA caducidad ⇒ se reescribe con la del origen (R-15).
@@ -342,9 +900,245 @@ function planPartition(partition, enumerated, destTuples, context) {
342
900
  else
343
901
  unchanged += 1;
344
902
  }
345
- const toDelete = destTuples.filter((tuple) => destInvalid.has(tuple) || !sourceByKey.has(tupleKey({ ...tuple, kind: 'allow' })));
346
- const massDelete = prune && toDelete.length > 0 && sourceTuples.length === 0;
347
- return { partition, enumerated: enumerated.length, toWrite, toUpdate, toDelete, unchanged, skipped, modelDrift, massDelete, invalid };
903
+ const allowDelete = [];
904
+ const denyDelete = [];
905
+ const expiredDelete = new Set();
906
+ for (const tuple of destTuples) {
907
+ if (residue.has(tuple))
908
+ continue;
909
+ if (sourceByKey.has(tupleKey({ ...tuple, kind: kindOf(tuple) })))
910
+ continue;
911
+ if (kindOf(tuple) === 'deny')
912
+ denyDelete.push(tuple);
913
+ else
914
+ allowDelete.push(tuple);
915
+ if (!liveInDestination(tuple, now))
916
+ expiredDelete.add(tuple);
917
+ }
918
+ const massDelete = prune && allowDelete.length + denyDelete.length > 0 && sourceTuples.length === 0;
919
+ const orderContext = [...sourceTuples, ...destByKey.values()].map(orderView);
920
+ // Cierre 2 de D2 · 🟠 N1: la RENOVACIÓN de una membresía que transporta un deny (en el grafo de los DOS estados: los
921
+ // denies del destino siguen vivos hasta el final de la pasada y los del origen se escriben primero) en un destino que
922
+ // renueva con delete + write (sin `atomicRenewal`) dejaría al sujeto SIN el deny entre los dos `Write`. El plan no la
923
+ // renueva: la lista (`renewal-unsafe`, con su receta) y es deriva. Los mismos números en `--dry-run` y en la real.
924
+ const renewalUnsafe = [];
925
+ const evolves = [];
926
+ /** La caducidad que el DESTINO tiene de una tupla de `toUpdate` (viva). */
927
+ const destExpiry = (tuple) => destByKey.get(tupleKey({ ...tuple, kind: kindOf(tuple) }))?.expiresAt ?? null;
928
+ /** ¿La renovación ALARGA la caducidad (la del origen es posterior o `null`)? */
929
+ const extends_ = (tuple) => {
930
+ const old = destExpiry(tuple);
931
+ return tuple.expiresAt == null || (old != null && tuple.expiresAt.getTime() > old.getTime());
932
+ };
933
+ if (!atomicRenewal && toUpdate.length > 0) {
934
+ const carriers = denyCarrierGroups(orderContext);
935
+ for (let i = toUpdate.length - 1; i >= 0; i--) {
936
+ const tuple = toUpdate[i];
937
+ if (tuple.object.type !== RELATION_USERSET_TYPE || !carriers.has(`${tuple.object.type}:${tuple.object.id}`))
938
+ continue;
939
+ toUpdate.splice(i, 1);
940
+ const old = destExpiry(tuple);
941
+ const newer = tuple.expiresAt ? tuple.expiresAt.toISOString() : 'sin caducidad';
942
+ // Cierre 6 de D2 · ⚪ D5-3 (E3): «como estaba» NO es estático. Si la renovación ALARGA, desde la caducidad vieja el
943
+ // destino ya no transporta los denies del grupo por esta membresía y CONCEDE lo que cortan (si otro camino lo da),
944
+ // cosa que el origen sigue negando: la línea lo dice con fecha y con qué, en vez de dejar que el operador lo deduzca.
945
+ const member = orderView(tuple).user;
946
+ const cut = carriedDenies([...destByKey.values()].map(orderView), `${tuple.object.type}:${tuple.object.id}`).map((v) => `${v.relation} sobre ${v.object}`);
947
+ if (extends_(tuple) && old !== null && cut.length > 0)
948
+ evolves.unshift({ at: old.toISOString(), member, cut });
949
+ const grantsLater = old !== null && cut.length > 0
950
+ ? ` A partir de ${old.toISOString()} el destino CONCEDERÁ a ${member} lo que cortan esos denies (${cut.join(', ')}) si ` +
951
+ `otro camino se lo da, y el origen lo sigue negando: negar la partición NO congela el tiempo.`
952
+ : '';
953
+ renewalUnsafe.unshift({
954
+ side: 'destino',
955
+ reason: 'renewal-unsafe',
956
+ tuple: describeTuple(tuple),
957
+ message: `la membresía cambia de caducidad (destino ${old ? old.toISOString() : 'sin caducidad'}, origen ${newer}) y su grupo ` +
958
+ `TRANSPORTA un deny vivo; el destino renueva con delete + write (no declara atomicRenewal) y entre los dos el deny no ` +
959
+ `alcanzaría al sujeto. No se renueva y la PARTICIÓN ${scopeKey(partition)} queda NEGADA: cero escrituras y cero ` +
960
+ `borrados en ella (su destino queda como estaba, con su deriva a la vista); el resto de particiones migra. ` +
961
+ `${extends_(tuple) ? `Desde ${old ? old.toISOString() : '—'} el destino ya no transporta ese deny por esta membresía.${grantsLater}` : `Hasta ${old ? old.toISOString() : '—'} el destino la conserva más que el origen (${newer}).`} ` +
962
+ `Receta: ${partitionRefusalRecipe(['renewal-unsafe'])}`,
963
+ });
964
+ }
965
+ }
966
+ // **Cierre 5 de D2 (decisión del dueño §2026-10-16 (10), opción A)**: una partición con ≥ 1 `renewal-unsafe` o
967
+ // `deny-conditional` —lo que el destino no puede escribir en una escritura— recibe CERO escrituras y CERO borrados
968
+ // (también con `--prune` y las vencidas): queda EXACTAMENTE como estaba, que es lo único certificable; el resto migra.
969
+ // Sustituye a la retención del cierre 4 (`relationWithheldWrites`, `withheld-by-unsafe-deny`, `carrier-conflict`): retener
970
+ // tupla a tupla no cubría lo que el destino YA concedía (D4-1) y su negativa era global (D4-3).
971
+ const causes = [...denyConditional, ...renewalUnsafe].map((detail) => ({ reason: detail.reason, tuple: detail.tuple }));
972
+ const refusal = causes.length > 0
973
+ ? {
974
+ partition: scopeKey(partition),
975
+ causes,
976
+ pending: {
977
+ write: toWrite.length + denyWrite.length,
978
+ update: toUpdate.length + denyUpdate.length + causes.length,
979
+ delete: prune ? allowDelete.length + denyDelete.length : 0,
980
+ },
981
+ recipe: partitionRefusalRecipe(causes.map((cause) => cause.reason)),
982
+ ...(evolves.length > 0 ? { evolves } : {}),
983
+ }
984
+ : undefined;
985
+ const shortening = toUpdate.filter((tuple) => !extends_(tuple));
986
+ return {
987
+ partition,
988
+ enumerated: enumerated.length,
989
+ toWrite,
990
+ toUpdate,
991
+ allowDelete,
992
+ unchanged,
993
+ denyWrite,
994
+ denyUpdate,
995
+ denyPresent,
996
+ denyUnchanged,
997
+ denyDelete,
998
+ expiredDelete,
999
+ denyRefusals,
1000
+ skipped,
1001
+ modelDrift,
1002
+ // Una partición NEGADA no borra nada: el seguro de borrado masivo no aplica.
1003
+ massDelete: massDelete && !refusal,
1004
+ details: [...invalid, ...usersetResidue, ...undeclaredResidue, ...denyConditional, ...renewalUnsafe],
1005
+ destResidue: residue.size,
1006
+ orderContext,
1007
+ shortening,
1008
+ denyConditional: denyConditional.length,
1009
+ refusal,
1010
+ };
1011
+ }
1012
+ /**
1013
+ * **¿Cuenta una tupla del DESTINO en el plan?** (cierre 3 de D2 · 🟠 C2-1 / 🟡 C2-2): sí si está VIVA en `now` (el mismo
1014
+ * `now` de toda la pasada, con el que el origen descarta sus caducadas); una VENCIDA es AUSENTE — no conecta nada en
1015
+ * ningún driver—, así que ni se «renueva» ni entra en el contexto del orden. Punto de paso ÚNICO de la regla: lo usa
1016
+ * `planPartition` y la fuerza bruta del orden (`tests/helpers/order_bruteforce.ts`, universo con vigencia) deriva con
1017
+ * ÉL qué es escritura, renovación y contexto — un mutante que ignore la vigencia aquí pone rojos los dos.
1018
+ */
1019
+ export function liveInDestination(tuple, now) {
1020
+ return isActiveExpiry(tuple.expiresAt ?? null, now);
1021
+ }
1022
+ /**
1023
+ * La vista de ORDEN de una tupla ya planificada (cierre 1 de D2 · 🟠 1): objeto `<tipo>:<id>` y sujeto `<tipo>:<uuid>` o
1024
+ * `<tipo>:<id>#<rel>` — sin partición: el plan es de UNA partición.
1025
+ */
1026
+ function orderView(tuple) {
1027
+ const s = tuple.subject;
1028
+ return {
1029
+ deny: kindOf(tuple) === 'deny',
1030
+ object: `${tuple.object.type}:${tuple.object.id}`,
1031
+ user: isRelUserset(s) ? `${s.object.type}:${s.object.id}#${s.relation}` : `${s.type}:${s.uuid}`,
1032
+ relation: tuple.relation,
1033
+ };
1034
+ }
1035
+ /** El `kind` de una tupla enumerada: `deny` solo si el driver lo marca; ausente = `allow` (tipo exportado opcional). */
1036
+ function kindOf(tuple) {
1037
+ return tuple?.kind === 'deny' ? 'deny' : 'allow';
1038
+ }
1039
+ /**
1040
+ * **F-05 del destino sobre una fila ENUMERADA del destino** (cierre 3 del lote B ·
1041
+ * 🟠 N9, capa 1): la MISMA `assertRelationDeclared` que corre en la primera línea
1042
+ * de `unrelate` de los dos drivers, aplicada en el plan para que `--dry-run` y la
1043
+ * pasada real den los mismos números y la fila nunca llegue a un `unrelate` que
1044
+ * la rechazaría. Devuelve el porqué (el mensaje de F-05: tipo o relación) o
1045
+ * `null` si el destino la declara. Nunca lanza por un dato del store
1046
+ * (invariante 5): cualquier otra excepción se propaga tal cual.
1047
+ */
1048
+ function classifyDestinationRow(toConfig, tuple) {
1049
+ try {
1050
+ assertRelationDeclared(toConfig, tuple.object, tuple.relation);
1051
+ return null;
1052
+ }
1053
+ catch (error) {
1054
+ if (error instanceof RelationTypeUnknownError || error instanceof RelationUnknownError)
1055
+ return error.message;
1056
+ throw error;
1057
+ }
1058
+ }
1059
+ export const RELATION_REJECTIONS = Object.freeze({
1060
+ [RelationTypeUnknownError.code]: {
1061
+ kind: 'classified',
1062
+ reason: 'undeclared',
1063
+ where: 'planPartition (toConfig: skipped.undeclared en el origen) · classifyDestinationRow (destino) · denyGate (denyPhase ⇒ refusal undeclared)',
1064
+ },
1065
+ [RelationUnknownError.code]: {
1066
+ kind: 'classified',
1067
+ reason: 'undeclared',
1068
+ where: 'planPartition (toConfig) · classifyDestinationRow · denyGate (denyPhase ⇒ refusal undeclared)',
1069
+ },
1070
+ [RelationUsersetUnsupportedError.code]: { kind: 'classified', reason: 'userset-unsupported', where: 'classifySubject (origen y destino, con o sin toConfig)' },
1071
+ [InvalidIdentityError.code]: { kind: 'classified', reason: 'invalid-identity', where: 'enumeratedKey (origen y destino)' },
1072
+ [RelationNotDenyableError.code]: { kind: 'refused', refusal: 'phase', where: "denyGate: denyPhase del DESTINO ≠ 'write' ⇒ 500 antes de escribir" },
1073
+ [RelationDenyExpiryError.code]: { kind: 'unreachable', why: 'el reconcile nunca pasa expiresAt a deny/removeDeny (D3: el deny no caduca)' },
1074
+ [CheckManyBatchError.code]: { kind: 'unreachable', why: 'el reconcile no llama a checkMany ni a decideMany' },
1075
+ [RelationConfigError.code]: { kind: 'unreachable', why: 'se lanza al DEFINIR la config; cuando el reconcile corre los drivers ya están construidos' },
1076
+ [RelationConfigUnknownKeyError.code]: { kind: 'unreachable', why: 'se lanza al DEFINIR la config (clave desconocida de la spec, B2)' },
1077
+ [InvalidSlugError.code]: { kind: 'unreachable', why: 'gramática de SLUGS del catálogo de roles: ninguna escritura de relaciones la invoca' },
1078
+ [ActorRequiredError.code]: { kind: 'unreachable', why: 'solo el RelationsManager exige actor; el reconcile habla con los drivers' },
1079
+ // ⚪ 7 (cierre 1 de D2): los 422 que nombra `src/shared/` (que importan los drivers de relaciones).
1080
+ [UnknownScopeError.code]: {
1081
+ kind: 'unreachable',
1082
+ why: 'src/shared/backend_guard.ts: assertKnownScope/canonicalScopeTargets son del árbol de ROLES; los drivers de relaciones solo importan de ahí withDeadline/guardSql/isServerWriteLimit/isTimeoutLike/isDeadlock/isUniqueViolation',
1083
+ },
1084
+ [NoScopeResolverError.code]: {
1085
+ kind: 'unreachable',
1086
+ why: 'src/shared/backend_guard.ts: rootOnlyResolver/resolveChain son del árbol de ROLES; ningún driver de relaciones resuelve cadenas de scopes',
1087
+ },
1088
+ });
1089
+ /**
1090
+ * **Los 422 de la FAMILIA de relaciones, clasificados por `code`** (cierre 3 del lote B · 🟠 N9, capa 2) — DERIVADA del
1091
+ * registro L9: las entradas `classified`. Por `code` y `status`, no por clase: un driver de terceros lanza su propia
1092
+ * clase con el mismo código. Fuera de esta tabla NADA se absorbe.
1093
+ */
1094
+ export const RELATION_422_REASONS = Object.freeze(Object.fromEntries(Object.entries(RELATION_REJECTIONS).flatMap(([code, entry]) => (entry.kind === 'classified' ? [[code, entry.reason]] : []))));
1095
+ /**
1096
+ * **El registro de los RESIDUOS de la pasada, por motivo** (cierre 2 de D2 · ⚪ N6; linaje L9): cada motivo de
1097
+ * `RelationsReconcileDetail` dice DÓNDE se clasifica —en el PLAN (antes del driver: los mismos números en `--dry-run` y
1098
+ * en la pasada real, punto 2 de L9) o en la PASADA (solo el driver lo sabe: EXCEPCIÓN declarada al punto 2, con su
1099
+ * porqué)—. `tests/relations_reconcile_l9.spec.ts` cruza las claves con `RELATIONS_RECONCILE_DETAIL_REASONS` (un motivo
1100
+ * nuevo sin entrada nace ROJO) y ejecuta un caso por entrada. Hasta el cierre 2 `deny-conditional` (un `catch` del 500
1101
+ * marcado en la pasada) quedaba fuera de la guardia, que solo enumeraba 422.
1102
+ */
1103
+ export const RELATION_RESIDUES = Object.freeze({
1104
+ 'invalid-identity': { where: 'plan', how: 'enumeratedKey (origen y destino); capa 2 por RELATION_REJECTIONS' },
1105
+ 'userset-unsupported': { where: 'plan', how: 'classifySubject (origen y destino, con o sin toConfig); capa 2 por RELATION_REJECTIONS' },
1106
+ 'undeclared': { where: 'plan', how: 'classifyDestinationRow (destino, con toConfig); sin toConfig, capa 2 por RELATION_REJECTIONS' },
1107
+ 'renewal-unsafe': {
1108
+ where: 'plan',
1109
+ how: 'planPartition: una membresía de toUpdate cuyo grupo transporta un deny (denyCarrierGroups) y un destino sin capabilities.atomicRenewal — sale de toUpdate y NIEGA su partición (cierre 5: cero escrituras en ella)',
1110
+ },
1111
+ 'deny-conditional': {
1112
+ where: 'plan',
1113
+ how: 'planPartition (cierre 4 de D2 · C3-2): un deny del destino CON caducidad —vivo con otra o VENCIDO— que el origen tiene, en un destino sin capabilities.atomicRenewal — no se reescribe y NIEGA su partición (cierre 5: cero escrituras en ella)',
1114
+ },
1115
+ });
1116
+ /** El motivo de la capa 2 para un error del driver, o `undefined` si no es de la familia (por `status` Y `code` PROPIO de la tabla). */
1117
+ function classifiedReason(error) {
1118
+ // `Object.hasOwn` (repaso del cierre 3 · ⚪ N13): un 422 con un `code` heredado del prototipo no es de la familia.
1119
+ const code = String(error?.code);
1120
+ return error?.status === 422 && Object.hasOwn(RELATION_422_REASONS, code) ? RELATION_422_REASONS[code] : undefined;
1121
+ }
1122
+ /**
1123
+ * Borra la tupla del destino (`unrelate` un allow, `removeDeny` un deny — jamás al revés); devuelve `null` si se borró
1124
+ * (o ya no estaba) y el motivo + porqué si el DRIVER la rechazó con un 422 de la familia (`RELATION_422_REASONS`): la
1125
+ * pasada lo clasifica como residuo del destino con `pruned: false` y SIGUE con las demás filas (🟠 N9: nunca un throw a
1126
+ * mitad de pasada por una fila que el reporte puede contar). Cualquier otro error se propaga tal cual.
1127
+ */
1128
+ async function deleteOrClassify(to, tuple, op) {
1129
+ try {
1130
+ if (op === 'removeDeny')
1131
+ await to.removeDeny(tuple.subject, tuple.relation, tuple.object, tuple.partition);
1132
+ else
1133
+ await to.unrelate(tuple.subject, tuple.relation, tuple.object, tuple.partition);
1134
+ return null;
1135
+ }
1136
+ catch (error) {
1137
+ const reason = classifiedReason(error);
1138
+ if (reason === undefined)
1139
+ throw error;
1140
+ return { reason, message: String(error?.message ?? error) };
1141
+ }
348
1142
  }
349
1143
  /** ¿El tipo de objeto cabe en el modelo fusionado del destino? (`group` es built-in: `hasType` lo incluye.) */
350
1144
  function declaredIn(config, objectType) {
@@ -353,7 +1147,12 @@ function declaredIn(config, objectType) {
353
1147
  /**
354
1148
  * Escribe la tupla en el destino; `false` si el DRIVER la rechazó por F-05
355
1149
  * (tipo o relación no declarados: 422 `E_AUTHZ_RELATION_TYPE_UNKNOWN` /
356
- * `E_AUTHZ_RELATION_UNKNOWN`). Cualquier otro error se propaga tal cual.
1150
+ * `E_AUTHZ_RELATION_UNKNOWN`). Cualquier otro error se propaga tal cual —
1151
+ * **también `E_AUTHZ_RELATION_USERSET_UNSUPPORTED`** (cierre 1 del lote B,
1152
+ * decisión 4 rechazada): esa clase ya no hereda de F-05 y aquí no se absorbe;
1153
+ * el residuo de B3 se clasifica en el PLAN y nunca llega a este `relate`. Un
1154
+ * driver que lo lanzara por una tupla que el plan dio por buena está diciendo
1155
+ * otra cosa, y eso se propaga (R2-5: el plan clasifica, el driver no absorbe).
357
1156
  */
358
1157
  async function relateOrSkip(to, tuple) {
359
1158
  try {
@@ -363,7 +1162,9 @@ async function relateOrSkip(to, tuple) {
363
1162
  return true;
364
1163
  }
365
1164
  catch (error) {
366
- if (error instanceof RelationTypeUnknownError || error instanceof RelationUnknownError)
1165
+ // L9: por el registro (capa 2) — SOLO `undeclared` se cuenta aquí (`skipped.undeclared`); el resto de la familia lo
1166
+ // clasifica el plan antes y, si el driver lo lanza igual, es una contradicción que se propaga.
1167
+ if (classifiedReason(error) === 'undeclared')
367
1168
  return false;
368
1169
  throw error;
369
1170
  }