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

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 (71) hide show
  1. package/README.md +174 -17
  2. package/build/commands/authz_relations_reconcile.d.ts +7 -62
  3. package/build/commands/authz_relations_reconcile.d.ts.map +1 -1
  4. package/build/commands/authz_relations_reconcile.js +94 -118
  5. package/build/commands/authz_relations_reconcile.js.map +1 -1
  6. package/build/index.d.ts +1 -1
  7. package/build/index.d.ts.map +1 -1
  8. package/build/index.js +1 -1
  9. package/build/index.js.map +1 -1
  10. package/build/providers/authz_provider.d.ts.map +1 -1
  11. package/build/providers/authz_provider.js +4 -1
  12. package/build/providers/authz_provider.js.map +1 -1
  13. package/build/src/define_config.d.ts +32 -0
  14. package/build/src/define_config.d.ts.map +1 -1
  15. package/build/src/define_config.js +109 -0
  16. package/build/src/define_config.js.map +1 -1
  17. package/build/src/drivers/database_relations_driver.d.ts.map +1 -1
  18. package/build/src/drivers/database_relations_driver.js +3 -2
  19. package/build/src/drivers/database_relations_driver.js.map +1 -1
  20. package/build/src/drivers/openfga_driver.d.ts +30 -4
  21. package/build/src/drivers/openfga_driver.d.ts.map +1 -1
  22. package/build/src/drivers/openfga_driver.js +49 -9
  23. package/build/src/drivers/openfga_driver.js.map +1 -1
  24. package/build/src/drivers/openfga_relations_driver.d.ts +66 -9
  25. package/build/src/drivers/openfga_relations_driver.d.ts.map +1 -1
  26. package/build/src/drivers/openfga_relations_driver.js +145 -21
  27. package/build/src/drivers/openfga_relations_driver.js.map +1 -1
  28. package/build/src/errors.d.ts +36 -0
  29. package/build/src/errors.d.ts.map +1 -1
  30. package/build/src/errors.js +36 -0
  31. package/build/src/errors.js.map +1 -1
  32. package/build/src/manager.d.ts.map +1 -1
  33. package/build/src/manager.js +6 -1
  34. package/build/src/manager.js.map +1 -1
  35. package/build/src/openfga.d.ts +1 -1
  36. package/build/src/openfga.d.ts.map +1 -1
  37. package/build/src/openfga.js +1 -1
  38. package/build/src/openfga.js.map +1 -1
  39. package/build/src/relations/define_relations_config.d.ts +74 -1
  40. package/build/src/relations/define_relations_config.d.ts.map +1 -1
  41. package/build/src/relations/define_relations_config.js +201 -7
  42. package/build/src/relations/define_relations_config.js.map +1 -1
  43. package/build/src/relations/manager.d.ts.map +1 -1
  44. package/build/src/relations/manager.js +4 -2
  45. package/build/src/relations/manager.js.map +1 -1
  46. package/build/src/relations/reconcile.d.ts +77 -13
  47. package/build/src/relations/reconcile.d.ts.map +1 -1
  48. package/build/src/relations/reconcile.js +247 -36
  49. package/build/src/relations/reconcile.js.map +1 -1
  50. package/build/src/relations_config_store.d.ts.map +1 -1
  51. package/build/src/relations_config_store.js +29 -7
  52. package/build/src/relations_config_store.js.map +1 -1
  53. package/build/src/relations_reconcile.d.ts +79 -0
  54. package/build/src/relations_reconcile.d.ts.map +1 -0
  55. package/build/src/relations_reconcile.js +85 -0
  56. package/build/src/relations_reconcile.js.map +1 -0
  57. package/build/src/scope_outbox.d.ts +9 -5
  58. package/build/src/scope_outbox.d.ts.map +1 -1
  59. package/build/src/scope_outbox.js +7 -4
  60. package/build/src/scope_outbox.js.map +1 -1
  61. package/build/src/testing/relations_contract.d.ts.map +1 -1
  62. package/build/src/testing/relations_contract.js +72 -2
  63. package/build/src/testing/relations_contract.js.map +1 -1
  64. package/build/src/testing/scope_outbox_contract.d.ts +14 -1
  65. package/build/src/testing/scope_outbox_contract.d.ts.map +1 -1
  66. package/build/src/testing/scope_outbox_contract.js +126 -48
  67. package/build/src/testing/scope_outbox_contract.js.map +1 -1
  68. package/build/src/types.d.ts +7 -3
  69. package/build/src/types.d.ts.map +1 -1
  70. package/build/src/types.js.map +1 -1
  71. package/package.json +2 -1
package/README.md CHANGED
@@ -161,7 +161,7 @@ The relay is resumable and never silent: the report says *which* changes were ap
161
161
 
162
162
  **How old is the queue? `report.queue` and `--max-age` (2.4.0-alpha.8).** Measured in a consumer (panel relay 2, auditor B2): with the store down, ten relay passes in a row reported `attempts = 0` on every entry (a 503 does not consume an attempt, since alpha.5), `dead = 0` and **no notion of time anywhere** — the cron was red because of `failures`, but nobody could tell since when, and nothing distinguished a queue that has been stuck for an hour from one that was enqueued a second ago. Every relay report now carries **`queue: { oldestPendingAt, pendingAgeMs }`**: the `createdAt` of the **oldest pending entry of the whole queue** — the `pending(1)` the pass already ran at the end for `remaining`, so it costs no extra query — and its age, `max(0, now − createdAt)`, measured with the manager's injectable clock (`config.clock`; no `Date.now()` in the package sources, the suite greps for it). It is **always global, also under `only`**, like `remaining` and `dead`: it names nobody, it only says "there is backlog". Both fields are `null` when nothing is *pending* (an empty queue, or only parked entries — those have their own alarm, `deadCount`, below) and when the port does not provide `createdAt`: a third-party `ScopeOutbox` whose entries carry no `createdAt` reads as `null`, **never as `0` or "now"** (the same rule as `stillGranting`: "I do not know" does not degrade to a number). `sqlScopeOutbox` returns it from the `created_at` column it already had (`createdAt?: Date` is an optional field of `PendingScopeTreeChange`; the outbox contract has a `createdAt` capability pair). Read the number for what it is: **the age is the clock of whoever enqueued** — another replica with a skewed clock stamps its own `created_at`, so the age is clamped at zero and never negative, and it is not a decision the package takes (audit stamps are not decisions). The command prints it on every pass; **`--max-age=<ms>`** makes the pass exit ≠ 0 when the age is above the threshold — **only with a threshold**: without it the age is reported and never fails the run, because a cron that goes red for ever over a queue that simply has backlog is a cron nobody reads. Parking by age (`parkAfterMs`) is *not* offered, on purpose — see the changelog: it reintroduces, with another clock, the mass parking alpha.5 removed, and it was measured to make every pass a 500 once the census fills.
163
163
 
