You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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>
Copy file name to clipboardExpand all lines: content/docs/deployment/cli.mdx
+64Lines changed: 64 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -657,6 +657,7 @@ where the data lives.
657
657
|`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 |
658
658
|`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 |
659
659
|`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 |
660
661
661
662
```bash
662
663
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
922
923
path: the server already holds a live automation engine, so the run resolves the
923
924
executor registry the conflict guard needs from the process it is running in.
924
925
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.
0 commit comments