@jantstack/adonis-authz 2.5.0-rc.1 → 2.5.0-rc.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.
package/README.md CHANGED
@@ -1107,13 +1107,13 @@ alias `fga: () => new OpenFgaRelationsDriver(…)` with the same `OPENFGA_STORE_
1107
1107
  written in a config — are **not** detected: `--to=fga --from=database` passes the guard and resurrects what was revoked.
1108
1108
  Nothing is built before the guard, so there is no backend to compare. Register an alias with the *same* reference
1109
1109
  (`const fga = () => …` and `{ openfga: fga, alias: fga }`) or do not register it; a backend identity declared by the
1110
- driver is on the 2.5 list. Until the closing lot of 2.4.0-alpha.10 the primitive
1110
+ driver is on the 2.6 list. Until the closing lot of 2.4.0-alpha.10 the primitive
1111
1111
  `reconcileRelations` was exported from the package root and this paragraph recommended it for K partitions: it
1112
1112
  never saw the active driver nor took the window, so a loop written after the cutover resurrected revoked shares
1113
1113
  with the fleet still writing. It is no longer public. **The guard reads the config of the process that runs it**:
1114
1114
  `relations.default ?? config.default` *there* — a job or pod started with another environment (say, without
1115
1115
  `AUTHZ_DRIVER`) believes another driver is active and the guard is off. Run it with the fleet's config; a durable
1116
- seal of the active driver is on the 2.5 list. **The pass that writes runs under the durable freeze** (2.4.0-alpha.2,
1116
+ seal of the active driver is on the 2.6 list. **The pass that writes runs under the durable freeze** (2.4.0-alpha.2,
1117
1117
  lot L-1), exactly like `authz:reconcile`: every write of the package — roles *and* relations — answers a
1118
1118
  retryable 503 `E_AUTHZ_FROZEN` fleet-wide while it runs; a live **operator** window (`authz:freeze`) is its
1119
1119
  own context (it runs inside, does not renew or lift it); a live freeze of another pass is 423; and the
@@ -1425,8 +1425,8 @@ runAuthorizationDriverContract({
1425
1425
  countRoleAssignments: true, // the port's optional countRoleAssignments (2.3); false ⇒ pruneOrphanRoles reports `undefined`, never `false`
1426
1426
  canonicalScopeReads: true, // reads canonicalise the caller's scope spelling against your tree (2.3); false ⇒ an alias finds no facts (fail-closed)
1427
1427
  enumerateFacts: false, // the port's optional enumerateFacts (2.3): can this driver be the SOURCE of authz:reconcile?
1428
- experimental: false, // optional (2.5.0-alpha.4): true ⇒ the driver declares itself outside the release's guarantee (the suite still runs whole)
1429
- snapshotDecisions: false, // optional (rotation 2 closure 3): true ⇒ the port's readSnapshot reads grants and denies from ONE state; judged at '2.1' and above
1428
+ experimental: false, // optional (2.5.0-alpha.4): true ⇒ the driver declares itself outside the release's guarantee (the suite still runs whole); omitted ⇒ your driver's declaration decides the face
1429
+ snapshotDecisions: false, // optional (rotation 2 closure 3): true ⇒ the port's readSnapshot reads grants and denies from ONE state; judged at '2.1' and above; omitted ⇒ your driver's declaration decides the face
1430
1430
  },
1431
1431
  // The suite builds the scope tree case by case; hand it to your driver.
1432
1432
  makeDriver: (tree) => new MyDriver({ resolveChain: resolveChainFrom(tree) }), // tree.chainOf(scope) = the canonical chain
@@ -1441,9 +1441,9 @@ runAuthorizationDriverContract({
1441
1441
  })
1442
1442
  ```
1443
1443
 