164
- **Enqueueing outside your transaction: `sqlScopeOutbox({ requireTransaction: true })` (2.4.0-alpha.8).** The mitigation above is *entirely* the `{ transaction: trx }` you hand to `scopes.attached/moved/detached`. Without it the call still enqueues — the row commits on its own, outside your unit of work — and if your transaction rolls back afterwards **the change stays in the queue and the relay applies it to the store**: your database says one parent, the PDP another, and nothing on your side can show it (the S5 leak, on the *correct-use* path; measured by the tester of panel relay 2, P13). With `requireTransaction: true` that call is **500 `E_AUTHZ_CONFIG` before the `INSERT`**, naming exactly that leak, through the manager and through the outbox alone; with an open transaction of the outbox's connection it enqueues as always. The default is **`false` in 2.4** — the published outbox contract (`runScopeOutboxContract`) enqueues without a transaction in its `ctx()`, and a `true` default would turn every third-party outbox red — and **the default becomes `true` in 2.5** (owner's decision), together with the contract passing the transaction in `ctx()` (a prerequisite of that release; without it the default cannot change). Turn it on today: it is the letter of the mitigation, and the only thing you lose is the ability to enqueue by mistake. A transaction that is **already committed or rolled back** is a different 500, below.
164
+ **Enqueueing outside your transaction: `sqlScopeOutbox({ requireTransaction: true })` (2.4.0-alpha.8).** The mitigation above is *entirely* the `{ transaction: trx }` you hand to `scopes.attached/moved/detached`. Without it the call still enqueues — the row commits on its own, outside your unit of work — and if your transaction rolls back afterwards **the change stays in the queue and the relay applies it to the store**: your database says one parent, the PDP another, and nothing on your side can show it (the S5 leak, on the *correct-use* path; measured by the tester of panel relay 2, P13). With `requireTransaction: true` that call is **500 `E_AUTHZ_CONFIG` before the `INSERT`**, naming exactly that leak, through the manager and through the outbox alone; with an open transaction of the outbox's connection it enqueues as always. **The default is **`true` since 2.5.0** (lot B · B6, owner's decision; it was `false` in 2.4 because the published outbox contract enqueued without a transaction and a `true` default would have turned every third-party outbox red). `requireTransaction: false` is the explicit opt-out — the signature of code that enqueues outside any unit of work on purpose (a seeder, a harness) —, and the published contract now carries the capability pair **`requiresTransaction`** (optional, `false` when absent): with `true` the judge enqueues inside a transaction of your connection in every case, direct and through `scopes.*`, and pins the refusal (500 naming `requireTransaction`, zero rows, by the outbox alone and by the manager); with `false` it pins that enqueueing without one enters. Mind the pool: `{ transaction }` **through the manager** needs a pool of at least 2 (the freeze barrier reads through another connection; with a single one the notification is 503 `E_AUTHZ_BACKEND_TIMEOUT` at `freezeTimeoutMs`), so a deployment with **pool max 1 — knex's default for every SQLite connection, file or `:memory:`** — cannot use the default through `scopes.*`: declare `requireTransaction: false` there and say so (the 500 names the pool and that way out; following its first advice alone on a pool of 1 leads to the 503). A transaction that is **already committed or rolled back** is a different 500, below.
165
165
 
166
166
  **What the report says about the parked set — `deadCount`, `deadDetail`, and the `origin` of a deferral (2.4.0-alpha.8).** Three small corrections to the report, each measured in a consumer. **`deadCount`** is always present and always global: `dead[]` is capped at `batchSize` (100), so `dead.length` *lies* once more than that is parked; the alarm "the queue does not converge" now rests on the count, which comes from the census the pass already reads for the key plug (no extra query). **Under `only`, `dead[]` carries `id`, `change` and `attempts` and — by default — no `error`**: the inline recipe runs inside a tenant's request, and the error text is what the backend said about *another* tenant's tree; pass `deadDetail: 'full'` to get it back (without `only` nothing changes, and the entries you *asked for* keep their `reason` in `entries`, they are yours). Read this as the safe-fields rule for a report you might be tempted to surface: `deadCount` and the ids say "there is a parked backlog" and nothing else; `change` still names scopes of other tenants (the actor never travels in `dead[]`), so the rule above stands — do not return the report over HTTP. And every `deferred` entry now carries **`origin`**: `'parked'` (held, directly or transitively, by a parked entry — the only origin that lets a `detached` through, invariant 11), `'failed'` (held by a real failure of this pass), or `'pending'` (under `only`: held by an earlier entry that is genuinely pending and was not requested — it will land on the next full pass). Until alpha.8 `depends-on:<id>` did not distinguish the last two and the origin lived only in the Spanish prose of `deferred[].error`; it is derived from the block itself, never parsed back from the text of `reason`.
167
167
 
@@ -249,7 +249,7 @@ The *row* is inside your transaction; the *decision* that authorises writing it
249
249
 
250
250
  #### Third-party drivers, and what the suite can and cannot prove
251
251
 
252
- "Both or neither" is **a guarantee of this package's `database` driver**, measured on its own schema on three engines. For a driver of yours declaring `transactionalWrites: true`, the runner requires the census (the `transactions` hook: how to open a transaction of *your* connection and how to count *your* rows) and judges rollback ⇒ zero, commit ⇒ applied, the uncommitted write invisible to the authority and three foreign transactions ⇒ 500 with zero statements — but it **cannot prove that a third-party driver's write really lives and dies with the transaction it is handed**: the census it counts is the one your hook reports, and a driver that opens its own transaction underneath passes the answer checks. Two more honest boundaries: the `true` face only runs where a second connection exists (`sqlite-file`, PostgreSQL, MySQL — the engine jobs of CI are not optional for this capability), so a driver judged only on `:memory:` never sees those cases; and the 409/503 classification of the table above is the `database` driver's — a third-party driver that returns the raw engine error is not caught by the suite.
252
+ "Both or neither" is **a guarantee of this package's `database` driver**, measured on its own schema on three engines. For a driver of yours declaring `transactionalWrites: true`, the runner requires the census (the `transactions` hook: how to open a transaction of *your* connection and how to count *your* rows) and judges rollback ⇒ zero, commit ⇒ applied, the uncommitted write invisible to the authority and three foreign transactions ⇒ 500 with zero statements — but it **cannot prove that a third-party driver's write really lives and dies with the transaction it is handed**: the census it counts is the one your hook reports, and a driver that opens its own transaction underneath passes the answer checks. Two more honest boundaries: the `true` face only runs where a second connection exists (`sqlite-file`, PostgreSQL, MySQL — the engine jobs of CI are not optional for this capability), so a driver judged only on `:memory:` never sees those cases; and the 409/503 classification of the table above is the `database` driver's — a third-party driver that returns the raw engine error is not caught by the suite. The same honesty for relations: the `listObjectsTruncation` pair of `runRelationsDriverContract` judges the **cap** only (`limits.listMaxResults`); **the runner does not judge the deadline** signal of a third-party `listObjects` (there is no way to observe it without sleeping), so a driver that does not implement it passes — the deadline cause is a property of this package's `openfga` driver, pinned by its own cases and by the surface guard. And the B3 case (a userset other than `group#member` as subject) observes your backend **through the port** — `listSubjects` and, under `enumerateRelations: true`, the whole partition — so a driver that stores the inert row and then throws the 422 goes red only through a port it declares: the minimal harness (`usersetSubjects: false`, `enumerateRelations: false`) has no port through which the runner can see that row, and such a driver passes (measured with that mutant on the double).
253
253
 
254
254
  ### Identity is validated, once and everywhere
255
255
 
@@ -374,7 +374,7 @@ router
374
374
 
375
375
  The middleware resolves the authenticated holder from its morph name and asks `authorize` at the **`app` scope**. It accepts **`{ permission }` only**: `appAccess({ role })` was removed in 2.0 — a gate over membership could not be denied — and passing `role` is a 500 `E_AUTHZ_ROLE_IS_NOT_ACCESS` with the recipe (create a permission, link it to the role, gate on the permission), thrown before authentication is checked (*"appAccess({ role }) es 500 E_AUTHZ_ROLE_IS_NOT_ACCESS con la receta"*). Per-organization or per-unit enforcement is your controller's or your own middleware's job: only your domain knows which scope a route belongs to. The holder must expose `uuid`; a numeric-PK model is rejected with an explicit error.
376
376
 
377
- ### Per-resource enforcement: `resourceAccess` (2.5)
377
+ ### Per-resource enforcement: `resourceAccess` (2.4.0-alpha.1)
378
378
 
