fix(storage): canonicalize remaining identity keys - #1218
Conversation
Completes the store-boundary backstop: plans, plan comments, and webhook events get the same Go-side identity fold as locks/checks and applies/tasks so no table is left half-canonical and cross-table predicates behave identically on MySQL and PostgreSQL. Survey confirmed no other store carries identity predicates. Includes cross-dialect parity subtests.
There was a problem hiding this comment.
Pull request overview
This PR completes storage-layer identity canonicalization for the remaining stores (plans, plan comments, webhook events) by folding repository/database/environment identity keys to a single canonical spelling at store boundaries, ensuring consistent behavior across MySQL (case-forgiving collation) and PostgreSQL (byte-wise comparisons).
Changes:
- Introduces
storage.CanonicalKeyand applies it to identity fields on create/insert paths and on read/list/delete predicates in the affected SQL stores. - Adds/extends storage parity tests to assert identity canonicalization behavior for plans, plan comments, and webhook event coalescing/head-coverage queries.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| pkg/storage/canonical.go | Adds shared helper for lowercasing identity keys to a canonical form. |
| pkg/storage/internal/sqlstore/plans.go | Canonicalizes plan identity fields on Create and folds repo/db/env filters on query/delete paths. |
| pkg/storage/internal/sqlstore/plan_comments.go | Canonicalizes plan comment identity fields on Insert and folds repo/db/dbType on list queries. |
| pkg/storage/internal/sqlstore/webhook_events.go | Canonicalizes repository key on Create and on repository-based lookup/coalescing paths. |
| pkg/storage/storagetest/plans.go | Adds parity test ensuring PlanStore canonicalizes identity keys across dialects. |
| pkg/storage/storagetest/plan_comments.go | Adds parity test ensuring PlanCommentStore canonicalizes identity keys across dialects. |
| pkg/storage/storagetest/webhook_events.go | Adds parity test ensuring WebhookEventStore canonicalizes repository key for head-coverage and coalescing behaviors. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
HasCoveringSuccessor and SupersedeIfCovered now fold into local copies so the documented read-only probe contract holds; Create/Insert in-place canonicalization is stated on the store godocs, and the cross-dialect load-bearing assertions are marked in storagetest. Addresses external review of pull/1218.
Deriving the successor's ReceivedAt from the claimed event's stored timestamp instead of the wall clock makes the newness ordering in the canonicalization parity test deterministic.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
🤖 Adversarial correctness review, requested by @aparajon and performed by their agent. Reviewed at head Verdict: the survey behind this one holds up — I checked every remaining store myself and the three you folded really are the only ones carrying identity predicates — but one of the four columns folded here is the wrong kind of key, and it is the one nothing queries on. Worth noting for whoever merges: with #1216 in and #1217 still open, the storage layer is half-folded on Findings1. 2. No backfill, so the "before" behavior in your diagram survives for rows already written. Same shape as the rest of the family, and this is the fourth PR carrying it, so it is worth one decision rather than four. On PostgreSQL a pre-canonical 3. (nit) One store now has two opposite mutation contracts, and the interface documents only one. Action items
Verified (tried to break, couldn't)The survey claim is the load-bearing one and it holds: I read every remaining store file and This review was generated by Claude Code (claude-opus-5). |
aparajon
left a comment
There was a problem hiding this comment.
🤖 Approving on @aparajon's behalf after the adversarial correctness review above. The findings there are yours to pick up as follow-ups — flagging them, not gating on them.
This stamp was left by Claude Code (claude-opus-5).
morgo
left a comment
There was a problem hiding this comment.
🤖 Approved on Morgan's behalf by his AI agent.
37/37 green, +137/-6, and it completes the storage-layer series cleanly. I verified the parts that could have made it a partial no-op rather than trusting the description or the tests:
SupersedeIfCoveredactually folds into the query, not just the guard. The diff only shows the error string switching to the localrepository, which would be cosmetic on its own. Read at head, it also doesqueryEvent := *event; queryEvent.Repository = repositoryand passes&queryEventintocoveringSuccessorQuery, so the fold reachessuccessorArgs. The outerUPDATEpredicate isid = ? AND lease_token = ?with no repository term, so the subquery was the only place it mattered — correct.- The read/write mutation split is deliberate and right. Write paths (
plans.Create,plan_comments.Insert,webhook_events.Create) fold in place; read paths (HasCoveringSuccessor,SupersedeIfCovered) copy the struct or use a local and leave the caller's event alone. Thestoragetestassertions pin both halves (assert.Equal(t, "MIXEDCASE/SAMPLE-REPO", claimed.Repository)after two read calls). That's a better contract than the uniform in-place folding in #1217, and documenting it on the interfaces instorage.gocloses the nit I left there. - Guard equivalence holds:
CanonicalKey("")is"", so moving the empty checks onto the folded value changes nothing.
The finding worth carrying forward — and it isn't specific to this PR. The Before/After frames orphaned plan rows and duplicate dispatch as the bug being fixed, but folding fixes them going forward only. There's no backfill here, and identity keys arrive from GitHub with stable casing, so for any repository whose name contains capitals, rows written before this deploys are stored SomeOrg/Repo and rows written after are someorg/repo. On PostgreSQL, DeleteByPR("someorg/repo", …) will never match the old ones — so the orphan class described in the PR body is prevented, not remediated, and the pre-deploy orphans become permanent. Same shape for HasEventForHead (one duplicate dispatch per in-flight head at deploy time) and for ListUnminimizedForSlot (old comments stop being minimized).
Not blocking, for three reasons: MySQL is unaffected (utf8mb4_0900_ai_ci still matches the old rows against folded predicates, so the live production path is inert here); it isn't a regression, since those rows were already mismatched against the stores folded by #1213/#1216/#1217; and the direction of travel is right. But this is the fourth PR widening the same surface, and the backfill is a single cross-cutting job rather than something any one of these PRs should carry. Worth one follow-up issue covering all four stores — or an explicit "PostgreSQL deployments predating the canonicalization series need a one-time UPDATE … SET repository = LOWER(repository) sweep" note in the release notes, so an operator isn't left to discover it through a cleanup that silently stops working.
Two nits, neither blocking:
- The new sentences in
storage.goare appended to the type doc comment (// Create canonicalizes …directly abovetype PlanStore interface), whileCreatehas its own doc comment a few lines below. The method comment is the natural home; as placed, the contract reads as belonging to the interface rather than the call. plans.Listfoldsopts.Database/Environmentbefore the limit validation, so a bad-limit error now echoes the folded spelling back at the caller (list plans for database "proddb"when they passedProdDB). Purely diagnostic.
|
🤖 Adversarial correctness review (sweep pickup). Reviewed at head 47d1c80. Verdict: correct and safe to land. Two prior agent reviews already sit on this PR; I verified their load-bearing claims independently rather than taking them on trust, and everything checked out. My own attack pass found no new correctness failure — the findings below are cross-references plus one piece of good news. Findings1. The prior review's environment-scope finding stands, and I confirmed both halves independently. 2. (resolved) The backfill question the prior review asked the family to settle is settled — by #1231. The 3. (nit, carried) Action items
Verified (tried to break, couldn't)The survey claim (the load-bearing one)
Fold placement and callers
Tests
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. The findings there are yours to pick up as follow-ups — flagging them, not gating on them.
This stamp was left by Cato, an AI review agent operated by Armand.
EnvironmentScope is not a query predicate; the minimizer compares the stored value in Go against a scope built from configured environment names, so folding only the stored side breaks that comparison. Also fold the webhook event repository after input validation, and document that the coalescing reads leave the caller's event untouched.
morgo
left a comment
There was a problem hiding this comment.
🤖 Re-approving on Morgan's behalf at 11d317a5 (automated review).
Force-pushed past my earlier approval, and this one carries a genuine semantic reversal, so I checked the premise rather than the commit message.
fix(storage): stop folding plan comment environment scope removes EnvironmentScope from canonicalizePlanCommentIdentity. The whole change rests on one claim — that no query predicate filters on it — and that claim is true. In plan_comments.go at this head, environment_scope appears only in planCommentColumns, the INSERT column list, and the scan target. All three WHERE clauses filter on repository, pull_request, database_name, database_type, id, and minimized_at; none touches it.
So the fold was buying nothing on the SQL side while actively breaking the Go side, where consumers compare the stored value against a scope rebuilt from configured environment names. Folding only the stored half of that comparison is precisely the asymmetry that produces a silent mismatch. Reversing it is correct, and the storagetest change asserting "Production,Staging" survives verbatim pins it properly.
The two mutation-hygiene commits are also right: moving the Create fold below the required-field validation means a rejected event no longer comes back with its Repository quietly rewritten, and leaving the caller's event untouched in the coalescing probes matches what the interface doc now promises.
CI green after the main merge, which is the part that actually matters for a merge commit in the stack.
Merge-order note, filed identically on #1231: that PR drops environment_scope from plan_comments in postgresIdentityKeyColumns, so the backfill and the store agree. They need to land together or in that order — a deploy running #1218's store logic against #1231's older column list would fold stored rows the store has stopped folding, reintroducing exactly the mismatch this commit removes.
|
🤖 Review response — created by Kiran's code review agent (Amp, Claude Opus 4.5) — pull/1218, follow-up commit All four findings addressed: the environment-scope fold is removed (the column is compared in Go, never queried), the backfill question is settled by #1231, and both nits are fixed. Severity-ordered;
|
…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
Fold the remaining store identity keys — plans, plan comments, and webhook events — completing the storage-layer canonicalization backstop.
Why
With locks/checks and applies/tasks folded by sibling PRs, any store left unfolded would make the layer half-canonical: cross-table predicates and cleanup paths would diverge between MySQL (ai_ci, forgiving) and PostgreSQL (byte-wise). A survey of every remaining store file confirmed these three are the only ones carrying identity predicates.
What
plans.go:Create,GetByPR,DeleteByPR,Listfold identity fields (repository, database, type, environment).plan_comments.go:Insert,ListUnminimizedForSlot,ListUnminimizedForRepoPRfold the same fields.webhook_events.go:Create,HasEventForHead,HasCoveringSuccessor,SupersedeIfCoveredfold the repository key.LOWER().Before / after