1444
- Declaring a capability `true` that the suite has no case for makes registration throw — a promise without a judge does not pass. The port also has optional methods a driver may implement to do better than the manager's composition — `onScopeAttached/Moved/Detached` (tree as facts) and, since 2.1, `withChainResolver(resolver)` (a view of the driver bound to another resolver, what `forRequest()` uses to memoise reads), `listDenies(subject, scope?)` (direct denies; what `effectivePermissions` and `authorizedScopes` subtract), `authorizeMany(subject, permission, scopes)` (one round-trip for N decisions; its `boolean[]` is validated) and `rolesInChain(subject, chain)` (the holder's direct roles along a resolved chain in one read; what `effectivePermissions` uses); a driver without them keeps passing the same suite at `'2.0'`. At `'2.1'`, **`listDenies` is a capability pair**: `listDenies: true` judges `listDenies`, `effectivePermissions`, `authorizedScopes` and the shared catalog version through them; `listDenies: false` judges instead that those primitives *say so* — 500 `E_AUTHZ_UNSUPPORTED` naming the method, never a simulated `[]` (the package runs that face itself, over a `database` view without the method: *"sin listDenies en el puerto: … 500 E_AUTHZ_UNSUPPORTED"*). Declaring `listDenies: true` below `'2.1'` throws: nothing observes it there. `exhaustiveLists: false` asks for the backend's cap and proves only the exact boundary.
1444
+ Declaring a capability `true` that the suite has no case for makes registration throw — a promise without a judge does not pass. An **optional** capability (`?:`) you leave out is judged by your driver's own normalised declaration (one case runs that face, and a `true` without a case at your level is red); one you declare decides the face, and contradicting the driver is red. The port also has optional methods a driver may implement to do better than the manager's composition — `onScopeAttached/Moved/Detached` (tree as facts) and, since 2.1, `withChainResolver(resolver)` (a view of the driver bound to another resolver, what `forRequest()` uses to memoise reads), `listDenies(subject, scope?)` (direct denies; what `effectivePermissions` and `authorizedScopes` subtract), `authorizeMany(subject, permission, scopes)` (one round-trip for N decisions; its `boolean[]` is validated) and `rolesInChain(subject, chain)` (the holder's direct roles along a resolved chain in one read; what `effectivePermissions` uses); a driver without them keeps passing the same suite at `'2.0'`. At `'2.1'`, **`listDenies` is a capability pair**: `listDenies: true` judges `listDenies`, `effectivePermissions`, `authorizedScopes` and the shared catalog version through them; `listDenies: false` judges instead that those primitives *say so* — 500 `E_AUTHZ_UNSUPPORTED` naming the method, never a simulated `[]` (the package runs that face itself, over a `database` view without the method: *"sin listDenies en el puerto: … 500 E_AUTHZ_UNSUPPORTED"*). Declaring `listDenies: true` below `'2.1'` throws: nothing observes it there. `exhaustiveLists: false` asks for the backend's cap and proves only the exact boundary.
1445
1445
 
1446
- Since the rotation 2 closure the port also has **`readSnapshot(subject, reads)`** (optional, with the capability **`snapshotDecisions`**, normalised to `false`): several port reads answered from **one state** of your facts — `reads` names any of `rolesInChain` (a resolved chain), `listScopes` (a permission) and `listDenies: true`, and each key comes back exactly as the method of the same name would answer it; what was not asked does not come back, and a malformed `reads` is 422 `E_AUTHZ_INVALID_IDENTITY`. The manager uses it for the decisions it *composes* from a grant read and a deny read — `effectivePermissions`, `authorizedScopes` and the delegation policy of `defineScopedRole`/`updateScopedRole` —, because two reads with no common snapshot can answer what no real state answers (a user offboarded with a `revoke` and then a `removeDeny` between the two reads came out of `effectivePermissions` with the permission the deny was there to remove). A driver without it keeps working exactly as before: the manager composes the two reads, and that gap is yours, declared in the [torn read](#limits) row. At `'2.1'` the pair judges either face: `true` — the method answers like its namesakes and the manager calls it **once** and never `rolesInChain`/`listScopes`/`listDenies` (*"snapshotDecisions:true · …"*; the judge counts only the manager's calls, so a `readSnapshot` that calls your own methods inside — a `REPEATABLE READ` transaction, say — passes); `false` — the manager never calls `readSnapshot`, even if your driver has a method with that name (the capability decides), and composes subtracting the denies (*"snapshotDecisions:false · …"*; without `listDenies` there is nothing composed to judge and that face only checks the declaration, as its title says). The delegation-policy part of each face (a `defineScopedRole` that must answer 422 because the actor has the permission denied) runs only when the harness declares `purgeRole: true` — a `'2.2'` pair: without `purgeRole` the manager does not delegate at all (500 before the policy) —, so a `'2.1'` driver with `listDenies` and without `purgeRole` passes the whole judge, and the title of each face says which variant was registered. The package runs the `false` face itself over a `database` view without the method (*"database (sin readSnapshot)"*), and both faces at `'2.1'` over `database` views without `purgeRole` (that one and *"database (2.1 sin purgeRole)"*). Whether your snapshot is really one is not something a generic judge can see from outside your backend: `database` pins it with forced interleavings and the surface guard `database.decision.snapshot`.
1446
+ Since the rotation 2 closure the port also has **`readSnapshot(subject, reads)`** (optional, with the capability **`snapshotDecisions`**, normalised to `false`): several port reads answered from **one state** of your facts — `reads` names any of `rolesInChain` (a resolved chain), `listScopes` (a permission) and `listDenies: true`, and each key comes back exactly as the method of the same name would answer it; what was not asked does not come back, and a malformed `reads` is 422 `E_AUTHZ_INVALID_IDENTITY`. The manager uses it for the decisions it *composes* from a grant read and a deny read — `effectivePermissions`, `authorizedScopes` and the delegation policy of `defineScopedRole`/`updateScopedRole` —, because two reads with no common snapshot can answer what no real state answers (a user offboarded with a `revoke` and then a `removeDeny` between the two reads came out of `effectivePermissions` with the permission the deny was there to remove). A driver without it keeps working exactly as before: the manager composes the two reads, and that gap is yours, declared in the [torn read](#limits) row. That holds for a driver that does not declare the capability — not for an object that wraps or subclasses `DatabaseAuthorizationDriver` and so copies or inherits its `true`: see point 13 of [Upgrading to 2.5.0](#upgrading-to-250-non-canonical-partition-spellings). At `'2.1'` the pair judges either face: `true` — the method answers like its namesakes and the manager calls it **once** and never `rolesInChain`/`listScopes`/`listDenies` (*"snapshotDecisions:true · …"*; the judge counts only the manager's calls, so a `readSnapshot` that calls your own methods inside — a `REPEATABLE READ` transaction, say — passes); `false` — the manager never calls `readSnapshot`, even if your driver has a method with that name (the capability decides), and composes subtracting the denies (*"snapshotDecisions:false · …"*; without `listDenies` there is nothing composed to judge and that face only checks the declaration, as its title says). The delegation-policy part of each face (a `defineScopedRole` that must answer 422 because the actor has the permission denied) runs only when the harness declares `purgeRole: true` — a `'2.2'` pair: without `purgeRole` the manager does not delegate at all (500 before the policy) —, so a `'2.1'` driver with `listDenies` and without `purgeRole` passes the whole judge, and the title of each face says which variant was registered. The package runs the `false` face itself over a `database` view without the method (*"database (sin readSnapshot)"*), and both faces at `'2.1'` over `database` views without `purgeRole` (that one and *"database (2.1 sin purgeRole)"*). Whether your snapshot is really one is not something a generic judge can see from outside your backend: `database` pins it with forced interleavings and the surface guard `database.decision.snapshot`.
1447
1447
 
1448
1448
  Since 2.2 the port has **`purgeRole(roleUuid)`** (optional): revoke every assignment of the role in every scope, delete its links and the role row, atomically, bumping the shared catalog version (`withAuthzCatalogWrite`); a malformed uuid is 422 `E_AUTHZ_INVALID_IDENTITY`, an unknown one 422 `E_AUTHZ_UNKNOWN_ROLE`. **The atomicity of the link deletion is guaranteed by the SCHEMA, not by the driver code**: `authz_role_permissions.role_uuid` is `ON DELETE CASCADE` and `authz_assignments.role_uuid` is `ON DELETE RESTRICT` in the published migration (and in the test mirror, compared action by action by the stub-vs-mirror guard). The judge counts the links after the purge, but on this schema a driver that "forgot" to delete them would pass anyway — the engine deletes them: measured, and it is an equivalent mutant in SQLite, PostgreSQL and MySQL (tester 3E · R7). **If you run your own schema without those actions, or with foreign keys disabled, deleting the links is your driver's job and no test of this package will catch you.** It does not distinguish global from local (that barrier is the manager's). A driver that cannot purge declares **`purgeRole: false`** and must throw 500 `E_AUTHZ_UNSUPPORTED` without touching anything — the pair at `'2.2'` judges either the purge (*"purgeRole(uuid) revoca todas las asignaciones del rol en TODOS los scopes"*) or the refusal (*"sin purgeRole de verdad: el driver lo dice con 500"*); `true` below `'2.2'` throws. The `'2.2'` cases also judge local roles on the driver itself — visibility by owner in every read and write (*"un rol local de la organization A concede en A y sus units"*, *"dos tenants definen el mismo slug"*, the reserved `global` key, deny × local role, `assignableAt` not evaluated) — using roles written straight into `authz_*` as another process would, and, under `listDenies: true`, the delegation API through a manager over your driver (*"defineScopedRole: el rol que el administrador de A delega concede en A y sus descendientes"*); under `listDenies: false`, that `defineScopedRole` says so with 500. A driver reads a role's owner from the catalog memo (`CatalogView.roleVisible(slug, scopeType, chainKeys)` — which **throws** 422 `E_AUTHZ_AMBIGUOUS_ROLE` when more than one role is visible: never resolve the ambiguity yourself —, `rolesNamed`, `roleByUuid(...).owner`, `isRoleVisibleWith`, and the shared `declaredRoleAt(catalog, uuid, scopeType, chainKeys)`) and applies the rule per level of the chain: an assignment in scope S counts only if the role is global or its owner is in chain(S).
1449
1449
 
@@ -1579,7 +1579,7 @@ This is the **only** migration and verification primitive of the package, and th
1579
1579
  **Whoever owns the facts is where they are read from (2.3, and it is the whole safety of this command).** `--to=openfga` used to read `authz_assignments` / `authz_denies` *always*. In a deployment that has already cut over to `facts` those tables are **not** the source of truth of the facts — the store is, and nothing keeps them in sync after the cutover — so a pass that rebuilt from them re-wrote grants you had revoked (no flag needed) and, with `--prune`, deleted the denies that only lived in the store. So the pass now asks first, and **says the answer in its first line** (`report.factsFrom`):
1580
1580
 
1581
1581
  - **`--to` is the *active* driver and its facts live in its own backend** (`capabilities.hierarchyFacts`, which is what `openfga` declares): then its facts are its own and it is read from *itself*. This is the **maintenance pass**: it rebuilds everything **derived** — root marker, catalog projection, tree — and applies the visibility sweep of invariant 18 with the tree and the catalog of *today*, and **it writes no fact; with `--prune` it deletes the facts of scopes that no longer resolve**, the expired ones and those of roles or permissions the catalog or the model no longer has (and the leftovers) — without `--prune` it deletes no assignment or deny (only the role-visibility edges invariant 18 forbids) and reports them as `extra-fact`. (Until 2.4.0-alpha.9 this line, and the command's first line, claimed the pass deleted nothing while `--prune` deleted them — measured by a consumer.) It is also what makes `--dry-run` usable as the CI verifier of a `facts` deployment: a correct store now comes out **clean** instead of reporting every live fact as `extra-fact`. (A driver in that position that cannot enumerate its own facts is 500 `E_AUTHZ_UNSUPPORTED` naming `enumerateFacts` — reading `authz_*` for it would be the bug.)
1582
- - **`--from=<driver>` is given**: you decide. If that driver's facts are `authz_*` (the package's `database`), `--to=openfga` is the **one-way migration** it always was, and it will happily overwrite the destination's facts with what those tables hold. That is what you want while migrating — and it is a loaded gun pointed at a store that is already live, which is why it now takes an explicit `--from` to fire it. **Except towards the active driver (2.4.0-alpha.10, breaking):** if `--to` is `config.default`, the only legitimate source is `--to` itself (the maintenance pass above); any other **resolved** source — named with `--from`, picked by elimination without `--from` (with `database` active, `--to=database` alone picks `openfga`), or the `authz_*` tables — makes the manager refuse with 500 `E_AUTHZ_STALE_SOURCE_REFUSED` before resolving the source, reading or freezing anything. That is the **stale direction**: once the engine serves from `--to`, its facts are written there and the source stopped seeing them, so the pass would re-write what was revoked since the cutover (with or without `--prune`; and the mass-deletion guard does not fire, because the source is stale, not empty). The guard looks at `--to` and at the source it resolves, never at the flag — and at the *resolved* driver, not the key: the same factory registered under two keys is the same driver, so an alias of `config.default` is refused too (**declared limit**: identity is the factory *reference*, so two different factories that build the same backend are not detected and such an alias passes; register an alias with the same reference, and a backend identity declared by the driver is on the 2.5 list) — so the four rows are: maintenance pass — runs; initial cutover (`--to=<new>` while the engine still serves from the old one) — runs; going back **before** switching `default` — runs; re-running either of the two **after** switching `default` — refused. `authz:relations:reconcile` applies the same rule with the active relations driver (below). `--allow-stale-source` (`allowStaleSource: true`) is the human decision; `--dry-run` never throws, it flags `report.staleSource = { to, from, allowed }`, prints a warning and exits non-zero. **The guard is only as reliable as the `config.default` of the process that runs the command**: it has no durable record of what the fleet serves from, so a migration job or pod started with a different environment (`default: env.get('AUTHZ_DRIVER', 'database')` without that variable) believes another driver is active and the guard is silently off. Run it with the fleet's config; a durable seal of the active driver is on the 2.5 list.
1582
+ - **`--from=<driver>` is given**: you decide. If that driver's facts are `authz_*` (the package's `database`), `--to=openfga` is the **one-way migration** it always was, and it will happily overwrite the destination's facts with what those tables hold. That is what you want while migrating — and it is a loaded gun pointed at a store that is already live, which is why it now takes an explicit `--from` to fire it. **Except towards the active driver (2.4.0-alpha.10, breaking):** if `--to` is `config.default`, the only legitimate source is `--to` itself (the maintenance pass above); any other **resolved** source — named with `--from`, picked by elimination without `--from` (with `database` active, `--to=database` alone picks `openfga`), or the `authz_*` tables — makes the manager refuse with 500 `E_AUTHZ_STALE_SOURCE_REFUSED` before resolving the source, reading or freezing anything. That is the **stale direction**: once the engine serves from `--to`, its facts are written there and the source stopped seeing them, so the pass would re-write what was revoked since the cutover (with or without `--prune`; and the mass-deletion guard does not fire, because the source is stale, not empty). The guard looks at `--to` and at the source it resolves, never at the flag — and at the *resolved* driver, not the key: the same factory registered under two keys is the same driver, so an alias of `config.default` is refused too (**declared limit**: identity is the factory *reference*, so two different factories that build the same backend are not detected and such an alias passes; register an alias with the same reference, and a backend identity declared by the driver is on the 2.6 list) — so the four rows are: maintenance pass — runs; initial cutover (`--to=<new>` while the engine still serves from the old one) — runs; going back **before** switching `default` — runs; re-running either of the two **after** switching `default` — refused. `authz:relations:reconcile` applies the same rule with the active relations driver (below). `--allow-stale-source` (`allowStaleSource: true`) is the human decision; `--dry-run` never throws, it flags `report.staleSource = { to, from, allowed }`, prints a warning and exits non-zero. **The guard is only as reliable as the `config.default` of the process that runs the command**: it has no durable record of what the fleet serves from, so a migration job or pod started with a different environment (`default: env.get('AUTHZ_DRIVER', 'database')` without that variable) believes another driver is active and the guard is silently off. Run it with the fleet's config; a durable seal of the active driver is on the 2.6 list.
1583
1583
  - **Otherwise** (the destination is not the active driver): the migration of always, facts read from `authz_*`.
1584
1584
 
1585
1585
  **It needs `scopes.enumerateEdges`** in your config: the whole tree, paginated with a cursor. `sqlScopeEdges({ table, uuidColumn, parentColumn, typeColumn })` implements it over a table with a parent column, exactly like `sqlDescendantsOf`. Without it the command refuses (500 `E_AUTHZ_CONFIG`) instead of assuming a flat tree — a flat tree would be an invented hierarchy, and an invented hierarchy grants.
@@ -1835,6 +1835,8 @@ Every bound the package enforces or measured, in one place, each with the consta
1835
1835
  | **The third mass-purge trigger does not cover a single victim (RC2-C)** — declared yellow of shared code (rotation 2 closure 2, closes in 2.6) | `allowMassPurge` (`--allow-mass-purge`) | deterministic: a resolver with tenant A's context leaked and ONE other owner with local roles that still grant ⇒ no trigger fires | the manager, `pruneOrphanRoles({ force: true })` / `authz:catalog:prune-orphans --force` | the third trigger asks for orphans of **more than one** owner (owner's decision): with a leaked tenant context and one victim, nothing distinguishes it from a tenant that left, `--force` purges that owner's roles and its users lose live permissions. **Recipe**: read the `--dry-run` before every `--force` (it lists those roles apart, `stillGranting: true`), and run the pass with a resolver that has no tenant context (a platform job, not a request). **Detector**: the dry run's `stillGranting: true` rows; `role_purged` events of an owner your tree still has. **Closes in 2.6** (or earlier if a consumer runs platform jobs with tenant context: question open). Case: «límite declarado rotación 2 · RC2-C una sola víctima: el resolutor con el contexto de A colado y UN owner ajeno ⇒ ningún disparador y --force purga un rol que concede; se voltea si se cierra». |
