feat(cli): add storage canonicalize-identity-keys admin subcommand - #1231
Conversation
Rows written before identity strings were folded at the write boundaries are invisible to folded lookups on PostgreSQL's byte-comparing collation: locks that cannot be released, checks that duplicate instead of updating. The one-time fold rewrites stored repository, database, environment, deployment, and lock-owner values to canonical lowercase; fold collisions fail naming the violated unique index for manual resolution.
There was a problem hiding this comment.
Pull request overview
Adds an operator-facing storage maintenance command to repair legacy PostgreSQL rows whose “identity key” strings were stored with mixed casing, making them invisible to newer lowercase-folded lookups (unlike MySQL’s case-insensitive collation). This fits the codebase’s operational tooling by extending the schemabot storage admin surface with a safe, idempotent one-time fix for existing storage data.
Changes:
- Added
schemabot storage canonicalize-identity-keysCLI subcommand to fold stored identity strings to canonical lowercase on PostgreSQL. - Implemented
api.CanonicalizePostgresIdentityKeysto rewrite only non-canonical rows per table/column map, with explicit unique-collision failure reporting. - Added unit + integration tests to pin the per-table column map to embedded schema and validate canonicalization behavior (including collision and non-storage DB refusal).
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| pkg/cmd/commands/storage.go | Adds the new admin subcommand and refactors DSN resolution into a shared helper. |
| pkg/cmd/commands/storage_test.go | Updates DSN-resolution tests to target the new shared resolveStorageDSN helper. |
| pkg/cmd/commands/storage_integration_test.go | Adds integration coverage for running the new CLI command end-to-end against Postgres storage. |
| pkg/api/canonicalize_postgres_identity.go | Implements table-by-table lowercase folding with collision detection and safety checks. |
| pkg/api/canonicalize_postgres_identity_test.go | Adds a parity test to ensure the canonicalization column map matches embedded Postgres schema files. |
| pkg/api/canonicalize_postgres_identity_integration_test.go | Adds integration tests validating folding behavior, idempotency, collision reporting, and non-storage refusal. |
| docs/configuration.md | Documents the upgrade-time operational procedure and collision-handling expectations. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Review follow-up: the rewrite is one-way, so prompt before touching rows; refuse non-PostgreSQL --dsn values up front; fold caller-supplied lock owners at acquire/release so the byte-exact release match holds across the fold. The schema parity test now runs both directions, and the docs state the every-writer precondition and the quiesced-window requirement.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
morgo
left a comment
There was a problem hiding this comment.
🤖 Approved on Morgan's behalf by his AI agent. This is the backfill I asked for on #1218 — thanks for building it, and the merge-order precondition is the right call.
Now fully green. The design holds up where it counts: rewrites only non-canonical rows so reruns are safe, per-table implicit transactions so a collision in one table leaves earlier folds applied, unique-violation (SQLSTATE 23505) caught and re-raised naming the constraint, PostgreSQL-only with a dialect refusal, and a confirmation prompt because the rewrite is one-way. The "none of the N storage tables exist" guard is a nice touch against being pointed at the wrong database.
The 8-line live half in lock_handlers.go is correct and symmetric — both acquire and release fold, and the comment names the exact bug it prevents. It's inert on MySQL, where ai_ci still matches a folded release predicate against a mixed-case stored owner, so the live production path is untouched.
Two things worth having on the record before someone runs this.
1. This folds in SQL, and the rest of the series folds in Go. #1218 states the convention explicitly — "Go-side folds only; no SQL LOWER()." Here foldPostgresIdentityKeys emits SET col = lower(col) WHERE col <> lower(col). That's two different implementations of "canonical" now, and PostgreSQL's lower() is collation-dependent while Go's strings.ToLower is locale-independent Unicode folding. They agree on ASCII, which is why CanonicalKey's doc says the ASCII assumption is deliberate — but that assumption is now load-bearing in two places with two different functions behind it.
The pathological case is a Turkish-collated database, where lower('I') is 'ı' (dotless) but Go produces 'i': the fold would rewrite a row to a spelling the Go-side lookups can never generate, turning "invisible row" into "permanently invisible row." Vanishingly unlikely for a storage DB, and GitHub restricts repo and owner names to ASCII — but deployment, environment, and database_name are operator-controlled and not similarly constrained. A sentence in the function doc pinning the ASCII assumption (or asserting the DB collation) would close it; I wouldn't hold the PR for it.
2. The ordering creates a transient stuck-lock window, by construction. The live owner fold ships with this PR, but the command is documented to run last, after every writer is upgraded. In between, a lock acquired by an earlier release stores CLI:User@Host verbatim; a release request arriving after the deploy folds the incoming owner to cli:user@host and compares byte-exact, so it won't match — exactly the "acquire a lock it can never release" case the new comment describes, just with the two spellings on opposite sides of the deploy rather than within it.
It's narrow (PostgreSQL only, caller-supplied owners only, only locks spanning the deploy, and only when the release path supplies an owner — it isn't required there), and lock leases should time it out rather than leaving it wedged forever. But it's inherent to the sequencing rather than avoidable, so it belongs in the runbook next to the merge-order note: operators may need to wait out or hand-clear locks held across the upgrade, and the fold cures the rest.
Nit: nothing enforces the "run with the server quiesced" precondition — it's documented on the function and in the help text, but an operator running it under live traffic gets row locks that make FOR UPDATE SKIP LOCKED claims silently skip rows. Given --auto-approve exists for scripted windows, that's a reasonable place to trust the operator; just noting the guard is prose, not code.
|
🤖 Adversarial correctness review, requested by Armand and performed by his review agent. Reviewed at head 6fee86b. Verdict: correct, fail-closed, and safe to land — no blocking findings. I attacked the fold's failure directions, the collision path, the rerun semantics, the owner-fold symmetry, and the wrong-database guard, and the design held everywhere it matters: the Findings1. (doc) 2. (minor, optional) The fold's canonical form is SQL Action items
Verified (tried to break it, couldn't)Fail-closed behavior and blast radius
Rewrite semantics
Map ↔ schema lockstep
Owner-fold symmetry
Tests and CI
This review was performed by Cato, an AI review agent operated by Armand. |
aparajon
left a comment
There was a problem hiding this comment.
🤖 Approving on Armand's behalf after the adversarial correctness review above (no blocking findings; the two doc-level action items are trusted to the author before merge). Merge-order condition from the PR body carries to merge time: land this PR last in the canonicalization series, and run the command only after every writer runs a folding release. This stamp was left by Cato, an AI review agent operated by Armand.
The plan comment store persists environment_scope as given because its consumers compare it in Go against configured environment names, so the backfill must not fold rows the writer preserves. Also correct the lock owner note in CanonicalKey's docs: the lock API folds caller-supplied owners at acquire and release.
morgo
left a comment
There was a problem hiding this comment.
🤖 Re-approving on Morgan's behalf at 617ec986 (automated review).
Force-pushed past my earlier approval, so I re-verified the delta.
fix(cli): exclude plan comment environment scope from the identity fold drops environment_scope from the plan_comments entry in postgresIdentityKeyColumns, matching #1218's store-side reversal. I verified the shared premise on #1218: environment_scope never appears in a WHERE clause in plan_comments.go, so folding it gained nothing in SQL and broke the Go-side comparison against a scope rebuilt from configured environment names. Excluding it from the backfill is the correct other half — a backfill that folded rows the store no longer folds would recreate the mismatch on existing data.
The added note that lower() in SQL and storage.CanonicalKey in Go agree because identity strings are ASCII is worth having written down; it's the assumption that makes a SQL-side backfill and a Go-side ingress fold interchangeable, and it was previously implicit.
CI green.
Two notes, neither blocking.
Merge-order collision with #1217. Both PRs rewrite the same CanonicalKey doc block in pkg/storage/canonical.go with different accounts of lock owners — here "the lock API folds a caller-supplied owner (cli:user@host) with this function at acquire and release", on #1217 "deliberately not folded … compared byte-wise on both sides." Different boundaries, both true, but they conflict textually and a careless resolution leaves the doctrine inconsistent. Whichever lands second should state both.
Deploy ordering for the lock-owner fold. Folding caller-supplied owners at acquire and release is self-consistent going forward, but a lock acquired before this ships stores an unfolded owner (CLI:User@Host), and a release afterwards folds the supplied value and no longer matches — the "acquire a lock it can never release" hazard, inverted across the deploy boundary. locks.owner is in postgresIdentityKeyColumns, so the backfill closes it; worth calling out in the rollout notes that it should run before or promptly after, rather than leaving it to lock expiry.
|
🤖 Review response — created by Kiran's code review agent (Amp, Claude Opus 4.5) — pull/1231, follow-up commit Both doc findings fixed; a third row records a cross-PR consistency fix made in the same change so the backfill map matches what #1218's writer now stores.
|
…ditive-convergence * origin/main: (33 commits) feat(postgres): add ADD COLUMN synthesis to the statement parser seam (#1212) feat(cli): add storage canonicalize-identity-keys admin subcommand (#1231) fix(storage): canonicalize remaining identity keys (#1218) fix(storage): canonicalize apply and task identity keys (#1217) fix(webhook): canonicalize repository identity at ingress (#1213) docs: document the PostgreSQL support envelope (#1144) fix(engine): report why a Vitess schema change failed (#1242) feat(ddl): detect statements whose cost scales with table size (#1237) fix(operator): keep a multi-table apply running while tables are queued behind a cutover (#1241) fix(storage): index the webhook inbox claim ordering (#1196) fix(github): drop the cutover duration promise from progress surfaces (#1240) fix(github): render row-copy progress percentages at their true precision (#1239) fix(observability): do not report a shutdown as a claim failure (#1233) fix(github): tell an operator why a refused apply's database is busy (#1224) fix(engine): do not mark an apply failed when its driver shuts down (#1234) feat(github): render live row-copy progress on sharded table lines (#1191) feat(ui): add approximate row and byte formatters (#1236) fix(planetscale): delete the branch an apply created when it fails before its deploy request (#963) feat(api): app grouping field on database config (#1226) feat(cli): filter pulled tables with --table (#1235) ... # Conflicts: # docs/configuration.md # pkg/ddl/postgres_parser.go # pkg/ddl/postgres_parser_test.go
Adds a
storage canonicalize-identity-keysadmin subcommand that one-time folds stored identity strings (repository, database, environment, deployment, lock owner) to canonical lowercase on PostgreSQL storage.Why
The canonicalization series folds identity strings to canonical lowercase at the write boundaries; rows written by releases without those boundaries keep their original spelling. MySQL's accent- and case-insensitive storage collation forgives that drift; PostgreSQL compares bytes, so legacy-cased rows are invisible to folded lookups — locks that can never be released, checks that duplicate instead of updating, release/cleanup predicates that miss existing rows.
Merge order: this PR merges last in the series. The command must only run once every writer — server and workers — runs a release that folds at the write boundaries; a writer still on an earlier release would keep writing mixed-case rows and can turn the fold into duplicate-key collisions. The docs and the command's own help state this precondition.
What
CanonicalizePostgresIdentityKeysrewrites only non-canonical rows, table by table in per-table transactions, driven by a per-table column map kept in lockstep with the embedded schema files by a bidirectional parity test (every mapped column exists in the schema; every identity-named schema column is mapped or explicitly excluded with a rationale). Fold collisions fail naming the violated unique index for manual resolution — tables already folded stay folded, and a rerun folds the rest.storage canonicalize-identity-keysCLI subcommand wires it up. The rewrite is one-way — original spellings are not recorded — so it prompts before touching rows (--auto-approve/-yfor scripted maintenance windows). A direct--dsnthat does not parse as PostgreSQL is refused up front, mirroring the config path's dialect refusal.Before / after