379
379
  `appAccess` gates a route at the `app` scope. To gate one **resource** — *this document, in that
380
380
  organization* — the `resourceAccess` middleware composes the same `authorize`: your code says how to
@@ -641,7 +641,11 @@ platform escape hatch) and `authz:relations:reconcile` — which writes through
641
641
  { type: 'role_binding', id: <roleUuid> }, S)` turned `roles.authorize(evil)` from `false` to `true`
642
642
  (measured against a real server; the bridge spec keeps that case, and removing the driver guard puts
643
643
  it back in red). `manager.driver()` still skips the manager's *other* barriers (`actor`,
644
- `assertWrite`, `onRelationWrite`), as documented — F-05 is no longer one of them.
644
+ `assertWrite`, `onRelationWrite`), as documented — F-05 is no longer one of them. **The userset subject is part of
645
+ that grammar (2.5.0 · B3)**: the only userset the fused model admits as a subject is `group#member`, so
646
+ `{ object: document, relation: viewer }` as subject is 422 `E_AUTHZ_RELATION_USERSET_UNSUPPORTED` on every operation
647
+ that takes a subject, through the same single function in the manager, both drivers and the published double — until
648
+ 2.5.0 `database` stored that row inert and `openfga` surfaced the server's 400 as a 503.
645
649
 
646
650
  **And since `2.4.0-alpha.3` F-05 covers **all nine operations of the port** — the eight of alpha.3 plus