1836
1836
  | **Without `within`, a delegation 422 can name another tenant's role (RC2R-1/RC2R-2, lineage L13)** — declared yellow of shared code (rotation 2 re-attack, closes in 2.6) | `requireWithin` | deterministic: `updateScopedRole`/`deleteScopedRole(adminOfA, <uuid of a role of B>)` with no `within` | the manager, delegation API | `E_AUTHZ_ROLE_LEVEL_ABOVE_OWNER` names B's owner and `E_AUTHZ_RANK_EXCEEDED` B's slug and rank; set `requireWithin` (multi-tenant needs it); 2.6 routes every tenant-facing message through one sanitiser |
1837
1837
  | **Any tenant's catalog write makes every process reload the WHOLE catalog (R2-06)** — declared yellow of shared code (rotation 2, closes in 2.6) | — | not measured | the catalog memo (`CatalogCache`: `authz_permissions`, `authz_roles`, `authz_role_permissions` read entire on a version change) | a tenant admin looping `updateScopedRole` (renaming is a real change ⇒ a version bump) makes the next question of every process, for every tenant, reload the full catalog — with a large local catalog, latency of `authorize` for everyone. **Recipe**: rate-limit the delegation API per owner in your app; `catalogRevalidate: { everyMs }` caps reloads to one per window per process, at the price of that window. **Detector**: the rate of `onCatalogWrite` events per owner (or of `authz_catalog_version` increments). **Closes in 2.6**: incremental reload by version, or a per-owner rate in the package. Case: «límite declarado rotación 2 · R2-06 recarga entera: una escritura de catálogo de un tenant recarga el catálogo de todos; se voltea en 2.6». |
1838
+ | **A `+infinity` (PostgreSQL) or a text (SQLite) in `expires_at` grants in the decision (C1)** — declared yellow of shared code (rotation 3 re-attack 2, closes in 2.6) | — | deterministic, for a row whose `expires_at` the package did not write (a seed, an import, a hand edit) | `database` roles and relations: the SQL decision `expires_at > now` | the decision does not go through the expiry codec. MySQL's zero date and PostgreSQL's `-infinity` do not grant; but SQLite compares `TEXT > INTEGER` as true and PostgreSQL's `'infinity' > now()` is true, so such a row **grants for ever** — even a past ISO date written as text — while the readers (a re-grant, `reconcile`, the relations export) answer 500 `E_AUTHZ_INTERNAL` or, for the SQLite text, read it as the past date it spells. The package never writes these values. **Recipe**: never write `expires_at` by hand; find them — SQLite `SELECT uuid FROM authz_assignments WHERE typeof(expires_at) NOT IN ('integer', 'real', 'null')`, PostgreSQL `… WHERE expires_at = 'infinity'` (the same on `authz_relations`) — and `revoke` + `grant` each holder with the right `expiresAt`. **Detector**: those queries on a cron; a 500 naming `expires_at ilegible`. **Closes in 2.6**: the decision filters by type (SQLite) and finiteness (PostgreSQL), or a `CHECK`. Case: «límite declarado rotación 3 · C1: una caducidad PASADA en texto (SQLite) o +infinity (PostgreSQL) concede en la decisión de SQL; se voltea en 2.6». |
1839
+ | **`authz:reconcile --to=database` no longer repairs a row with an unreadable expiry (E1)** — declared, operability, fail-closed (rotation 3 re-attack 2) | — | deterministic | `database` as the destination of `authz:reconcile` (and `authz:relations:reconcile`) | since RR-2 a present `expires_at` that is not an instant is 500 `E_AUTHZ_INTERNAL` where it is read, and the destination is read whole before the first write: a pass whose destination holds such a row stops, exit 1, with nothing written. Until then it compared «no expiry» with the source and rewrote the row with the source's expiry — repairing it by accident. **Recipe**: find the row (MySQL `SELECT uuid FROM authz_assignments WHERE expires_at IS NOT NULL AND CONVERT_TZ(expires_at, '+00:00', '+00:00') IS NULL`; SQLite/PostgreSQL as in the row above), `revoke` + `grant` its holder with the right `expiresAt`, then run the pass again. **Detector**: the 500 itself. Not planned to change: an unreadable expiry is a fact only the operator can correct, and silently overwriting it is what RR-2 removed. Case: «RR-2 · … el re-grant es 500 sin tocar la fila» (`tests/expiry_timezone.spec.ts`: the same read; `revoke` + `grant` repairs). |
1838
1840
  | Ancestors `hierarchicalScopeResolver` walks | `maxDepth` (resolver option) | 64 in the README example | your `resolveChain` | 500 `E_AUTHZ_SCOPE_TOO_DEEP`, never a truncated chain |
1839
1841
 
1840
1842
  **The rows marked *declared limit* are the open limits of the experimental `openfga` driver (2.5.x).** Each one has an
@@ -1857,7 +1859,7 @@ The published migration (`stubs/migration.stub`) carries three decisions that we
1857
1859
 
