Skip to content

Commit e1bd051

Browse files
os-steveclaude
andauthored
docs(cli): give os migrate duplicates its row and section in the data-migration docs (#9443)
`os migrate duplicates` shipped as an operator command but never reached the operator docs: the data-migration table in `content/docs/deployment/cli.mdx` listed its four siblings and not it, and no section described it. Adds the table row and a `#### os migrate duplicates` section at the end of the Data migrations block. Both are derived from the command as it exists rather than from prose: read-only boot (`deferSchemaDdl` + `readOnlyProbe`), JSON on stdout with no `--json` flag and no human renderer, `--object` / `--database-url`, and the `no_sql_seam` refusal on a driver with no raw SQL seam. The load-bearing half is the ordering. The report must run before the seed/API tenancy repair: the repair merges the two autonumber counters and deletes the global one, so the live condition can never be produced again, while the already-minted duplicates survive because the repair refuses to adopt a row whose identifier is already taken in the destination partition. A multi-organization deployment is skipped by that repair, so its evidence stays intact. All three facts re-confirmed against `duplicates.pre-repair.test.ts` and `duplicates.integration.test.ts`. Pure insertion, no deletions, and disjoint from the `os compile`, `os info` and environment-transcript regions of the same file. Claude-Session: https://claude.ai/code/session_01XqDQYVU5smx29ts9pAErja Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 26e70fb commit e1bd051

1 file changed

Lines changed: 64 additions & 0 deletions

File tree

content/docs/deployment/cli.mdx

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -657,6 +657,7 @@ where the data lives.
657657
| `os migrate value-shapes` | Scan stored reference and structured-JSON field values against the platform's value contract, and record the deployment's migration flag when clean |
658658
| `os migrate summary-nulls` | Backfill roll-up `count` / `sum` columns still stored as `NULL` on parent rows created before the insert-time seed. Repairs values; no flag, nothing depends on it having run |
659659
| `os migrate meta --stored` | Replay the metadata conversion chain over this deployment's `sys_metadata` rows and rewrite the ones still carrying a pre-protocol shape. Hygiene, not a gate — nothing depends on it having run |
660+
| `os migrate duplicates` | Report business identifiers already minted twice across the organization partitions — a read-only inventory as JSON on stdout. Renumbers nothing and writes nothing at all; run it before the boot-time tenancy repair, which overwrites part of the evidence |
660661

661662
```bash
662663
os migrate files-to-references # Dry run: full report, writes nothing
@@ -922,6 +923,69 @@ not one item — and answers `403` otherwise. Flows need no extra setup on this
922923
path: the server already holds a live automation engine, so the run resolves the
923924
executor registry the conflict guard needs from the process it is running in.
924925

926+
#### `os migrate duplicates`
927+
928+
The one command in this family that is **not** a migration: it writes nothing
929+
under any flag, and there is nothing to apply. It inventories business
930+
identifiers the platform already handed out twice — one value held by rows in
931+
more than one of the organization partitions a
932+
[`unique: 'organization'`](/docs/data-modeling/indexing) index separates.
933+
934+
The gap it reports is a real one and predates the repair for it. A seeded row
935+
written before any organization existed carries `organization_id = NULL`, an API
936+
row carries the signed-in organization, and the partitioned unique index
937+
`(COALESCE(organization_id, '__global__'), field)` does not bite across the two
938+
— so each side allocated from its own autonumber counter and both could mint
939+
`CASE-00001`.
940+
941+
```bash
942+
os migrate duplicates # The report — JSON on stdout
943+
os migrate duplicates > duplicates-2026-08-18.json # Archive it; the file is the deliverable
944+
os migrate duplicates --object crm_case # Restrict the scan to one object
945+
os migrate duplicates --database-url postgres://… # Inspect a database directly
946+
```
947+
948+
Output is **always** the JSON document. There is no `--json` flag and no
949+
human-rendered mode — the report is the deliverable, you archive it, and a
950+
second renderer would be a second contract to keep true. The boot behind it is
951+
read-only: no DDL, no seed, and a missing SQLite file is not brought into
952+
existence. A full run leaves the rows and the counters byte-identical, so
953+
pointing it at production changes nothing about production.
954+
955+
**Run it before the repair reaches this deployment.** On its first boot after
956+
the upgrade, a single-organization deployment adopts those untenanted seed rows
957+
into its organization and merges the two counters — which is the same state
958+
this report reads. Half of what it can tell you does not survive that:
959+
960+
| Reported | Survives the repair? |
961+
| :--- | :--- |
962+
| **The duplicates themselves** — every value held across two partitions, with the id, organization and creation time of each holder | **Yes.** The repair deliberately refuses to adopt a row whose identifier is already taken in the destination partition, so those rows keep `organization_id = NULL` and stay visible |
963+
| **The live condition** — an object still running a global counter beside an organization-scoped one, and therefore about to mint more duplicates | **No.** The repair merges the two counters and deletes the global one. Once that has happened, this line can never be produced again |
964+
965+
Running it afterwards is still worth doing — the inventory is what you act on,
966+
and it is complete either way. What you cannot recover is the forward-looking
967+
half.
968+
969+
A deployment holding **more than one organization** is skipped by that repair
970+
rather than guessed at: there is no derivable answer to which organization owns
971+
an untenanted row, so it logs the condition and the remedy and changes nothing.
972+
Its evidence therefore stays intact, and this report stays reproducible until
973+
somebody stamps those rows by hand.
974+
975+
Nothing is renumbered, here or by the repair. A business identifier that has
976+
already left the building — on an invoice, in a notification, in another
977+
system's idempotence key — is not the platform's to rewrite, so both sides
978+
report and stop. Deciding what a duplicate should become is yours.
979+
980+
The scan covers every organization-scoped object and, on it, every field that
981+
is an identifier: `type: 'autonumber'`, or carrying any `unique` spelling.
982+
Platform objects are **not** filtered out — that filter is right for a repair
983+
and wrong for a report, which must not silently omit a real duplicate. Anything
984+
that could not be probed is listed under `skipped` with its reason, because
985+
"found nothing" and "never looked" must not read the same. For the same reason
986+
a driver with no raw SQL seam (memory, MongoDB) fails the whole run with
987+
`error: "no_sql_seam"` rather than returning an empty inventory.
988+
925989
### Scaffolding
926990

927991
| Command | Alias | Description |

0 commit comments

Comments
 (0)