647
651
  `checkMany` (per item) since 2.5.0 —, not only the two writes that compose a tuple.** The alpha.3 audit found the chokepoint one call-site short (pre-existing in alpha.2, in
@@ -674,28 +678,59 @@ membership (through nested groups). Only the `database` driver has it (a recursi
674
678
  it is 500 `E_AUTHZ_UNSUPPORTED` naming it — the transitive form is `ListUsers`, which truncates
675
679
  without a reliable signal, and we never return a silent partial. `listSubjects` (direct facts,
676
680
  invariant 7) works in both. `listObjects` in `openfga` signals `truncated: true` when the server's
677
- `ListObjects` cap cuts the page, never a mute partial list.
678
-
679
- **The config is persisted, and republishing never mutilates the model.** `saveRelationsConfig(spec)`
680
- stores the relations config in `authz_relations_config` under the shared version gate (invariant 14).
681
- Because the catalog and the relations config share one model lifecycle, both `syncAuthzCatalog` and a
682
- config save republish the fused model — and they race for the `modelId`. `republishFusedModel` reads
683
- **both** persisted halves (catalog permissions + relation types) every time, so the published model
684
- is never "the model of one, the tuples of another"; the `modelId` is pinned with a bounded CAS, and
685
- contention that will not yield is 409 `E_AUTHZ_WRITE_CONFLICT`, never a half model.
681
+ `ListObjects` cap cuts the page **and** when the call took **90 % or more of the server's `ListObjects`
682
+ deadline** (`listObjectsDeadlineMs`, default 3,000 ms, OpenFGA's default `--listObjects-deadline`; set it to
683
+ your server's **effective** cut, below) — past its deadline the server answers 200 with whatever it had resolved and **no signal**
684
+ (measured, 2.5.0 · B4; until then the README promised no mute partial list at all, and that was only true of the
685
+ cap). Both causes are one signal, through one envelope (`#listObjectsPage`, measured with a monotonic clock);
686
+ the signal is conservative on purpose: a slow-but-complete call may be flagged, a cut one never passes as
687
+ complete — the price is in [Limits](#limits). `listObjectsDeadlineMs` is the server's **effective** cut, whichever is
688
+ lower of the three — `--listObjects-deadline` (when it is not 0), `--request-timeout` (3 s by default) and, over HTTP,
689
+ the gateway's `--http-upstream-timeout` (5 s by default) — declare that lowest one: a server running with
690
+ `--listObjects-deadline=0` has **no** "no deadline" — its `--request-timeout` still cuts `ListObjects` with a mute 200
691
+ (measured on OpenFGA v1.19.0 by the audit of 2.5.0-alpha.2: `--request-timeout=30ms` answered `200` with 0 of 40,000
692
+ objects; `list_objects.go` only sets its own timeout when the deadline is not 0 and inherits the request-timeout
693
+ context otherwise) — so declare that value instead. `Infinity` is refused (500 `E_AUTHZ_CONFIG`): it would make the
694
+ driver pass that mute list as complete. Both at 0 is not supported (what the HTTP gateway's `--http-upstream-timeout`
695
+ does then is not measured for `ListObjects`; the package promises no "no deadline" it has not measured). And the
696
+ driver's own `timeoutMs` (5 s by default) cannot be lower than `listObjectsDeadlineMs`: the signal has to be able to
697
+ fire before the client cuts, so a deadline above the client timeout is 500 `E_AUTHZ_CONFIG` at construction
698
+ (`Number.MAX_SAFE_INTEGER` was "no deadline" under another name, 2.5.0-alpha.2 · closure 3). A 200 **without
699
+ `objects`** is 503, not an empty list. The `Read`-based enumerations — `listSubjects`, `enumerateRelations(Many)`,
700
+ the purges — are **exhaustive or 503**: they page until the continuation token is empty and throw on a response
701
+ without `tuples`, a repeating token or more than 10,000 pages (`E_AUTHZ_BACKEND_UNAVAILABLE`), never a short page.
702
+ The roles driver's `Read`s (`readAllTuples`, the reconcile dump, `enumerateFacts`) hold the same rule since
703
+ `2.5.0-alpha.2`.
704
+
705
+ **The config can be persisted, and nothing republishes the model behind your back.** `saveRelationsConfig(spec)`
706
+ stores the relations config in `authz_relations_config` under the shared version gate (invariant 14), so a
707
+ process without `defineRelationsConfig` in memory can read the declared types (`readRelationsConfig()`,
708
+ `null` when nothing was ever saved). **The fused model is published by one thing only: `node ace
709
+ openfga:provision`**, from the *static* config (`relations.config` and the `catalogs`) — the command boots
710
+ without a database and cannot read the table —, and re-publishing it is a manual operator step (single writer:
711
+ the `modelId` goes to every pod by hand, see [the cutover window](#the-cutover-window)). Neither
712
+ `syncAuthzCatalog` nor a config save republishes anything. The persisted copy has one reader today:
713
+ `authz:relations:reconcile`, which uses it as the **destination's** config to flag model drift — and since
714
+ 2.5.0 a *failed* read of that table stops the command before any write (an unreadable config is not "no
715
+ config"; `null` is).
686
716
 
687
717
  **Migrating tuples between drivers** is `node ace authz:relations:reconcile --to=<key>` — the relations
688
718
  analog of `authz:reconcile`, idempotent, bidirectional and never silent (it reports written / deleted
689
719
  / unchanged / extra). `--to`/`--from` are keys of `relations.drivers` in `config/authorization.ts`;
690
720
  `--dry-run` is the read-only verifier and also flags **model drift** (an object type in the source the
691
- destination does not declare); `--prune` deletes what the source no longer backs. **Never towards the
721
+ destination does not declare); `--prune` deletes what the source no longer backs — except a destination row the API
722
+ does not admit (`details`: `invalid-identity`, `userset-unsupported`, `undeclared` — a relation or type the
723
+ destination no longer declares), which no flag touches: listed, exit ≠ 0 in every mode, deleted by hand or, for
724
+ `undeclared`, declared again ([Upgrading, 6](#upgrading-to-250)); a 422 of the relations family from the destination's
725
+ `unrelate` is classified the same way and never aborts the pass. **Never towards the
692
726
  ACTIVE relations driver** (2.4.0-alpha.10): once `relations.default ?? config.default` is `--to`, any
693
727
  other source — with `--from` or picked by elimination — is 500 `E_AUTHZ_STALE_SOURCE_REFUSED` (it
694
728
  would resurrect shares revoked since the cutover and, with `--prune`, delete the new ones); `--dry-run`
695
729
  flags it, and `--allow-stale-source` is the human decision. It migrates **facts
696
730
  only** — there is no tree or catalog in `relations/` — and works **per partition** (`--partition-type`
697
731
  /`--partition-uuid`; default `app`). **From code, the one public way is `runRelationsReconcile`** —
698
- `import { runRelationsReconcile } from '@jantstack/adonis-authz/commands/authz_relations_reconcile'` and
732
+ `import { runRelationsReconcile } from '@jantstack/adonis-authz/relations/reconcile'` (its own subpath since 2.5.0; until
733
+ `2.5.0-alpha.1` it was `…/commands/authz_relations_reconcile`, which no longer exports it) and
699
734
  `runRelationsReconcile(authorization, { toKey, fromKey, partitions: [...] })` migrates several partitions in one
700
735
  pass (see the sweep below), with the same guard and the same durable window as the command. Source and
701
736
  destination are **keys of `relations.drivers`, resolved from the manager's own config**, and the guard compares
@@ -832,7 +867,7 @@ Every error the package raises carries `status` and `code`. A standard AdonisJS
832
867
  | `E_AUTHZ_FREEZE_HELD` | 423 | `freeze()` (or a second `authz:reconcile`) found a live freeze owned by someone else: two windows never interleave, and only the owner's token — or `authz:unfreeze` — lifts one. The message names the holder, the reason and the fence |
833
868
  | `E_AUTHZ_RESOLVER_FAILED` | 503 | your `resolveChain`, `parentOf` or `descendantsOf` threw or answered a malformed scope; `descendantsOf` and `resolveChain` disagree in `authorizedScopes`; a subtree to exclude cannot be enumerated |
834
869
  | `E_AUTHZ_WRITE_CONFLICT` | 409 | an `openfga` write kept clashing with another transaction over the same tuples (FGA answers `Aborted`/409, or 400 "cannot write a tuple which already exists"): the driver re-reads and re-applies, and only gives up after three rounds. The backend answered, so this is never a 503 — retry the write. Also (2.4.0-alpha.2, lot L-3) a `database` `grant`/`deny` **inside your transaction** (`{ transaction }`) that hits the UNIQUE because another transaction committed the same fact while yours was open: outside a transaction the driver re-reads and keeps the winner's row; inside yours it cannot (PostgreSQL has already aborted your transaction — every later statement is `25P02` until you roll back — and under REPEATABLE READ the re-read would not see the winner), so it **poisons your transaction**: roll back and retry. Measured on all three engines, see [writing inside your transaction](#writing-inside-your-transaction-240-alpha2). The same for a `database` `relate`/`unrelate`/`purge*` inside your transaction (lot L-4) — on a UNIQUE clash of a tuple (userset, and since lot L-4b holder too: `subject_relation` is `''`, never `NULL`), and on a **deadlock** (two transactions writing two tuples in crossed order: PostgreSQL `40P01`, MySQL `1213`; the engine's victim gets this 409, never a hang nor the raw error) |
835
- | `E_AUTHZ_CONFIG` | 500 | `relayScopeChanges({ only: [] })`, a `ScopeOutbox` that ignores `pending(limit, after, { ids })` and returns an entry that was not asked for, `requeueScopeChanges` with an id that is not parked (or an empty list), or a `scopes.maxDepth` that is not an integer ≥ 1 (2.4.0-alpha.5); contradictory config (`holderTypes` not injective or a holder type not declared in it, `scopes.*` without resolver, `appAccess` without `permission`, `catalog` together with `catalogRevalidate`, an invalid `maxAgeMs`); `bumpAuthzCatalogVersion` called without the writing transaction's client; a `{ transaction }` handed to `sqlScopeOutbox` that is not an open Lucid transaction of the outbox's own connection (another connection, a `QueryClient`, or the whole `db` — the message names both connections), **or one that is already committed or rolled back** (`isCompleted`, 2.4.0-alpha.8 — the same check in the roles and relations drivers; it used to surface as a 503); `sqlScopeOutbox({ requireTransaction: true })` enqueueing without `{ transaction }` (2.4.0-alpha.8; the default becomes `true` in 2.5); `relayScopeChanges({ deadDetail })` with a value other than `'full'`; `requireTransactionalWrites: true` (root, or `relations.requireTransactionalWrites`) with an active driver that does not declare `transactionalWrites: true` — raised **when the driver is resolved**, so reads fail too and the deployment does not start (2.4); a `relations.assertWrite` that returns a promise — or any value at all: `false`, `true`, a thenable function (it must be synchronous and it does not return verdicts: it throws or it does not; the write is refused before the driver, 2.4.0-alpha.3) |
870
+ | `E_AUTHZ_CONFIG` | 500 | `relayScopeChanges({ only: [] })`, a `ScopeOutbox` that ignores `pending(limit, after, { ids })` and returns an entry that was not asked for, `requeueScopeChanges` with an id that is not parked (or an empty list), or a `scopes.maxDepth` that is not an integer ≥ 1 (2.4.0-alpha.5); contradictory config (`holderTypes` not injective or a holder type not declared in it, `scopes.*` without resolver, `appAccess` without `permission`, `catalog` together with `catalogRevalidate`, an invalid `maxAgeMs`); `bumpAuthzCatalogVersion` called without the writing transaction's client; a `{ transaction }` handed to `sqlScopeOutbox` that is not an open Lucid transaction of the outbox's own connection (another connection, a `QueryClient`, or the whole `db` — the message names both connections), **or one that is already committed or rolled back** (`isCompleted`, 2.4.0-alpha.8 — the same check in the roles and relations drivers; it used to surface as a 503); `sqlScopeOutbox()` enqueueing without `{ transaction }` (`requireTransaction`, 2.4.0-alpha.8; `true` by default since 2.5.0 · B6, `false` is the explicit opt-out); `relayScopeChanges({ deadDetail })` with a value other than `'full'`; `requireTransactionalWrites: true` (root, or `relations.requireTransactionalWrites`) with an active driver that does not declare `transactionalWrites: true` — raised **when the driver is resolved**, so reads fail too and the deployment does not start (2.4); a `relations.assertWrite` that returns a promise — or any value at all: `false`, `true`, a thenable function (it must be synchronous and it does not return verdicts: it throws or it does not; the write is refused before the driver, 2.4.0-alpha.3) |
836
871
  | `E_AUTHZ_ROLE_IS_NOT_ACCESS` | 500 | `appAccess({ role })` |
837
872
  | `E_AUTHZ_INTERNAL` | 500 | package invariant violated (empty scope set on a write, misaligned batch, a third-party `authorizeMany` answering the wrong shape, a `Read` continuation token that never advances or more than 10,000 pages, a corrupt `assignable_at`/`owner_scope_key` row) |
838
873
  | `E_AUTHZ_PURGE_INCOMPLETE` | 500 | `purgeScope` could not prove zero; on the relations port, `purgeObject`/`purgeSubject` of `openfga` could not prove zero after deleting — the error carries the `markPartialWrite` mark and the event was emitted with `indeterminate: true` (2.4.0-alpha.3) |
@@ -1261,6 +1296,7 @@ Every bound the package enforces or measured, in one place, each with the consta
1261
1296
  | Depth gate on writes (opt-in, 2.4.0-alpha.5) | `scopes.maxDepth` | none by default; set it to 22 for `facts` | the manager, `scopes.attached`/`scopes.moved`, every driver | 422 `E_AUTHZ_SCOPE_DEPTH_LIMIT` **before** writing or enqueueing; see below |
1262
1297
  | Authorization-model size | `FACTS_MODEL_MAX_BYTES` | 262,144 | `openfga`, `syncAuthzCatalog` | 500 `E_AUTHZ_MODEL_TOO_LARGE` before writing, warning past 80 % (`FACTS_MODEL_WARN_RATIO`); how many permissions those bytes are is [not a property of the model](#the-model-ceiling-is-bytes-not-permissions) |
1263
1298
  | Checks per `batchCheck` | `FGA_MAX_BATCH_CHECK` | 50 | `openfga`, `authorizeMany` | the driver splits into batches of 50 (the SDK's cap), nothing to do |
1299
+ | `ListObjects` effective cut of the server (relations `listObjects`) | `listObjectsDeadlineMs` (`DEFAULT_LIST_OBJECTS_DEADLINE_MS`) | 3,000 ms | the `openfga` relations driver (2.5.0 · B4) | a call that takes ≥ `LIST_OBJECTS_DEADLINE_RATIO` (0.9) × this value returns `truncated: true` — the server cuts at its deadline with a 200 and no signal; set it to the **lowest** of your server's `--listObjects-deadline` (when not 0), `--request-timeout` (3 s by default) and, over HTTP, `--http-upstream-timeout` (5 s by default) — declare that lowest one: with `--listObjects-deadline=0` the request timeout still cuts with a mute 200 (measured on OpenFGA v1.19.0), there is no "no deadline", `Infinity` is 500, both at 0 is not supported (the gateway's cut is not measured for `ListObjects`), and the driver's `timeoutMs` (5 s by default) cannot be lower than `listObjectsDeadlineMs` (500 at construction: the signal has to fire before the client cuts; `Number.MAX_SAFE_INTEGER` is caught by that). **The price of the conservative signal**: with the default, every `ListObjects` that takes 2,700 ms or more comes back `truncated` even when complete; a consumer that treats `truncated` as a 503 pays that p99 as user-facing 503s — raise the server's deadline (and this value) or handle `truncated` as a partial list, not as an outage |
1264
1300
  | Objects per `checkMany` | `RELATIONS_CHECK_MANY_MAX` | 50 | the `RelationsManager` and both relations drivers (2.5.0) | 422 `E_AUTHZ_CHECK_MANY_BATCH` with zero backend calls — split the list yourself; `[]` is 422 too. One batch is one `batchCheck` in `openfga` and one query in `database` |
1265
1301
  | `Read` page size (enumerations, `enumerateFacts`, `enumerateRelations`) | — | 100 | `openfga` | the server's hard cap: `pageSize: 1000` is a 400; the driver pages until the continuation token is empty — a page with zero tuples **and** a token is not the end; a token that repeats, more than 10,000 pages, or a response without `tuples` is 503 `E_AUTHZ_BACKEND_UNAVAILABLE`, never a shorter enumeration |
1266
1302
  | `enumerateRelations(partition)` / `enumerateRelationsMany(partitions)` cost | — | O(tuples in the store) **per sweep** — one sweep per `enumerateRelations` call, **one sweep for K partitions** with `enumerateRelationsMany` / `runRelationsReconcile(…, { partitions })` (2.4.0-alpha.6) | `openfga`, the *source* of `authz:relations:reconcile` | there is no prefix `Read` in OpenFGA (a prefix object answers 200 with **zero** tuples, measured: never "optimise" it that way — the suite spies the filter of every `read`, it is always `{}`); measured 15,000 tuples ⇒ 151 pages ≈ 0.5 s per sweep whatever the partition size; 3 partitions in a 5,000-tuple store: **150 reads / 502–558 ms** as three sweeps → **50 reads / 170–209 ms** as one ([the table](#the-source-sweep-measured)). Run it from a job or a cron, **never from a tenant request** |
@@ -1441,13 +1477,134 @@ in the same line). Three things change for an installation on `2.4.0-alpha.x`:
1441
1477
  driver without the manager and expects the same 422s — on `membersOf` too when the driver declares
1442
1478
  `membersOfNative`, and `checkMany` called directly must refuse 51 objects and `[]`. A third-party harness
1443
1479
  that seeded undeclared types straight through its driver breaks on purpose; the in-memory double already
1444
- behaved this way since lot 0.
1480
+ behaved this way since lot 0. **Since `2.5.0-alpha.2` the same is demanded for B3** (closure 1 of lot B): a
1481
+ userset subject that is not `group#member` must be 422 `E_AUTHZ_RELATION_USERSET_UNSUPPORTED` through the
1482
+ manager *and* through the driver called directly, on `relate`/`unrelate`/`check`/`checkMany`/`listObjects`/
1483
+ `purgeSubject` — through the manager with zero driver calls, spied — (29 core cases per harness). A third-party
1484
+ driver that stored such a row inert, or answered 503, passed the contract until then.
1445
1485
  3. **`checkMany` and `relationTupleKey` are additive** (`relationTupleKey` demands `kind`: there is no default);
1446
1486
  `contractRelationsConfig()` declares a second object type (`workspace`, same relations as `document`) so the
1447
1487
  mixed-types case is observable — a harness that asserted on the published model of that config sees one type
1448
1488
  more. `createTestClock` lives at **`@jantstack/adonis-authz/testing/clock`** (no Japa or Lucid import):
1449
1489
  import it from there in a config file production loads.
1450
1490
 
1491
+ **Lot B (`2.5.0-alpha.2`) — the breaking changes decided for before the first 2.x stable**, each with how to adapt:
1492
+
1493
+ 4. **A cycle in `includes` is 422 at `defineRelationsConfig`** (B1): `editor ⊇ viewer ⊇ editor` (direct, two or more
1494
+ relations) used to be accepted — `database` served it, `openfga:provision` could not publish it. The message names
1495
+ the cycle (`document#editor → document#viewer → document#editor`). *Adapt*: remove the edge that closes it; a diamond
1496
+ (`viewer ⊇ editor ⊇ owner` and `viewer ⊇ owner`) is fine.
1497
+ 5. **An unknown key anywhere in the relations spec is 422 `E_AUTHZ_RELATION_CONFIG_UNKNOWN_KEY`** (B2), naming the path
1498
+ (`objectTypes[1].deny`, `objectTypes[0].relations[1].denny`, `database.foo`) and the allowed keys of that level — until
1499
+ now every unknown key was dropped in silence. **The roles `defineConfig` rejects unknown keys too** (500
1500
+ `E_AUTHZ_CONFIG`, root and the `openfga`/`scopes`/`relations`/`hooks` blocks — and since `2.5.0-alpha.2` the roles
1501
+ check also runs in the `AuthorizationManager` constructor and in the relations service, so a config exported without
1502
+ `defineConfig` is judged too; a block that is not an object, `scopes: true`, is 500 as well). *Adapt*: fix the typo or
1503
+ remove the key; if you were parking a key for a future version, it does not belong in the spec yet. **The persisted
1504
+ copy is revalidated too**: `saveRelationsConfig` before 2.5.0 stored the spec *as given*, unknown keys included, so a
1505
+ row written then that carries one (or an `includes` cycle, B1) makes `readRelationsConfig()` 422 and
1506
+ `authz:relations:reconcile` stops with its own line — *"la config persistida en authz_relations_config NO valida en
1507
+ 2.5: <motivo>"* — distinct from the unreadable-table line (point 8). *Adapt*: re-save it once from a process with
1508
+ the corrected static config, `saveRelationsConfig(<corrected spec>)` (the spec, not the frozen config: passing the
1509
+ output of `defineRelationsConfig` is 422 with that hint), and run the pass again.
1510
+ 6. **A userset subject that is not `group#member` is 422 `E_AUTHZ_RELATION_USERSET_UNSUPPORTED`** (B3) on `relate`/
1511
+ `unrelate`/`check`/`checkMany`/`listObjects`/`purgeSubject`, in both drivers and the manager, before any backend
1512
+ call. Until now `database` stored such a row inert and `openfga` answered a misclassified 503. *Adapt*: relate the
1513
+ holder or the group, never the relation of another object. **Rows written before 2.5.0 with such a subject are a
1514
+ residue the package lists but never deletes**: `listSubjects` still returns them (output is not validated),
1515
+ `purgeSubject(userset)` no longer reaches them (422), and `purgeObject(<the tuple's own object>)` removes them only
1516
+ together with every legitimate share of that object (`purgeObject` on the userset's object does not touch them).
1517
+ `authz:relations:reconcile` classifies them **in the plan, before any driver call**, on both sides and with or
1518
+ without the persisted config: one enumerated from the *source* counts in `skipped.usersetUnsupported` and is listed
1519
+ in `details` (`side: 'origen'`, `reason: 'userset-unsupported'`); one found in the *destination* counts in `extra`
1520
+ and is listed (`side: 'destino'`, `pruned: false` under `--prune`) — **`--prune` never deletes it** (no platform
1521
+ `DELETE` on a row the API does not admit), the pass writes and prunes everything else, `--dry-run` and the real pass
1522
+ give the same numbers (declared limit: if the persisted config and the destination driver's config differ —
1523
+ `saveRelationsConfig` is manual — the dry-run classifies with the persisted one and the real pass with the driver's,
1524
+ so the numbers can differ until you re-save the config), and the pass exits ≠ 0 with the residue, its side and the recipe on the line until you run
1525
+ it. The **form** of the userset is judged before the pair's F-05, with or without the persisted config: a residue
1526
+ whose userset names a type or a relation the destination does not declare is still this residue (with the recipe),
1527
+ never `skipped.undeclared` with the false remedy "declare it in the destination". **One rule for every destination
1528
+ row the API does not admit**: a tuple whose identity is outside the grammar (`reason: 'invalid-identity'` — an
1529
+ `object_uuid` written by hand in upper case, say) and — since closure 3 of this release — a row whose **relation or
1530
+ object type the destination no longer declares** (`reason: 'undeclared'`: a relation retired from the config while
1531
+ its rows were still there; `enumerateRelations` filters by type, not by relation, and `unrelate` refuses the row by
1532
+ F-05) have exactly the same semantics — `extra`, listed with `pruned: false` under `--prune`, never asked of the
1533
+ driver when the persisted config is at hand (both package drivers would refuse it in `unrelate`: R-16 for the
1534
+ grammar, F-05 for `undeclared`), exit ≠ 0 with or without `--prune`, the remedy on the line ("delete it by hand
1535
+ (SQL/SDK)"; for `undeclared`, "or declare the relation again in the destination and re-save its persisted config"),
1536
+ and never "repeat with `--prune`" (that advice is printed only for what `--prune` really deletes, and only when
1537
+ `--prune` was not passed; the report carries `prune`). Without the persisted config (`reconcileRelations` called
1538
+ with no `toConfig`) the plan cannot anticipate F-05 — `--dry-run` counts that row in `deleted`, the declared limit
1539
+ of L-0 for `written` — and the real pass **catches only the 422s of the relations family** from `unrelate`
1540
+ (`E_AUTHZ_RELATION_TYPE_UNKNOWN`, `E_AUTHZ_RELATION_UNKNOWN`, `E_AUTHZ_RELATION_USERSET_UNSUPPORTED`,
1541
+ `E_AUTHZ_INVALID_IDENTITY`), classifies each by code, goes on with the other rows and lists it the same way; any
1542
+ other error still propagates (that is the driver contradicting the plan for a reason the report cannot count).
1543
+ Until `2.5.0-alpha.2` such a tuple was asked of the driver, a destination residue under `--prune` aborted the pass
1544
+ mid-way (after writing), a source residue was counted as `skipped.undeclared` with a false remedy, and — until
1545
+ closure 3 — a retired relation's row made `--prune` throw `E_AUTHZ_RELATION_UNKNOWN` mid-pass, after writing, on
1546
+ every run (🟠 N9 of the final audit: `--prune` was unusable for that partition). The recipe — one detector and one `DELETE`, the same
1547
+ statements on PostgreSQL, MySQL and SQLite, executed verbatim by the suite on the three (`upgrade_recipe.spec`) and
1548
+ printed on the reconcile line:
1549
+
1550
+ ```sql
1551
+ -- B3 · userset residue (PostgreSQL, MySQL and SQLite: the same two statements)
1552
+ -- 1 · detector: every row whose subject is a userset other than group#member (zero rows = nothing to do)
1553
+ SELECT uuid, partition_key, object_type, object_uuid, relation, subject_type, subject_uuid, subject_relation FROM authz_relations WHERE subject_relation <> '' AND NOT (subject_type = 'group' AND subject_relation = 'member');
1554
+ -- 2 · delete them (holders have subject_relation = ''; a pre-L-4b NULL holder is excluded too: NULL <> '' is not true)
1555
+ DELETE FROM authz_relations WHERE subject_relation <> '' AND NOT (subject_type = 'group' AND subject_relation = 'member');
1556
+ ```
1557
+
1558
+ (on `openfga` such tuples never existed: the server refused them).
1559
+ 7. **`listObjects` of `openfga` signals `truncated: true` on the server's deadline too** (B4): a call that takes ≥ 90 % of
1560
+ `listObjectsDeadlineMs` (default 3,000 ms, OpenFGA's default `--listObjects-deadline`). *Adapt*: set `listObjectsDeadlineMs`
1561
+ to your server's **effective** cut in `OpenFgaRelationsDriver`; code that already handled `truncated` needs nothing else; code
1562
+ that assumed the list complete on a slow server was wrong before and is told now. The value is the server's
1563
+ **effective** cut — whichever is lower of the three: `--listObjects-deadline` (when it is not 0), `--request-timeout`
1564
+ (3 s by default) and, over HTTP, the gateway's `--http-upstream-timeout` (5 s by default); declare that lowest one. A server that runs with **`--listObjects-deadline=0`** does **not** run without a deadline:
1565
+ its `--request-timeout` still cuts `ListObjects` with a mute 200 (measured on OpenFGA v1.19.0 by the audit of this
1566
+ release, 40,000 tuples: `--request-timeout=30ms` ⇒ `200`, 0 objects of 40,000, three runs; control with 30 s ⇒
1567
+ 40,000 of 40,000; in the source, `list_objects.go` sets its own timeout only when the deadline is not 0 and the
1568
+ request-timeout interceptor returns the partial result without a signal) — declare *that* value. The earlier advice
1569
+ of this release (`Infinity` on the driver for a deadline of 0) is withdrawn: it was measured against a
1570
+ whole list that returned in 5 ms, which cannot tell "no deadline" from "deadline = request timeout", and it made
1571
+ the driver pass the mute partial list as complete — strictly worse than the default; `Infinity` is now 500
1572
+ `E_AUTHZ_CONFIG` ("the server always cuts at one of the two; declare the lower one"), like `NaN`, `0` and
1573
+ negatives (`0` on the driver would mean "everything truncated"). Both at 0 is not supported: the HTTP gateway's
1574
+ `--http-upstream-timeout` (5 s) is not measured for `ListObjects`, and the package promises no "no deadline" it
1575
+ has not measured. And the driver's `timeoutMs` (5 s by default) cannot be lower than `listObjectsDeadlineMs`
1576
+ (closure 3, ⚪ N10): a deadline the client cuts before the 0.9 threshold is a signal that can never fire — and if
1577
+ the server cuts earlier, a mute list passes as complete — so `listObjectsDeadlineMs > timeoutMs` is 500
1578
+ `E_AUTHZ_CONFIG` at construction ("the signal has to be able to fire before the client cuts"), which also closes
1579
+ `Number.MAX_SAFE_INTEGER`, "no deadline" under another name. *Adapt*: a driver built with a `timeoutMs` below
1580
+ 3,000 ms must now declare a `listObjectsDeadlineMs` at or below it. And since `2.5.0-alpha.2` a `ListObjects` that answers 200 **without `objects`** is
1581
+ 503 `E_AUTHZ_BACKEND_UNAVAILABLE`, like a `Read` without `tuples` — the same for the three `Read`s of the roles
1582
+ driver (`readAllTuples`, the reconcile dump, `enumerateFacts`), where such a response used to read as an empty
1583
+ enumeration and `purgeScope` "proved" zero on it.
1584
+ 8. **`authz:relations:reconcile` stops (exit 1) when `authz_relations_config` cannot be read** (B5) — before building,
1585
+ freezing or writing anything; "no config persisted" (`null`) still runs and says so. A persisted config that **does
1586
+ not validate in 2.5** (a pre-2.5 row with a key 2.4 dropped in silence, an `includes` cycle, corrupt JSON) is a
1587
+ fourth branch with its own line, *"NO valida en 2.5: <motivo> … saveRelationsConfig(<spec corregida>)"* — never
1588
+ sold as "the read failed" nor as "no config" (point 5). *Adapt*: nothing, unless a cron relied on the silent
1589
+ continuation: give the command a readable database; for the fourth branch, re-save the corrected spec once.
1590
+ 9. **`sqlScopeOutbox()` requires `{ transaction }` by default** (B6): `scopes.attached/moved/detached` (and
1591
+ `outbox.enqueue`) without the open transaction of the outbox's connection are **500 `E_AUTHZ_CONFIG` before the
1592
+ `INSERT`**. *Adapt*: pass `{ transaction: trx }` from `db.transaction()` as the recipe above shows; if you really
1593
+ enqueue outside any unit of work (a seeder, a platform job), say so: `sqlScopeOutbox({ requireTransaction: false })`.
1594
+ `{ transaction }` through the manager needs a pool ≥ 2 (the freeze barrier reads through another connection; with
1595
+ one connection it is 503 `E_AUTHZ_BACKEND_TIMEOUT` at `freezeTimeoutMs`), so with **pool max 1 — knex's default for
1596
+ every SQLite connection, file or `:memory:`** — keep the opt-out; the 500 says so too. A third-party outbox
1597
+ judged by `runScopeOutboxContract` declares the new optional capability `requiresTransaction` (absent = `false`) and,
1598
+ with `true`, hands the judge `transactions.begin()`.
1599
+ 10. **`runRelationsReconcile` is imported from `@jantstack/adonis-authz/relations/reconcile`** (B7); the command module
1600
+ `./commands/authz_relations_reconcile` no longer exports it (nor `RunRelationsReconcileOptions`). *Adapt*: change the
1601
+ import path; the signature is the same.
1602
+ 11. **The roles reconcile verifier reads the pinned model** (B8): with `modelId` set on `OpenFgaAuthorizationDriver`, the
1603
+ pass checks the catalog against *that* model, not the latest one in the store. *Adapt*: nothing; a pass that used to
1604
+ report `permission-not-in-model` for a permission only present in a newer model now projects it under the pinned one
1605
+ — and one that used to die with a 503 mid-pass (permission in the latest model but not in the pinned one) now counts
1606
+ it. The runbook rule stands: publish and roll out the model **before** the window, never inside it.
1607
+
1451
1608
  **Default (read-only): detect, list, count.** Nothing is written. Statement 3 says, per branch, what `--rekey`
1452
1609
  would do: a row *with* a canonical twin is dropped by it, a row *without* one is re-keyed.
1453
1610
 
@@ -1,66 +1,6 @@
1
1
  import { BaseCommand } from '@adonisjs/core/ace';
2
2
  import { CommandOptions } from '@adonisjs/core/types/ace';
3
- import type { RelationsReconcileOptions, RelationsReconcileReport } from '../src/relations/reconcile.js';
4
- import type { AuthorizationManager } from '../src/manager.js';
5
- /**
6
- * **La ÚNICA vía pública para reconciliar relaciones** (L-1 · J1; cierre del
7
- * auditor de alpha.10 · R1/R4). `reconcileRelations` (`src/relations/`) es el
8
- * primitivo: no puede ver al manager de roles (regla 2 de pureza), así que no
9
- * sabe cuál es el driver ACTIVO ni puede tomar la ventana durable. Hasta el
10
- * cierre se exportaba desde la raíz del paquete y el README lo recomendaba para
11
- * K particiones: esa vía llegaba a la dirección rancia sin guard y sin freeze.
12
- * Ahora sale de la raíz y TODA migración de relaciones pasa por aquí —el
13
- * comando, un bucle por particiones de la plataforma—, que es el punto único
14
- * donde viven las dos garantías:
15
- *
16
- * - **la dirección RANCIA** (alpha.10 · S1, gemelo de `manager.reconcile`):
17
- * hacia el driver de relaciones activo (`relations.default ?? default`)
18
- * ningún origen distinto pasa. **El origen y el destino se RESUELVEN aquí,
19
- * por su clave, desde el config del manager** (R4): hasta el cierre la
20
- * función recibía las instancias `from`/`to` y, aparte, las ETIQUETAS
21
- * `toKey`/`fromKey`/`activeKey`, y juzgaba las etiquetas — un bucle con
22
- * `fromKey === toKey` copiado y pegado, o un `activeKey` que contradecía al
23
- * manager, escribía la compartición retirada en el activo. La identidad es
24
- * la de la factory resuelta: dos claves con la MISMA factory son el mismo
25
- * driver (un alias del activo también se niega);
26
- * - **la ventana durable**: `--dry-run` **NO congela** (el verificador es
27
- * read-only y está hecho para un cron); la pasada que ESCRIBE corre bajo
28
- * `withFrozenWrites` con `kind: 'reconcile'` y la ventana del OPERADOR como
29
- * contexto propio; un freeze vivo de OTRA pasada es 423; el reporte publica
30
- * `frozen` —fence, lease, `window` y **`lapsed`**— y un lease perdido a
31
- * mitad significa que la pasada NO se certifica (exit ≠ 0).
32
- *
33
- * Límite declarado (auditor · R5): el activo es el que dice el config del
34
- * PROCESO que ejecuta esto; un Job con otro entorno apaga el guard. El sello
35
- * durable del driver activo está en el roadmap (2.5).
36
- */
37
- /**
38
- * **Las opciones PÚBLICAS de `runRelationsReconcile`** (cierre-2 de alpha.10 · C7):
39
- * las del primitivo sin `from`/`to` (instancias: retiradas) más las claves. Hasta el
40
- * cierre-2 la raíz exportaba `RelationsReconcileOptions` —la forma del primitivo, con
41
- * `from`/`to` obligatorios— y no esta.
42
- */
43
- export type RunRelationsReconcileOptions = Omit<RelationsReconcileOptions, 'from' | 'to'> & {
44
- /** Clave (en `relations.drivers` del config del manager) del DESTINO. */
45
- toKey: string;
46
- /** Clave del ORIGEN (el de `--from` o el elegido por descarte). */
47
- fromKey: string;
48
- /** La salida humana de `E_AUTHZ_STALE_SOURCE_REFUSED`. */
49
- allowStaleSource?: boolean;
50
- };
51
- /**
52
- * Límites DECLARADOS de la identidad (cierre-2 de alpha.10 · C1 y C6):
53
- * - la identidad es la REFERENCIA de la factory: dos factories DISTINTAS que
54
- * construyen el mismo backend (`fga: () => new OpenFgaRelationsDriver(…mismo
55
- * store…)`) NO se detectan — el alias pasa el guard. No se construye nada antes
56
- * del guard, así que no hay backend que comparar; identidad declarada por el
57
- * driver ⇒ roadmap 2.5. Registra un alias con la MISMA referencia
58
- * (`const fga = () => …; drivers: { openfga: fga, alias: fga }`) o no lo registres.
59
- * - `authorization` es estructural (`Pick<…>`): un objeto que FALSEA
60
- * `relationsDriverKey`/`relationsDriverFactory` decide el activo y las factories.
61
- * Pásale el manager del servicio, nunca uno construido o envuelto a mano.
62
- */
63
- export declare function runRelationsReconcile(authorization: Pick<AuthorizationManager, 'withFrozenWrites' | 'relationsDriverKey' | 'relationsDriverFactory'>, options: RunRelationsReconcileOptions): Promise<RelationsReconcileReport>;
3
+ import type { RelationsReconcileReport } from '../src/relations/reconcile.js';
64
4
  /**
65
5
  * Cómo se nombra la ventana en la que corrió la pasada (2.4.0-alpha.10 · 3):
66
6
  * la del OPERADOR dice si caduca y cuándo — hasta alpha.9 una ventana abierta
@@ -87,7 +27,12 @@ export declare function frozenWindowLabel(frozen: {
87
27
  *
88
28
  * `extra` en una pasada que SÍ escribe (sin `--prune`) se AVISA —«repite con
89
29
  * --prune»— pero no tumba: el operador eligió no podar. Es la misma cortesía
90
- * que `authz:reconcile` de roles con la ventana del relay.
30
+ * que `authz:reconcile` de roles con la ventana del relay. **Salvo el residuo
31
+ * del destino que nadie borra** (`invalid-identity`, `userset-unsupported` y,
32
+ * desde el cierre 3 · 🟠 N9, `undeclared` — la fila cuya relación o tipo el
33
+ * destino ya no declara; cierre 2 del lote B · 🟡 N3): ése es deriva con o sin
34
+ * `--prune`, y el consejo solo cuenta lo que `--prune` borraría de verdad y
35
+ * solo si no se pasó.
91
36
  */
92
37
  export declare function relationsReconcileLines(report: RelationsReconcileReport): {
93
38
  lines: Array<{
@@ -1 +1 @@
1
- {"version":3,"file":"authz_relations_reconcile.d.ts","sourceRoot":"","sources":["../../commands/authz_relations_reconcile.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAS,MAAM,oBAAoB,CAAA;AACvD,OAAO,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAA;AACzD,OAAO,KAAK,EAAE,yBAAyB,EAAE,wBAAwB,EAAE,MAAM,+BAA+B,CAAA;AACxG,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,mBAAmB,CAAA;AAG7D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH;;;;;GAKG;AACH,MAAM,MAAM,4BAA4B,GAAG,IAAI,CAAC,yBAAyB,EAAE,MAAM,GAAG,IAAI,CAAC,GAAG;IAC1F,yEAAyE;IACzE,KAAK,EAAE,MAAM,CAAA;IACb,mEAAmE;IACnE,OAAO,EAAE,MAAM,CAAA;IACf,0DAA0D;IAC1D,gBAAgB,CAAC,EAAE,OAAO,CAAA;CAC3B,CAAA;AAED;;;;;;;;;;;GAWG;AACH,wBAAsB,qBAAqB,CACzC,aAAa,EAAE,IAAI,CAAC,oBAAoB,EAAE,kBAAkB,GAAG,oBAAoB,GAAG,wBAAwB,CAAC,EAC/G,OAAO,EAAE,4BAA4B,GACpC,OAAO,CAAC,wBAAwB,CAAC,CAkFnC;AAED;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE;IACxC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAA;IACtB,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACvB,MAAM,CAAC,EAAE,UAAU,GAAG,KAAK,GAAG,WAAW,CAAA;CAC1C,GAAG,MAAM,CAaT;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,uBAAuB,CAAC,MAAM,EAAE,wBAAwB,GAAG;IACzE,KAAK,EAAE,KAAK,CAAC;QAAE,KAAK,EAAE,KAAK,GAAG,SAAS,GAAG,OAAO,GAAG,SAAS,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC,CAAA;IACjF,KAAK,EAAE,OAAO,CAAA;CACf,CA8KA;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AACH,MAAM,CAAC,OAAO,OAAO,uBAAwB,SAAQ,WAAW;IAC9D,MAAM,CAAC,WAAW,SAA8B;IAChD,MAAM,CAAC,WAAW,SAAgG;IAElH,MAAM,CAAC,OAAO,EAAE,cAAc,CAE7B;IAGO,EAAE,EAAE,MAAM,GAAG,SAAS,CAAA;IAGtB,IAAI,EAAE,MAAM,GAAG,SAAS,CAAA;IAGxB,MAAM,EAAE,OAAO,GAAG,SAAS,CAAA;IAG3B,KAAK,EAAE,OAAO,GAAG,SAAS,CAAA;IAK1B,eAAe,EAAE,OAAO,GAAG,SAAS,CAAA;IAMpC,gBAAgB,EAAE,OAAO,GAAG,SAAS,CAAA;IAGrC,aAAa,EAAE,MAAM,GAAG,SAAS,CAAA;IAGjC,aAAa,EAAE,MAAM,GAAG,SAAS,CAAA;IAEnC,GAAG;CAyJV"}
1
+ {"version":3,"file":"authz_relations_reconcile.d.ts","sourceRoot":"","sources":["../../commands/authz_relations_reconcile.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAS,MAAM,oBAAoB,CAAA;AACvD,OAAO,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAA;AACzD,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,+BAA+B,CAAA;AAO7E;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE;IACxC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAA;IACtB,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACvB,MAAM,CAAC,EAAE,UAAU,GAAG,KAAK,GAAG,WAAW,CAAA;CAC1C,GAAG,MAAM,CAaT;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,uBAAuB,CAAC,MAAM,EAAE,wBAAwB,GAAG;IACzE,KAAK,EAAE,KAAK,CAAC;QAAE,KAAK,EAAE,KAAK,GAAG,SAAS,GAAG,OAAO,GAAG,SAAS,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC,CAAA;IACjF,KAAK,EAAE,OAAO,CAAA;CACf,CAmMA;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AACH,MAAM,CAAC,OAAO,OAAO,uBAAwB,SAAQ,WAAW;IAC9D,MAAM,CAAC,WAAW,SAA8B;IAChD,MAAM,CAAC,WAAW,SAAgG;IAElH,MAAM,CAAC,OAAO,EAAE,cAAc,CAE7B;IAGO,EAAE,EAAE,MAAM,GAAG,SAAS,CAAA;IAGtB,IAAI,EAAE,MAAM,GAAG,SAAS,CAAA;IAGxB,MAAM,EAAE,OAAO,GAAG,SAAS,CAAA;IAG3B,KAAK,EAAE,OAAO,GAAG,SAAS,CAAA;IAK1B,eAAe,EAAE,OAAO,GAAG,SAAS,CAAA;IAMpC,gBAAgB,EAAE,OAAO,GAAG,SAAS,CAAA;IAGrC,aAAa,EAAE,MAAM,GAAG,SAAS,CAAA;IAGjC,aAAa,EAAE,MAAM,GAAG,SAAS,CAAA;IAEnC,GAAG;CAoMV"}