1858
1860
  Also on MySQL: `sqlDescendantsOf` quotes identifiers with backticks and sends `/*+ SET_VAR(cte_max_recursion_depth = …) */` with each walk — MySQL aborts a recursive CTE after 1000 iterations (`cte_max_recursion_depth`, error 3636), which turned a cycle under a bound above 1000 (the manager's default is 10 000) into a 503 instead of the contract's 422 "posible ciclo". The bound is the same one the query already imposes with `depth < maxNodes + 1`; nothing from your input reaches the hint, and `maxScopes`/`maxDescendants` are capped at 10 000 000 (`MAX_SCOPE_BOUND`; above it the hint leaves MySQL's range and the 422 degrades to a 503 — 500 `E_AUTHZ_CONFIG` instead).
1859
1861
 
1860
- **Expiry is an instant, and the package stores it as UTC itself.** On MySQL `expires_at` is `DATETIME(3)`, which has no time zone, and `mysql2` serialises and parses `Date` values with the **process's** `TZ` (`timezone: 'local'`, its default): a process in UTC wrote `12:00:00` for `12:00Z` and a process in Caracas read it as `16:00Z` — the assignment expired four hours late for it (and nine hours early for one in Tokyo). The `database` driver does not depend on your connection options: on MySQL it writes `expires_at` as an explicit UTC string (`YYYY-MM-DD HH:mm:ss.SSS`), compares with `now` formatted the same way and reads it back through `DATE_FORMAT` (a string, parsed as UTC), so `timezone`, `dateStrings` and `TZ` do not enter the decision; the model trait compares the same way. PostgreSQL stores `timestamptz(3)` (an absolute instant) and SQLite a number. The suite spawns real child processes in `UTC`, `Asia/Tokyo` and `America/Caracas` over the same database, with the default connection options, writing and reading in both directions (*"expires_at es un instante: procesos con TZ distinta sobre la misma base ven la misma caducidad"*). Keep the process on NTP; do not set MySQL's `timezone` option for the package's sake — it is not needed, and it must not be relied on.
1862
+ **Expiry is an instant, and the package stores it as UTC itself.** On MySQL `expires_at` is `DATETIME(3)`, which has no time zone, and `mysql2` serialises and parses `Date` values with the **process's** `TZ` (`timezone: 'local'`, its default): a process in UTC wrote `12:00:00` for `12:00Z` and a process in Caracas read it as `16:00Z` — the assignment expired four hours late for it (and nine hours early for one in Tokyo). The `database` driver does not depend on your connection options: on MySQL it writes `expires_at` as an explicit UTC string (`YYYY-MM-DD HH:mm:ss.SSS`), compares with `now` formatted the same way and reads it back through `DATE_FORMAT` (a string, parsed as UTC), so `timezone`, `dateStrings` and `TZ` do not enter the decision; the model trait compares the same way. PostgreSQL stores `timestamptz(3)` (an absolute instant) and SQLite a number. The suite spawns real child processes in `UTC`, `Asia/Tokyo` and `America/Caracas` over the same database, with the default connection options, writing and reading in both directions (*"expires_at es un instante: procesos con TZ distinta sobre la misma base ven la misma caducidad"*). Keep the process on NTP; do not set MySQL's `timezone` option for the package's sake — it is not needed, and it must not be relied on. 1.x did not do this: its MySQL expiries are the wall clock of the process that wrote them, and [`authz:upgrade-1x`](#expiry-written-by-1x-on-mysql) converts them with one value you supply. A present `expires_at` that is not a real instant — the zero date `0000-00-00` a non-strict `sql_mode` leaves, a PostgreSQL `±infinity`, text in SQLite — is **500 `E_AUTHZ_INTERNAL`** wherever the package reads it (a re-grant, `reconcile`, the relations export), never «no expiry»: nothing is written from it — `authz:reconcile --to=database` included, which until then rewrote such a row with the source's expiry and now stops before its first write —, and `revoke` + `grant` repairs the row. The decision itself is SQL's `expires_at > now`, which does not read through the codec: closed for MySQL's zero date and PostgreSQL's `-infinity`, but a `+infinity`, or text in SQLite (which sorts after every number), compares as later than any instant and **grants** — the package never writes them, do not either; both are declared in [Limits](#limits) with their recipe (C1, E1) (*"RR-2 · una caducidad presente e ilegible es un error…"*).
1861
1863
 
1862
1864
  **`withAuthzCatalogWrite` and a swallowed SQL error.** Do not `try/catch` a SQL failure inside the `fn(trx)` you pass and carry on: on PostgreSQL the transaction is aborted (`25P02`) and every following statement fails — the package classifies that as 503 `E_AUTHZ_BACKEND_UNAVAILABLE` with the `pg` error as `cause` (never the raw error with your SQL in it); on MySQL and SQLite the engine does **not** abort the transaction and what follows **is committed**. The divergence is the engines', pinned by the suite on the three (*"un error SQL tragado dentro de fn envenena la transacción en PostgreSQL ⇒ 503 …; en MySQL y SQLite la transacción sigue y se confirma"*).
1863
1865
 
@@ -1901,7 +1903,7 @@ tuples grant nothing and are no membership. The way across is **`authz:reconcile
1901
1903
  leftovers in one pass — there is no `openfga:import` (removed in 2.3). See [Migrating and
1902
1904
  verifying](#migrating-and-verifying-authzreconcile-23).
1903
1905
 
1904
- **The SQL recipe.** Upgrading a 1.x installation (which used `uuid` columns, `timestamp` for `expires_at`, the default collation, had no `authz_catalog_version`, and — before 2.2 — no `owner_scope_key` on roles nor `assignable_at` on permissions): run the statements below for your engine in a migration of your own. They are **executed by the suite** (`tests/upgrade_recipe.spec.ts`): the 1.1.0 migration is created on a scratch database with a role already in it, these exact statements are applied, the resulting schema is compared column by column with the published migration, and the 2.x engine is exercised on top (non-UUID ids, millisecond expiry, dates past 2038, byte-wise identity, the catalog version, and the pre-existing role left **global** and recognised by the next sync as the same role). Existing UUID values are valid strings; nothing needs rewriting.
1906
+ **The upgrade, per engine.** A 1.x installation used `uuid` columns, `timestamp` for `expires_at`, the default collation, had no `authz_catalog_version`, and — before 2.2 — no `owner_scope_key` on roles nor `assignable_at` on permissions. Existing UUID values are valid strings and need no rewriting. **PostgreSQL**: run the statements below in a migration of your own (its DDL is transactional, so a failed migration rolls back whole); 1.x's `timestamp` was already `timestamptz` and nothing is converted. **MySQL**: run **`node ace authz:upgrade-1x`** — there is no SQL block to copy any more (see below for why). **SQLite** has nothing to upgrade. Both paths are **executed by the suite** (`tests/upgrade_recipe.spec.ts`): the 1.1.0 migration is created on a scratch database with a role already in it, the PostgreSQL statements or the command are applied, the resulting schema is compared column by column with the published migration, and the 2.x engine is exercised on top (non-UUID ids, millisecond expiry, dates past 2038, byte-wise identity, the catalog version, 1.x expiries written by processes outside UTC keeping their instant, and the pre-existing role left **global** and recognised by the next sync as the same role).
1905
1907
 
1906
1908
  ```sql
1907
1909
  -- PostgreSQL: upgrading a 1.x schema to 2.x
@@ -1930,43 +1932,64 @@ CREATE INDEX authz_roles_owner_idx ON authz_roles (owner_scope_key);
1930
1932
  ALTER TABLE authz_permissions ADD COLUMN assignable_at varchar(500);
1931
1933
  ```
1932
1934
 
1933
- ```sql
1934
- -- MySQL: upgrading a 1.x schema to 2.x
1935
- ALTER TABLE authz_roles
1936
- MODIFY slug varchar(100) COLLATE utf8mb4_bin NOT NULL,
1937
- MODIFY scope_type varchar(20) COLLATE utf8mb4_bin NOT NULL;
1938
- ALTER TABLE authz_permissions
1939
- MODIFY slug varchar(100) COLLATE utf8mb4_bin NOT NULL;
1940
- ALTER TABLE authz_assignments
1941
- MODIFY holder_type varchar(50) COLLATE utf8mb4_bin NOT NULL,
1942
- MODIFY holder_uuid varchar(64) COLLATE utf8mb4_bin NOT NULL,
1943
- MODIFY scope_type varchar(20) COLLATE utf8mb4_bin NOT NULL,
1944
- MODIFY scope_uuid varchar(64) COLLATE utf8mb4_bin NOT NULL,
1945
- MODIFY expires_at datetime(3) NULL;
1946
- ALTER TABLE authz_denies
1947
- MODIFY holder_type varchar(50) COLLATE utf8mb4_bin NOT NULL,
1948
- MODIFY holder_uuid varchar(64) COLLATE utf8mb4_bin NOT NULL,
1949
- MODIFY scope_type varchar(20) COLLATE utf8mb4_bin NOT NULL,
1950
- MODIFY scope_uuid varchar(64) COLLATE utf8mb4_bin NOT NULL;
1951
- CREATE TABLE authz_catalog_version (
1952
- id int NOT NULL PRIMARY KEY,
1953
- version bigint NOT NULL DEFAULT 0,
1954
- updated_at timestamp NOT NULL,
1955
- freeze_reason varchar(255) NULL,
1956
- freeze_holder varchar(120) NULL,
1957
- freeze_until_ms bigint NULL,
1958
- freeze_fence bigint NOT NULL DEFAULT 0
1959
- );
1960
- INSERT INTO authz_catalog_version (id, version, updated_at) VALUES (1, 0, CURRENT_TIMESTAMP);
1961
- INSERT INTO authz_catalog_version (id, version, updated_at) VALUES (2, 0, CURRENT_TIMESTAMP);
1962
- ALTER TABLE authz_roles
1963
- ADD COLUMN owner_scope_key varchar(80) COLLATE utf8mb4_bin NOT NULL DEFAULT 'global',
1964
- DROP INDEX authz_roles_slug_scope_uq,
1965
- ADD UNIQUE INDEX authz_roles_slug_scope_owner_uq (slug, scope_type, owner_scope_key),
1966
- ADD INDEX authz_roles_owner_idx (owner_scope_key);
1967
- ALTER TABLE authz_permissions ADD COLUMN assignable_at varchar(500) NULL;
1935
+ #### MySQL: `node ace authz:upgrade-1x` (2.5.0)
1936
+
1937
+ ```bash
1938
+ node ace authz:upgrade-1x --zone=Europe/Madrid # dry run: the detected state and the plan, nothing written
1939
+ node ace authz:upgrade-1x --zone=Europe/Madrid --confirm-legacy-stopped --force # upgrade; re-run the SAME line after any failure
1940
+ node ace authz:upgrade-1x --fix-previous-upgrade --zone=Europe/Madrid --upgraded-at=2026-09-01T10:00:00Z --confirm-legacy-stopped --force
1941
+ node ace authz:upgrade-1x --declare-born-in-2 --force # a database born on 2.x: writes the marker, no data
1968
1942
  ```
1969
1943
 
1944
+ **Before `--force`: stop every 1.x process — the command requires you to say so.** Do not run a rolling deploy 1.x → 2.x on MySQL. A 1.x process still serving after the conversion keeps writing expiries as the **local wall clock** of its zone into a column 2.x reads as UTC: east of UTC (Madrid) each of its new expiries grants 1–2 h **after** it expired (**fail-open**); west of UTC (Caracas) a 1.x process also reads the converted UTC values as its local time and grants 4 h too long while it serves. Nothing in the schema stops 1.x from writing (it does not need `authz_catalog_version`), and **the command cannot detect it afterwards**: the marker already says done, and a re-run answers `hecho`. So every `--force` that converts — the whole upgrade (`1x-sin-subir`), a resumed run that has not converted yet (`a-medias`), and `--fix-previous-upgrade` — is **refused (exit 1, nothing written) without `--confirm-legacy-stopped`**, which is your confirmation that no 1.x process is left; the dry run prints this precondition in its plan. The order is: stop all of 1.x → run the command with `--force` → start 2.x.
1945
+
1946
+ **Why a command and not SQL.** On MySQL 1.x's expiries are not instants (next subsection), so the upgrade has to *convert* `expires_at` — and a conversion is the one statement that must never run twice, nor on rows 2.x already wrote. Three versions of a copy-paste block failed exactly there: the first did not convert (the published block of 2.4.0-alpha.1 … 2.5.0-rc.1), the second converted again when re-run after a failure (MySQL's DDL commits implicitly, so neither a transaction nor a migration undoes what ran before a failure), the third converted 2.x's rows when run over a database already upgraded with the first, and turned a 1.x zero date into `NULL` — no expiry, a **permanent** grant. The command reads the state **before touching anything** and decides from it.
1947
+
1948
+ **What it reads, and what it does with it.** The first line of its output is always `estado: <state>`; every refusal exits 1 and writes nothing.
1949
+
1950
+ | Detected state | How it is recognised | What the command does |
1951
+ |---|---|---|
1952
+ | `no-mysql` | the connection is PostgreSQL or SQLite | nothing: exit 0, zero statements, and it names the flags it ignored |
1953
+ | `1x-sin-subir` | `expires_at` is 1.x's `timestamp`, no marker | the whole upgrade; **`--zone` is required** (no default) |
1954
+ | `a-medias` | a marker armed by an earlier run of the command (or done with DDL left) | resumes where it stopped, **with the marker's zone** (another `--zone` is refused) |
1955
+ | `receta-vieja` | `expires_at` is already `datetime(3)` and there is no marker: upgraded with the old block — or born on 2.x; the command cannot tell which | **refuses** and offers both ways out: `--declare-born-in-2` if it was born on 2.x, `--fix-previous-upgrade` if it came from 1.x (below) |
1956
+ | `hecho` | marker done and the 2.x schema (an upgrade, a correction, or a database declared born on 2.x) — or no marker and not one row with an expiry | nothing: exit 0 (another `--zone` than the marker's is refused: it never converts twice) |
1957
+ | `motor-no-transaccional` | `authz_assignments`, or the marker table, is not InnoDB (`information_schema.tables.engine`) | **refuses before anything else**, names the tables and prints `ALTER TABLE … ENGINE=InnoDB` |
1958
+ | `incoherente` | e.g. marker done but `expires_at` is `timestamp` again (a 1.x dump of `authz_*` restored after upgrading), `authz_catalog_version` present before the conversion (2.x could have written), a marker or indexes this command never writes | **refuses**, explains, changes nothing |
1959
+ | `sin-tablas` | a 1.x table is missing | refuses: a fresh install uses the published migration |
1960
+
1961
+ And, in every state that converts: a zone MySQL cannot convert (an unknown name, or a name without the server's time-zone tables — `mysql_tzinfo_to_sql`; `'Z'` is written `'+00:00'`) is refused **before anything changes**; expiries MySQL cannot read (the zero date `0000-00-00`, or a zero month/day, which a non-strict `sql_mode` left when 1.x wrote an expiry past 2038 into a `TIMESTAMP`) are refused, counted, unless **`--zero-dates=expire`**, which sets them to a **past** instant (they never granted in 1.x either) — never `NULL`. Writing needs **`--force`**; without it (or with `--dry-run`, which is the default and is refused together with `--force`) it prints the plan and exits 0. A second `--force` run on the same database waits for nobody: it is refused (`estado: ocupado`) while another holds the lock. `--connection=<name>` picks a Lucid connection other than the primary one.
1962
+
1963
+ **How a cut is made safe.** The command runs everything over one session, which it sets to the server's zone (the one 1.x's connections had: `@@global.time_zone`) and to a strict `sql_mode`, whatever the pool left there. The marker row (`authz_upgrade_1x`, step `expires_at`) is **armed, with the zone, only while `expires_at` is still `timestamp`** — before the `MODIFY` that turns it into `datetime(3)`; the conversion and the «done» go in **one transaction** (DML, all or nothing — which only holds on InnoDB: on MyISAM a `ROLLBACK` undoes nothing, a failure between the two writes would leave the rows converted with the marker still armed, and the re-run would convert them again; so the command reads the engine of `authz_assignments` and of the marker from `information_schema.tables` and refuses anything but InnoDB, `motor-no-transaccional`, and creates its own tables with an explicit `ENGINE=InnoDB` whatever `default_storage_engine` says), and only from an armed marker; `authz_catalog_version` is created **after** the conversion, so no 2.x process can have written a row the conversion would move (without that table every 2.x write is a 503). Each step is detected on its own from `information_schema`, so a re-run after a failure at any point — the suite cuts the command after each of its ten steps and runs it again — resumes, converts once, and ends with the published schema. **Keep the marker table**: it is what tells a re-run, and a restored 1.x dump, apart. If 1.x's connections ran their own `SET time_zone` (not the server default), the command does not cover it: that is the one input it cannot read.
1964
+
1965
+ #### Expiry written by 1.x on MySQL
1966
+
1967
+ **What 1.x stored.** 1.x wrote `expires_at` through `mysql2` with its default `timezone: 'local'`: the `TIMESTAMP` column received the **wall clock of the Node process** (`2030-07-01 02:00:00` for `2030-07-01T00:00Z` in a Madrid process), and MySQL turned that wall clock into its internal instant with the **session's** zone. 1.x read and compared it the same way, so it was consistent with itself — but the column only held the true instant when process and session were in the same zone (measured on MySQL 8.4: a Madrid process on a server in UTC stores that expiry as `02:00Z`). `MODIFY … datetime(3)`, run in the session zone 1.x had, leaves exactly that wall clock, and 2.x reads `DATETIME` as UTC ([Expiry is an instant](#operational-notes-for-the-sql-engines)). Without the conversion every 1.x expiry moves by the process's offset: a Madrid fleet (+02:00 in summer) keeps granting **two hours after the expiry** (fail-open), a Caracas fleet (−04:00) loses access four hours early. The command renders the wall clock in the session zone 1.x had and converts it from the 1.x process zone (`--zone`) to UTC.
1968
+
1969
+ **What to put in `--zone`.** If the 1.x connection config set mysql2's `timezone`, that value (`'Z'` is `+00:00`, `'+HH:MM'` is itself; `'local'` means the process zone). Otherwise the zone of the 1.x Node process: its `TZ`, or the host's zone (`node -p "Intl.DateTimeFormat().resolvedOptions().timeZone"` on that host). A process in UTC: `--zone=+00:00` (the conversion changes nothing and marks itself done). A zone **with daylight saving goes by name** (`Europe/Madrid`), not by offset — a fixed offset is an hour wrong for the expiries on the other side of the change, and the command warns when you give one —; names need the server's time-zone tables (the official Docker image loads them). `SELECT * FROM authz_upgrade_1x` tells you afterwards which zone was used and when.
1970
+
1971
+ **If the zone was wrong.** Every expiry moves by the difference between the real offset and the one you gave. A zone **west** of the real one (a smaller offset — e.g. `+00:00` for a Madrid fleet) makes expiries later: assignments keep granting after they expired (**fail-open**). A zone east of it makes them earlier (fail-closed). The command never converts twice, so re-running it with another zone is refused: apply the difference yourself to the rows 1.x wrote (the next subsection shows how they are told apart) and update the marker's `zone`. Before 2.x serves traffic, take an assignment whose expiry you know and compare `SELECT expires_at` (now UTC) with it — the dry run prints how many rows it will convert.
1972
+
1973
+ **What no command recovers** (each with the direction of the error, measured on MySQL 8.4):
1974
+ - *1.x processes in different zones, or a server zone changed after the rows were written*: 1.x itself stored inconsistent values (the defect 2.x fixed by writing UTC itself); the conversion uses one zone. Rows written from a zone east of the one you give end up later (fail-open), from one west of it earlier.
1975
+ - *The repeated hour of a daylight-saving fall-back, in the 1.x process zone*: a wall clock inside it (`02:30` on the last Sunday of October in Madrid) is ambiguous in 1.x's own data, and `CONVERT_TZ` takes the **daylight-saving** reading — the earlier instant (`2030-10-27 02:30` Madrid → `00:30Z`, `2030-11-03 01:30` New York → `05:30Z`). An expiry that 1.x meant for the second reading ends up one hour **early** (fail-closed); never late.
1976
+ - *The spring-forward gap, in the SESSION zone*: when the server (or the session) zone has daylight saving and differs from the process zone — e.g. `SYSTEM` is `Europe/Madrid` and 1.x ran in UTC — a wall clock 1.x wrote inside the gap (`2030-03-31 02:30`) is not a valid `TIMESTAMP` there. Under a strict `sql_mode` 1.x's own `grant` failed, so no such row exists; under a non-strict one MySQL stored the end of the gap (`03:00`), and the conversion converts that: up to one hour **late** (fail-open) — a defect 1.x already had (it compared the same shifted value). The repeated hour in the session zone round-trips (the `MODIFY` renders back the wall clock 1.x wrote).
1977
+ - *Stale time-zone tables*: `CONVERT_TZ` uses the server's tables, Node used its own ICU data. If a zone changed its rules after the tables were loaded (a country that dropped daylight saving), the expiries in the affected months move by that hour, in the direction of the change, silently — reload the tables (`mysql_tzinfo_to_sql`) before running the command if they are older than your Node's tzdata.
1978
+
1979
+ #### If you already upgraded from 1.x with the previous MySQL block
1980
+
1981
+ The MySQL block published from **2.4.0-alpha.1 to 2.5.0-rc.1** changed the type and left the wall clock. **You are affected** if all of this holds: MySQL; the data came from 1.x; you ran that block; and your 1.x processes did not serialise dates in UTC. An installation that started on 2.x, or whose 1.x processes were in UTC, is not affected; neither is PostgreSQL. **What it did**: every expiry 1.x wrote moved by the 1.x zone's offset — a fleet **east** of UTC (Madrid: +1 h in winter, +2 h in summer) keeps granting that long after each expiry (**fail-open**), one west of UTC (Caracas: −4 h) loses access that much early. Rows written by 2.x since then are already UTC, and **must not** be converted — which is why `authz:upgrade-1x` on such a database stops at `estado: receta-vieja` and converts nothing by default.
1982
+
1983
+ **The command looks for traces of 1.x**: `expires_at` still `timestamp`, a marker already there, a 1.x schema step still pending (`authz_catalog_version` missing or without its two rows, `authz_roles` without `owner_scope_key`, `authz_permissions` without `assignable_at`, a column not yet `utf8mb4_bin`) or unreadable expiries (zero dates, which 2.x never writes). No published 2.x migration leaves any of them. With a trace, `--declare-born-in-2` is **refused** (exit 1, nothing written: «hay rastro de 1.x, born no aplica») and `receta-vieja` offers only the correction. **Without any trace, a database born on 2.x and one fully upgraded with the old block have the same shape** (`datetime(3)`, no marker, nothing pending), and the command cannot tell them apart. If yours never held 1.x data, **declare it**: `--declare-born-in-2 --force` writes the marker (state done, mode `born`) and touches no row; from then on the command answers `hecho`. It is **your declaration, not something the command proves** — and it is accepted only there. Flags of the conversion (`--zero-dates`, `--confirm-legacy-stopped`, `--zone`, `--fix-previous-upgrade`, `--upgraded-at`) are refused with it, not ignored. If the declaration is false, the 1.x expiries stay shifted by their zone's offset for good (east of UTC: fail-open) and the command says `hecho`. Do not use `--fix-previous-upgrade` to make a born-on-2.x database "green": it would move its expiries earlier (fail-closed, every row).
1984
+
1985
+ **The correction: `--fix-previous-upgrade --zone=<1.x zone> --upgraded-at=<ISO> --confirm-legacy-stopped`** (all required, no defaults). **`--upgraded-at` is the instant the last 1.x process stopped writing** — not when you ran the block: a 1.x process that kept serving after the block wrote more 1.x rows. Wrong one way or the other: too **early**, and the 1.x rows written after it are left unconverted (east of UTC they keep granting after they expire: **fail-open**); too **late**, and the 2.x rows inserted in between are moved earlier (they expire early: fail-closed). Both 1.x and 2.x give each assignment a UUIDv7, whose first 48 bits are the millisecond it was inserted, from the clock of the process — not its zone; so the rows inserted before you ran the block have a `uuid` that sorts before the UUIDv7 prefix of `--upgraded-at`. What cannot be told apart **with certainty** is a 1.x row whose expiry 2.x rewrote afterwards (a re-grant with an expiry, a `reconcile --to=database` update): it keeps its `uuid` and now holds a UTC value. So the correction is the **safe** one: it only touches rows inserted before that instant, and only moves an expiry **earlier** — for a zone east of UTC it converts every such row (a 1.x row rewritten by 2.x ends up expiring early by the offset: fail-closed; re-grant it), and for a zone west of UTC it moves nothing (those rows expire early already — fail-closed; re-grant the holders you need). So give an instant **later** rather than earlier than the real one. `--upgraded-at` needs an explicit zone (`Z` or `±hh:mm`), and the command refuses an instant in the future or one that is not later than the oldest row — it would correct nothing and mark itself done. The dry run prints how many rows are 1.x's and how many it moves. The correction and its marker are one transaction; running it again is `hecho`.
1986
+
1987
+ #### What the command does (an appendix, not a recipe)
1988
+
1989
+ In order, each step only if `information_schema` says it is still pending: the zero dates to a past instant (only with `--zero-dates=expire`); the marker table (`ENGINE=InnoDB`), and the marker armed with the zone (only while `expires_at` is `timestamp`); `authz_roles`, `authz_permissions` and `authz_denies` identity and slug columns to `utf8mb4_bin` (`varchar(64)` for ids); `authz_assignments` likewise plus `expires_at` to `datetime(3)`, in the server's session zone; the conversion `CONVERT_TZ(expires_at, <zone>, '+00:00')` of every present expiry and the marker set done, in one transaction; `authz_catalog_version` (`ENGINE=InnoDB`) with rows 1 and 2 at version 0; `authz_roles.owner_scope_key` (default `global`) with the three-column unique; `authz_permissions.assignable_at`. It then reads the state again and only reports success if it is `hecho`. The statements are in `commands/authz_upgrade_1x.ts`; they are the published migration's shapes (the suite compares the result column by column), not something to run by hand.
1990
+
1991
+ **PostgreSQL and SQLite have no such step.** On PostgreSQL 1.x's `timestamp` was already `timestamptz` (an instant) and the recipe only changes its precision; SQLite has nothing to upgrade, and 1.x stored a number there (knex binds a `Date` as milliseconds). The suite executes both paths (`tests/upgrade_recipe.spec.ts`): child processes in `Europe/Madrid` (summer and winter expiries) and `America/Caracas` write 1.x expiries with 1.x's client and connection defaults; on PostgreSQL the recipe runs, on MySQL the command runs with `--zone` as an operator would — over sessions born in another zone (`+05:00`) and a non-strict `sql_mode`, which it has to undo —, and 2.x grants 1 ms before each expiry and not at it (*"R3-01 · una caducidad que 1.x escribió desde un proceso en … deja de conceder EXACTAMENTE en su instante tras la subida"*). The command's states, its ten cut points, the refusals and the correction are pinned by `tests/command_flags_upgrade_1x.spec.ts`.
1992
+
1970
1993
  ### Upgrading to 2.5.0: non-canonical partition spellings
1971
1994
 
1972
1995
  There is **no `2.4.0` stable**: the 2.4 line ends at `2.4.0-alpha.10` and the next stable is **2.5.0** (the
@@ -2137,6 +2160,68 @@ DELETE FROM authz_relations WHERE subject_relation <> '' AND NOT (subject_type =
2137
2160
  report `permission-not-in-model` for a permission only present in a newer model now projects it under the pinned one
2138
2161
  — and one that used to die with a 503 mid-pass (permission in the latest model but not in the pinned one) now counts
2139
2162
  it. The runbook rule stands: publish and roll out the model **before** the window, never inside it.
2163
+ 12. **An optional capability your harness omits is judged by the driver's own declaration** (rc.1 rehearsal): in
2164
+ `runAuthorizationDriverContract` (`experimental`, `snapshotDecisions`) and `runRelationsDriverContract`
2165
+ (`relationDenies`, `atomicRenewal`, `experimental`), a key left out of `capabilities` takes the driver's normalised
2166
+ declaration and the judge runs that face in one case; a key you declare explicitly still decides, and contradicting the
2167
+ driver is still red. *Adapt*: nothing — a harness written for alpha.4 that registers the package's `database` without
2168
+ `snapshotDecisions` is green again (it is judged on the `true` face).
2169
+ 13. **What you see going from `2.5.0-alpha.4` to `2.5.0-rc.x` with live data** (rotation 2 closures; the
2170
+ [CHANGELOG](./CHANGELOG.md) of rc.1 has the why of each). There is no migration, no stub and no config key to change
2171
+ between alpha.4 and rc.x; these are behaviours, each with what to do:
2172
+ - **`authorizedScopes` can answer 503 where it answered `none`** (`database`). It reads the holder's grants and denies
2173
+ from one snapshot, so it resolves the chain of every deny of the holder even when no scope grants the permission: a
2174
+ holder with no grant and a deny on a scope your `resolveChain` cannot resolve gets **503 `E_AUTHZ_RESOLVER_FAILED`**
2175
+ instead of `{ kind: 'none' }`, and a holder with N denies costs N `resolveChain` calls. *Adapt*: handle it as any
2176
+ resolver failure (fail-closed); remove denies on scopes that no longer exist.
2177
+ - **`authz:catalog:prune-orphans --force` has a third refusal trigger, `granting-across-owners`**: orphans of more
2178
+ than one owner of which any still grants — or cannot be counted — are **500 `E_AUTHZ_MASS_PURGE_REFUSED`** before
2179
+ anything is deleted, so a cron running `--force` that passed on alpha.4 can now exit 1. With a driver without
2180
+ `countRoleAssignments`, every multi-owner `--force` needs `--allow-mass-purge`. *Adapt*: read the `--dry-run` (it
2181
+ still exits 0 and now prints the reasons, `massPurgeReasons`) and add `--allow-mass-purge` only after checking the
2182
+ job's resolver; see the RC2-C row in [Limits](#limits) for the single-victim case the trigger does not cover.
2183
+ - **`scopes.attached`/`scopes.moved` report containment first.** The whole containment block (`within` required,
2184
+ `'non-root'`, destination and origin) now runs before the cycle and depth checks: with `requireWithin` and no
2185
+ `within` you get **`E_AUTHZ_WITHIN_REQUIRED`** where you got `E_AUTHZ_SCOPE_CYCLE` or `E_AUTHZ_SCOPE_DEPTH_LIMIT`, a
2186
+ `within` that fails is `E_AUTHZ_NOT_WITHIN` whatever the edge, and in `scopes.moved` with a `within` and an unknown
2187
+ child the origin's `E_AUTHZ_UNKNOWN_SCOPE` comes before the depth. Without `within`, or with one that passes, the
2188
+ order is unchanged; the relay does not change. *Adapt*: only tests or clients that matched the code of the first
2189
+ error.
2190
+ - **`defineScopedRole`: a homonym committed by another owner during your write now enters.** The re-check inside the
2191
+ transaction judges by authority, like the check before it: a global or ancestor-or-self homonym is still 422, any
2192
+ other owner's (a sibling, a descendant) is not a collision and the role is written — before, a sibling's was a 422
2193
+ «retry» that named its owner. *Adapt*: nothing, unless a test of yours pinned that 422 (the package's own 3F · U3
2194
+ case now pins that it enters).
2195
+ - **MySQL: the relay lease takes two names during 2.5.x.** `GET_LOCK` on alpha.4's name (without the database) first,
2196
+ then on the new one (with `DATABASE()`), and both are released, so a rolling deploy from alpha.4 stays
2197
+ single-writer. While the old name is taken, two deployments on different databases of one server with the same
2198
+ connection and table names still exclude each other (`busy`, exit 0). *Adapt*: nothing for a rolling deploy; on a
2199
+ shared server, give each deployment its own outbox table or connection name (R2-01 row in [Limits](#limits)).
2200
+ - **`readSnapshot` and `snapshotDecisions`.** The `database` driver declares `snapshotDecisions: true`, and the
2201
+ manager reads what it composes — `effectivePermissions`, `authorizedScopes`, the delegation policy of
2202
+ `defineScopedRole`/`updateScopedRole` — through `readSnapshot`, **not** through `rolesInChain`/`listScopes`/
2203
+ `listDenies`. A third-party driver that implements the port and does not declare the capability keeps working as
2204
+ before. **An object that wraps or subclasses `DatabaseAuthorizationDriver`** (not a documented extension point)
2205
+ copies or inherits `snapshotDecisions: true` and `readSnapshot`: a decorator that copies `capabilities` without
2206
+ forwarding `readSnapshot` gets **500 `E_AUTHZ_CONFIG`** at the first composed decision (`authorize` and the
2207
+ `list*` keep answering — fail-closed), and a subclass that overrides `listDenies` (or `rolesInChain`/`listScopes`)
2208
+ to add facts of its own is **no longer consulted** there: `effectivePermissions` can list a permission its own
2209
+ `authorize` refuses, and the delegation policy can let that actor delegate it. *Adapt*: in a decorator, forward
2210
+ `readSnapshot`; in a subclass, override `readSnapshot` so it answers with your extra facts too, or declare
2211
+ `snapshotDecisions: false` on your object — in a subclass's constructor,
2212
+ `Object.defineProperty(this, 'capabilities', { value: Object.freeze({ ...this.capabilities, snapshotDecisions: false }) })`
2213
+ — and the manager composes through your methods again, with the two-read gap of the [torn read](#limits) row.
2214
+ **A decorator must also re-wrap the views it hands out**: the manager reads a `forRequest()` view through
2215
+ `withChainResolver(resolver)` and, with `config.clock`, replaces the driver with `withClock(clock)` once. Forwarded
2216
+ as is (`withChainResolver: (r) => inner.withChainResolver(r)`), they return the **inner** driver's view —
2217
+ undecorated, with its `snapshotDecisions: true` —, so `forRequest()` (and, with `config.clock`, every call) skips
2218
+ your object: measured with a decorator that denies a permission in `authorize`/`listDenies`, `forRequest()`
2219
+ granted it, and with `config.clock` the denied actor delegated it through `defineScopedRole`. Return your
2220
+ decorator around the view instead (`withChainResolver: (r) => decorate(inner.withChainResolver(r))`, the same for
2221
+ `withClock`). A subclass needs nothing here — both views inherit from it by prototype —
2222
+ **unless its overrides use `#private` fields**: a view is `Object.create(this)` and does not carry them, so
2223
+ `forRequest()` (and, with `config.clock`, every call) throws `TypeError` (fail-closed, but it does not work). Use
2224
+ ordinary properties, or override `withChainResolver` and `withClock` to return a new instance of your subclass.
2140
2225
 
2141
2226
  **Default (read-only): detect, list, count.** Nothing is written. Statement 3 says, per branch, what `--rekey`
2142
2227
  would do: a row *with* a canonical twin is dropped by it, a row *without* one is re-keyed.
@@ -0,0 +1,216 @@
1
+ import { BaseCommand } from '@adonisjs/core/ace';
2
+ import { CommandOptions } from '@adonisjs/core/types/ace';
3
+ /**
4
+ * **`authz:upgrade-1x`** (rotación 3 · cierre 3, ESTRUCTURAL; firmado por el dueño el 2026-10-26).
5
+ *
6
+ * La subida 1.x → 2.x de MySQL deja de ser un bloque SQL que el operador copia y pasa a ser CÓDIGO que inspecciona el
7
+ * estado antes de tocar nada. Es el tercer defecto del linaje «receta del operador que no aguanta su re-ejecución»
8
+ * (R3-01 → RR-1 → A1/A2 del re-ataque 2, los tres confirmados en `mysql84`), y la regla de `CLAUDE.md` es que el
9
+ * tercero no se parchea: se quita el camino manual.
10
+ *
11
+ * Reparto (mismo patrón que `unfreezePlan`/`reconcileLines`):
12
+ * - `readUpgrade1xState` LEE (information_schema, el marcador y los datos) por UNA sesión;
13
+ * - `upgrade1xPlan(estado, banderas)` es la función PURA que DECIDE: estado detectado, negativa o pasos;
14
+ * - `run()` imprime el plan y, solo con `--force`, ejecuta los pasos por esa misma sesión.
15
+ *
16
+ * Qué hace la subida (lo que era el bloque MySQL del README): los `MODIFY` de collation/tipo, `expires_at` de
17
+ * `timestamp` a `datetime(3)` en la zona de sesión que tenía 1.x (`@@global.time_zone`), la CONVERSIÓN de cada
18
+ * caducidad de 1.x desde la zona de sus procesos (`--zone`) a UTC, `authz_catalog_version` con sus dos filas, y las
19
+ * columnas `owner_scope_key`/`assignable_at`. El marcador `authz_upgrade_1x` (fila `expires_at`) se ARMA antes del
20
+ * `MODIFY` y SOLO si la columna sigue siendo `timestamp` (cierra A1/B2: una base ya en `datetime` sin marcador no se
21
+ * convierte nunca por defecto); la conversión y el «hecho» van en UNA transacción (DML de InnoDB: todo o nada) y la
22
+ * conversión va ANTES de crear `authz_catalog_version`, así que ningún proceso 2.x ha podido escribir una fila que se
23
+ * convierta (sin esa tabla toda escritura de 2.x es 503).
24
+ *
25
+ * Cierre 4 de la rotación 3 (firmado 2026-10-26, «sí, firmo la a) con el 2 y el 3»), tres límites que el código solo no
26
+ * podía ver:
27
+ * - C1 · un proceso 1.x VIVO tras la conversión sigue escribiendo la pared de su zona en el `datetime(3)` y nada lo
28
+ * detecta después (el marcador ya dice `done`). Precondición OBLIGATORIA y CONFIRMADA: todo `--force` que convierte
29
+ * (`1x-sin-subir`, `a-medias` antes de `convert`, `--fix-previous-upgrade`) exige `--confirm-legacy-stopped`; sin
30
+ * ella, negativa con cero sentencias de escritura. El esquema NO se cambia para romper las escrituras de 1.x (opción
31
+ * b descartada por el dueño).
32
+ * - C2 · «la conversión y el done en UNA transacción» solo es verdad en InnoDB: el comando lee
33
+ * `information_schema.tables.engine` de `authz_assignments` y del marcador y, si alguno no es InnoDB, se niega
34
+ * (`motor-no-transaccional`) antes de tocar nada; las tablas que crea llevan `ENGINE=InnoDB` explícito.
35
+ * - P1 · una base NACIDA en 2.x no se distingue de una subida ENTERA con el bloque viejo: `--declare-born-in-2` es la
36
+ * DECLARACIÓN del operador (el comando no puede demostrar que no hubo 1.x) y escribe el marcador `done`/`born` sin
37
+ * tocar datos.
38
+ *
39
+ * Cierre 5 de la rotación 3 (firmado 2026-10-27): born se NIEGA donde el comando SÍ ve 1.x (`upgrade1xLegacyTraces`:
40
+ * DDL de 1.x pendiente o fechas cero, además de `timestamp` y del marcador), y `receta-vieja` deja de ofrecerlo ahí (B1,
41
+ * B1-bis); las banderas de la conversión se rechazan con born en vez de tragarse (H4).
42
+ */
43
+ /** El estado detectado: la PRIMERA línea de la salida empieza por `estado: <clave>` (CLAUDE.md punto 7). */
44
+ export type Upgrade1xStateKey = 'no-mysql' | 'sin-tablas' | '1x-sin-subir' | 'a-medias' | 'receta-vieja' | 'hecho' | 'incoherente' | 'motor-no-transaccional';
45
+ /** Los DDL de la subida que se detectan pendientes uno a uno (cada uno es idempotente por inspección). */
46
+ export declare const UPGRADE_1X_PRE_CONVERT_DDL: readonly ["collation-roles", "collation-permissions", "assignments", "collation-denies"];
47
+ export declare const UPGRADE_1X_POST_CONVERT_DDL: readonly ["catalog-version", "roles-owner", "permissions-assignable"];
48
+ export type Upgrade1xDdl = (typeof UPGRADE_1X_PRE_CONVERT_DDL)[number] | (typeof UPGRADE_1X_POST_CONVERT_DDL)[number];
49
+ /** Un paso ejecutable, en el orden en que el plan los pone. */
50
+ export type Upgrade1xStep = 'zero-dates' | 'marker-table' | 'marker-arm' | 'convert' | 'fix-previous' | 'born' | Upgrade1xDdl;
51
+ /** Los pasos que CONVIERTEN caducidades: con `--force` exigen `--confirm-legacy-stopped` (C1). */
52
+ export declare const UPGRADE_1X_CONVERTING_STEPS: readonly Upgrade1xStep[];
53
+ export interface Upgrade1xMarker {
54
+ step: string;
55
+ /** `armed` (antes del MODIFY de `expires_at`, con la columna aún `timestamp`) o `done`. */
56
+ state: string;
57
+ /**
58
+ * `upgrade` (subida completa), `fix-previous` (corrección de quien subió con el bloque viejo) o `born` (el operador
59
+ * DECLARÓ la base nacida en 2.x: `--declare-born-in-2`; zona `+00:00`, ningún dato tocado).
60
+ */
61
+ mode: string;
62
+ zone: string | null;
63
+ /** `--upgraded-at` de la corrección, ISO; `null` en `upgrade`. */
64
+ upgradedAt: string | null;
65
+ }
66
+ /** Lo que `readUpgrade1xState` lee. Las entradas de la tabla de verdad de `upgrade1xPlan`. */
67
+ export interface Upgrade1xState {
68
+ /** Nombre de la conexión de Lucid sobre la que se lee. */
69
+ connection: string;
70
+ /** `dialect.name` de esa conexión. Fuera de MySQL nada más se lee. */
71
+ dialect: string;
72
+ /** Faltan tablas de 1.x (`authz_roles`, `authz_permissions`, `authz_assignments`, `authz_denies`). */
73
+ missingTables: string[];
74
+ /**
75
+ * Tablas que la conversión escribe en UNA transacción (`authz_assignments` y, si existe, `authz_upgrade_1x`) con un
76
+ * motor que NO es InnoDB, como `tabla (MOTOR)` (C2: en MyISAM el ROLLBACK no deshace nada). Ausente = ninguna.
77
+ */
78
+ nonTransactional?: string[];
79
+ /** `COLUMN_TYPE` de `authz_assignments.expires_at` (`timestamp`, `datetime(3)`, …), o `null` si no existe. */
80
+ expiresType: string | null;
81
+ /** Filas de `authz_upgrade_1x`; `null` si la tabla no existe; `'malformed'` si existe con otra forma. */
82
+ marker: Upgrade1xMarker[] | null | 'malformed';
83
+ /** DDL pendientes, en orden canónico. */
84
+ pendingDdl: Upgrade1xDdl[];
85
+ /** Combinaciones de esquema que ningún punto de corte de este comando produce (índices a medias, …). */
86
+ schemaOdd: string[];
87
+ /** ¿Existe `authz_catalog_version`? (con ella un proceso 2.x puede haber escrito). */
88
+ catalogVersionTable: boolean;
89
+ /** Filas con `expires_at` no nulo. */
90
+ expiring: number;
91
+ /** Caducidades presentes que MySQL no puede convertir (fecha cero, mes/día cero): `CONVERT_TZ` da `NULL` (A2). */
92
+ unreadable: number;
93
+ /** Zonas sondeadas (`--zone`, la del marcador) → ¿`CONVERT_TZ` las convierte? (tablas de zonas cargadas). */
94
+ zones: Record<string, boolean>;
95
+ /** Milisegundo del UUIDv7 más antiguo de `authz_assignments` (`null` si no hay ninguno v7). */
96
+ oldestV7Ms: number | null;
97
+ /** Filas cuyo `uuid` no tiene forma de UUIDv7. */
98
+ nonV7: number;
99
+ /** Con `--fix-previous-upgrade`: filas con caducidad insertadas antes de `--upgraded-at` (de 1.x). */
100
+ fixBefore: number | null;
101
+ /** …y de ellas, las que la conversión ADELANTA (las únicas que la corrección mueve). */
102
+ fixCandidates: number | null;
103
+ /** Reloj de pared (`systemClock`), para «`--upgraded-at` no es futuro». */
104
+ nowMs: number;
105
+ }
106
+ export interface Upgrade1xFlags {
107
+ zone?: string;
108
+ force?: boolean;
109
+ dryRun?: boolean;
110
+ fixPreviousUpgrade?: boolean;
111
+ upgradedAt?: string;
112
+ zeroDates?: string;
113
+ /** C1: el operador CONFIRMA que todos los procesos 1.x están parados (obligatoria con `--force` donde se convierte). */
114
+ confirmLegacyStopped?: boolean;
115
+ /** P1: el operador DECLARA que la base nació en 2.x (nunca tuvo datos de 1.x). */
116
+ declareBornIn2?: boolean;
117
+ }
118
+ export interface Upgrade1xLine {
119
+ level: 'info' | 'warning' | 'error' | 'success';
120
+ message: string;
121
+ }
122
+ export interface Upgrade1xPlan {
123
+ state: Upgrade1xStateKey;
124
+ /** `noop` (nada que hacer, exit 0), `refuse` (exit 1, nada escrito) o `run` (hay pasos). */
125
+ action: 'noop' | 'refuse' | 'run';
126
+ /** `run` con `--force`: se ejecutan los pasos. `run` sin él es la simulación (exit 0, cero escrituras). */
127
+ write: boolean;
128
+ exitCode: 0 | 1;
129
+ /** La zona con la que se convierte (bandera o marcador). */
130
+ zone: string | null;
131
+ upgradedAtMs: number | null;
132
+ steps: Upgrade1xStep[];
133
+ /** La primera es SIEMPRE `estado: <clave> · …`. */
134
+ lines: Upgrade1xLine[];
135
+ }
136
+ /** La zona que guarda el marcador `born`: 2.x siempre escribió UTC (columna `zone` NOT NULL). */
137
+ export declare const UPGRADE_1X_BORN_ZONE = "+00:00";
138
+ /** La letra de la negativa de born con rastro de 1.x (rotación 3 · cierre 5, B1/B1-bis). */
139
+ export declare const UPGRADE_1X_BORN_TRACE_TEXT = "hay rastro de 1.x, born no aplica";
140
+ /**
141
+ * Los RASTROS de 1.x que el comando DETECTA en una base con `expires_at` ya `datetime(3)` y sin marcador (rotación 3 ·
142
+ * cierre 5, B1/B1-bis). Ninguna migración publicada de 2.x deja un paso de esquema pendiente (crean `authz_catalog_version`
143
+ * con sus dos filas, `owner_scope_key` con el unique de tres columnas, `assignable_at` y las columnas `utf8mb4_bin`), y el
144
+ * codec de 2.x no escribe fechas cero. Sin ninguno de los dos, una base nacida en 2.x y una subida entera con el bloque
145
+ * viejo tienen la misma forma: ahí `--declare-born-in-2` sigue siendo una DECLARACIÓN del operador.
146
+ */
147
+ export declare function upgrade1xLegacyTraces(state: Pick<Upgrade1xState, 'pendingDdl' | 'unreadable'>): string[];
148
+ /**
149
+ * La precondición de C1, en una frase: la usan la negativa, la línea del plan y la simulación. Un proceso 1.x vivo tras
150
+ * la conversión escribe la PARED de su zona en el `datetime(3)` que 2.x lee como UTC, y ya nada lo detecta.
151
+ */
152
+ export declare const UPGRADE_1X_LEGACY_STOPPED_TEXT: string;
153
+ /** El instante en el que `--zero-dates=expire` deja una caducidad ilegible: PASADO, nunca `NULL` (A2). */
154
+ export declare const UPGRADE_1X_EXPIRED_INSTANT = 86400;
155
+ /** `--upgraded-at` exige ISO con zona explícita (`Z` o `±hh:mm`): sin ella JavaScript la leería en la hora local. */
156
+ export declare function parseUpgradedAt(value: string | undefined): number | null;
157
+ /** El prefijo de UUIDv7 de un milisegundo (`xxxxxxxx-xxxx`): las filas insertadas antes ordenan por debajo, por bytes. */
158
+ export declare function v7Prefix(ms: number): string;
159
+ export declare function describeUpgrade1xStep(step: Upgrade1xStep): string;
160
+ /**
161
+ * Decide, sin I/O, qué hacer con el estado detectado. Tabla de verdad (la fija `command_flags_upgrade_1x.spec.ts`):
162
+ *
163
+ * | `expires_at` | marcador | otras entradas | estado | acción |
164
+ * |---|---|---|---|---|
165
+ * | (no MySQL) | — | — | `no-mysql` | noop, exit 0, cero sentencias |
166
+ * | falta una tabla de 1.x | — | — | `sin-tablas` | negativa |
167
+ * | `timestamp` | ninguno | sin `authz_catalog_version` | `1x-sin-subir` | subida completa; exige `--zone` |
168
+ * | `timestamp` | `armed` | sin `authz_catalog_version` | `a-medias` | reanuda con la zona del marcador |
169
+ * | `timestamp` | `done` | — | `incoherente` (B2: volcado de 1.x restaurado) | negativa |
170
+ * | `timestamp` | ninguno/`armed` | con `authz_catalog_version` | `incoherente` (2.x pudo escribir en la columna de 1.x) | negativa |
171
+ * | `datetime(3)` | ninguno | alguna caducidad o DDL pendiente | `receta-vieja` | negativa; con `--fix-previous-upgrade` corrige |
172
+ * | `datetime(3)` | ninguno | ninguna caducidad y nada pendiente | `hecho` | noop |
173
+ * | `datetime(3)` | `armed` | sin `authz_catalog_version` | `a-medias` | convierte y termina |
174
+ * | `datetime(3)` | `done` | DDL pendiente | `a-medias` | termina el DDL |
175
+ * | `datetime(3)` | `done` | nada pendiente | `hecho` | noop (otra `--zone` ⇒ negativa) |
176
+ * | otro tipo, marcador con otra forma, índices a medias | | | `incoherente` | negativa |
177
+ * | (cualquiera) | (cualquiera) | `authz_assignments` o el marcador no son InnoDB | `motor-no-transaccional` (C2) | negativa |
178
+ * | `datetime(3)` | ninguno | `--declare-born-in-2`, sin rastro de 1.x | `receta-vieja` / `hecho` | escribe el marcador `done`/`born`, cero datos (P1) |
179
+ * | `datetime(3)` | ninguno | `--declare-born-in-2` + DDL pendiente o fechas cero | `receta-vieja` | negativa: «hay rastro de 1.x, born no aplica» (B1) |
180
+ * | (cualquiera) | (cualquiera) | `--declare-born-in-2` + `--zero-dates`/`--confirm-legacy-stopped`/`--zone`/fix | | negativa (H4) |
181
+ *
182
+ * Y en todo estado que convierte: zona desconocida para MySQL ⇒ negativa ANTES de tocar nada; caducidades ilegibles ⇒
183
+ * negativa salvo `--zero-dates=expire`; con `--force`, sin `--confirm-legacy-stopped` ⇒ negativa (C1). Escribir exige
184
+ * `--force`; sin él es la simulación (que menciona la precondición).
185
+ */
186
+ export declare function upgrade1xPlan(state: Upgrade1xState, flags: Upgrade1xFlags): Upgrade1xPlan;
187
+ type Query = (sql: string, bindings?: unknown[]) => Promise<any[]>;
188
+ export declare function readUpgrade1xState(q: Query, connection: string, flags: Upgrade1xFlags, nowMs: number): Promise<Upgrade1xState>;
189
+ export declare function runUpgrade1xStep(q: Query, step: Upgrade1xStep, plan: Upgrade1xPlan): Promise<string>;
190
+ /**
191
+ * node ace authz:upgrade-1x --zone=Europe/Madrid # simulación: estado detectado + plan, cero escrituras
192
+ * node ace authz:upgrade-1x --zone=Europe/Madrid --confirm-legacy-stopped --force # sube (re-ejecutable tras un corte)
193
+ * node ace authz:upgrade-1x --fix-previous-upgrade --zone=Europe/Madrid --upgraded-at=2026-09-01T10:00:00Z --confirm-legacy-stopped --force
194
+ * node ace authz:upgrade-1x --declare-born-in-2 --force # base nacida en 2.x: marcador, cero datos
195
+ */
196
+ export default class AuthzUpgrade1x extends BaseCommand {
197
+ static commandName: string;
198
+ static description: string;
199
+ static options: CommandOptions;
200
+ /**
201
+ * Gancho de TEST (cortes): se llama tras cada paso confirmado; si lanza, el comando se comporta como un proceso
202
+ * cortado ahí (exit 1, lo hecho queda hecho). Nadie lo pone en producción.
203
+ */
204
+ static afterStep: ((step: Upgrade1xStep) => void | Promise<void>) | undefined;
205
+ zone: string | undefined;
206
+ force: boolean | undefined;
207
+ dryRun: boolean | undefined;
208
+ fixPreviousUpgrade: boolean | undefined;
209
+ upgradedAt: string | undefined;
210
+ zeroDates: string | undefined;
211
+ confirmLegacyStopped: boolean | undefined;
212
+ declareBornIn2: boolean | undefined;
213
+ connection: string | undefined;
214
+ run(): Promise<void>;
215
+ }
216
+ export {};