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
Update claude db migration skill reference with additional constraints (#2050)
## What
Expands the migrations guide in the `kagent-dev` skill reference
(`.claude/skills/kagent-dev/references/database-migrations.md`) to
document the migration safety constraints.
## Changes
New sections:
- **One Linear History** — migrations are immutable from merge (not
release); sequence numbers are claimed at merge and a draft on a feature
branch renumbers if `main` moved; a backward-compatible migration may
ship in the same PR as its code.
- **Rollback and ahead-schema tolerance** — failure-rollback vs.
version-rollback; the runner tolerates a database ahead of it; an
in-window version rollback runs no down files; the server does no
startup version arithmetic.
- **Upgrade and rollback testing** — previous-minor round-trip and
query-level backward-compatibility tests.
- **Schema-agnostic SQL** — migrations must not name a schema; the
connection selects it.
Reworked:
- **Backward compatibility and contraction** (was "Backward-compatible
schema changes") — rule stated by effect rather than DDL shape; expanded
compatibility table (new ❌ rows: new constraint on a shipped table, type
narrowing, data rewrite); windowed-contraction model with the **rollback
window defined as one minor back** (`Major.Minor.Patch` − 1 minor);
destructive DDL must be declared and reviewed.
- **Static Analysis Enforcement** — table gains two rows (contraction
guard, schema-agnostic lint).
- **sqlc Workflow**, **Down migrations**, **Downstream Extension
Model**, **Structure** — cross-links and short notes tying the above
together.
## Enforcement status
Gates that do not exist yet are marked **_Target — not yet enforced_**:
the contraction guard, schema-agnostic lint, previous-minor round-trip
and query-level tests, and the release-time patch/prefix checks. They
document the intended gates; building them is follow-up work. The checks
that exist today (`TestNoCrossTrackDDL`, `TestMigrationGuards`, sqlc
sync) are unchanged.
---------
Signed-off-by: Jeremy Alvis <jeremy.alvis@solo.io>
Co-authored-by: Eitan Yarmush <eitan.yarmush@solo.io>
Copy file name to clipboardExpand all lines: .claude/skills/kagent-dev/references/database-migrations.md
+89-18Lines changed: 89 additions & 18 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -30,6 +30,8 @@ go/core/internal/database/
30
30
31
31
Migrations manage two independent tracks — `core` and `vector` — and roll back both if either fails. The `--database-vector-enabled` flag (default `true`) controls whether the vector track runs.
32
32
33
+
Migration files are append-only and immutable once merged (see [One Linear History](#one-linear-history)).
34
+
33
35
## sqlc Workflow
34
36
35
37
When you add or change a SQL query:
@@ -48,26 +50,64 @@ A CI check (`.github/workflows/sqlc-generate-check.yaml`) fails the PR if `gen/`
48
50
-`:many` — returns a slice
49
51
-`:exec` — returns only error (use for INSERT/UPDATE/DELETE that don't need the result)
50
52
53
+
Because the hand-written queries are the source of truth for what code reads, sqlc's generated output makes "no current code reads this column" greppable — the check behind a contraction sign-off (see [Backward compatibility and contraction](#backward-compatibility-and-contraction)).
54
+
51
55
## Writing Migrations
52
56
53
-
### Backward-compatible schema changes
57
+
### Backward compatibility and contraction
54
58
55
-
During a rolling deploy, old pods will be reading and writing a schema that has already been upgraded. **Every migration must be backward-compatible with the previous version's code.**
59
+
During a rolling deploy — and after a version rollback — old pods read and write a schema a newer release has already migrated. **The default for every migration is backward-compatible: nothing a prior release's code reads or writes may stop working.** "Additive-only" is the usual shorthand, but it is imprecise — some additive DDL still breaks old code. State the rule by *effect*, not by DDL shape.
| Add column with `DEFAULT x`| INSERT omits it; DB fills default | ✅ |
61
-
| Add NOT NULL column **without** default | Old INSERT missing the column → error | ❌ |
62
65
| Add index | Invisible to application code | ✅ |
63
-
| Add foreign key | Old INSERT may fail constraint | ❌ |
64
-
| Drop/rename column old code references | Old SELECT/INSERT errors | ❌ |
65
-
| Change compatible type (e.g. `int` → `bigint`) | Usually fine | ⚠️ |
66
+
| Widen a compatible type (e.g. `int` → `bigint`) | Usually fine | ⚠️ |
67
+
| Add NOT NULL column **without** default | Old INSERT missing the column → error | ❌ |
68
+
| New constraint on a shipped table (FK / `UNIQUE` / `CHECK`) | Old writer violates it → error | ❌ |
69
+
| Narrow a column type | Existing/old-code value may no longer fit | ❌ |
70
+
| Drop or rename a column/table old code uses | Old SELECT/INSERT errors | ❌ |
71
+
| Rewrite stored rows into a new format | Old reader can't parse the new format | ❌ |
72
+
73
+
This is exactly what makes a rollback safe: when an operator redeploys the previous release against a database the newer release already migrated, the old code's queries still run because no contraction has shipped (see [Rollback and ahead-schema tolerance](#rollback-and-ahead-schema-tolerance)).
74
+
75
+
The last ❌ row is easy to miss: a migration — **or an out-of-band tool** — that rewrites existing rows breaks old readers the same way a `DROP COLUMN` does, with no DDL for static analysis to catch. A data rewrite is a contraction regardless of its SQL.
76
+
77
+
**Contractions are not banned — they are windowed.** Anything in the ❌ rows is a *contraction*. Forever-backward-compatible is not tenable (dead weight accumulates without bound), so a breaking change is split across minor versions such that no supported rollback target ever lands on code that needs the removed structure:
78
+
79
+
1.**Minor `X.Y` (expand):** add the new column/table (nullable or with default). Old code still works.
80
+
2.**Minor `X.Y` (deploy):** ship the code that uses the new structure.
81
+
3.**Minor `X.(Y+1)` (contract):** drop the old column/table — safe because the furthest supported rollback from `X.(Y+1)` lands on minor `X.Y`, which already uses the new structure.
82
+
83
+
The **rollback window** is how far back a rollback is supported: **one minor back**. From `Major.Minor.Patch`, an operator may roll back to an earlier release in the current minor, or to the previous minor — the previous minor is the furthest-back supported target. A contraction is therefore safe to merge only once its replacement shipped at or before the previous minor, so no supported rollback can land on code that predates it.
84
+
85
+
**Destructive changes must be declared, not silent.** An intentional contraction is allowed only with explicit reviewer sign-off confirming (1) the replacement shipped in the prior release and (2) no current code still reads the old structure — sqlc makes that second point checkable for Postgres, since the generated queries are greppable. Pre-rule contractions already in history are grandfathered; the rule binds going forward.
86
+
87
+
> **Enforcement.***Target — not yet enforced*: a static check extending `cross_track_test.go` will block undeclared destructive DDL at merge (see [Static Analysis Enforcement](#static-analysis-enforcement)).
88
+
89
+
### Schema-agnostic SQL
90
+
91
+
**Migration SQL must not name a schema.** The schema a migration lands in is chosen by the *connection* (its `search_path` / `current_schema`), not the file, so the same migration files apply into whatever schema the connection selects.
-- ✅ lands in whatever schema the connection selects
105
+
CREATETABLEIF NOT EXISTS eval_set (...);
106
+
```
107
+
108
+
Schema is a deploy-time choice, fixed by the connection rather than the migration file. A hard-coded schema breaks any deployment that runs the track in a different schema (e.g. a connection that sets `?search_path=<schema>`). The core and vector migrations comply today (verified by inspection until the lint lands).
66
109
67
-
**Expand-then-contract pattern for schema changes:**
68
-
1.**Version N (Expand)**: add the new column/table (nullable or with default); old code still works
69
-
2.**Version N (Deploy)**: ship new code that uses the new structure
70
-
3.**Version N+1 (Contract)**: drop the old column/table once version N is fully deployed and no pods run version N-1
110
+
> **Enforcement.** A static lint test rejecting the forbidden patterns across all migration files (*Target — not yet enforced*; see [Static Analysis Enforcement](#static-analysis-enforcement)).
71
111
72
112
### Idempotency and cross-track safety
73
113
@@ -93,6 +133,18 @@ Files must follow `NNNNNN_description.up.sql` / `NNNNNN_description.down.sql` wi
93
133
94
134
Every `.up.sql` must have a corresponding `.down.sql` that exactly reverses it. Down migrations are used for rollbacks and by automatic rollback on migration failure. They must be **idempotent** — the two-track rollback logic (roll back core if vector fails) may call them more than once in failure scenarios.
95
135
136
+
A down file that never runs is a down file you cannot trust. There are no up-only migrations — a working down has shipped with every migration since the golang-migrate adoption. Exercising every migration up → down → up against the real migration set, to prove the reversal rather than assume it, is a *Target — not yet enforced* (see [Upgrade and rollback testing](#upgrade-and-rollback-testing)).
137
+
138
+
## One Linear History
139
+
140
+
Migrations form a single, append-only sequence. Two rules keep it that way.
141
+
142
+
**Immutable from merge.** A migration file is never edited once it merges to `main` — not merely once it is released. The next build picks it up as soon as it merges, so editing it would diverge the schema state of any database that already applied it. A bug in a migration is fixed by a **new** compensating migration, never by editing the original in place.
143
+
144
+
**Sequence numbers are claimed at merge.** The 6-digit number is allocated when the migration lands on `main`. A feature branch carrying a draft migration **renumbers** before merging if `main` has moved on, so the sequence never forks or collides.
145
+
146
+
A backward-compatible migration may ship in the **same PR** as the code that uses it — the migration is additive, so old code tolerates the new schema during the rollout.
147
+
96
148
## Multi-Instance Safety
97
149
98
150
### How the advisory lock works
@@ -110,10 +162,7 @@ This is safe. The only risk is if the winning controller crashes mid-migration (
110
162
111
163
### Dirty state recovery
112
164
113
-
If the controller crashes mid-migration, the migration runner records the version as `dirty = true` in the tracking table. The next startup detects dirty state and calls `rollbackToVersion`, which:
114
-
1. Calls `mg.Force(version - 1)` to clear the dirty flag.
115
-
2. Runs the down migration to restore the previous clean state.
116
-
3. Re-runs the failed up migration.
165
+
If the controller crashes mid-migration, golang-migrate leaves the tracking table marked `dirty = true`. On the next startup `Up` refuses to run against a dirty database, so the runner clears the flag: `mg.Force(version - 1)` resets the tracking table to the last clean version. The process then exits with the error, and the failed migration is re-applied on a **subsequent** startup once the database is clean — so recovery from a transient failure spans restarts rather than completing in a single run.
117
166
118
167
**Requirement**: down migrations must be idempotent and correctly reverse their up migration. A missing or broken down migration requires manual recovery.
119
168
@@ -127,6 +176,18 @@ For backward-compatible migrations a rolling update is safe:
127
176
128
177
For a migration that is **not** backward-compatible, restructure it using the expand-then-contract pattern (add new column/table in version N, ship code that uses it, drop the old column in version N+1).
129
178
179
+
## Rollback and ahead-schema tolerance
180
+
181
+
Two distinct events both get called "rollback."
182
+
183
+
**A migration fails mid-upgrade.** The runner reverts the in-flight migration automatically and the process exits, leaving the database at the last clean version (see [Dirty state recovery](#dirty-state-recovery)). This has always worked.
184
+
185
+
**A version rollback after a successful upgrade.** A regression turns up and the operator redeploys the previous release against a database the newer release already migrated forward. The runner **tolerates a database ahead of it** — it sees a higher-than-known version, accepts it, and starts.
186
+
187
+
Tolerating an ahead database is only safe because of the [backward-compatibility window](#backward-compatibility-and-contraction): inside the window no contraction has shipped, so the old code's queries run against the newer schema by construction. The schema simply stays expanded until the operator re-upgrades. The server does **no** version arithmetic at startup — staying within the supported rollback window (one minor back) is the operator's responsibility, not a runtime check.
188
+
189
+
Down migrations are off this routine path. They are still authored and still run — by the failure-revert above, and for a deliberate schema reversal (run from the newer release, which ships the down files) — but a routine in-window version rollback touches no down file.
190
+
130
191
## Static Analysis Enforcement
131
192
132
193
The policies above are enforced by static analysis tests in `go/core/pkg/migrations/cross_track_test.go`. These run against the embedded SQL files — no database required.
@@ -135,21 +196,31 @@ The policies above are enforced by static analysis tests in `go/core/pkg/migrati
135
196
|------|-----------------|
136
197
|`TestNoCrossTrackDDL`| No track may `ALTER TABLE` or `CREATE INDEX ON` a table owned by another track |
137
198
|`TestMigrationGuards`| Up migrations must use `IF NOT EXISTS` on all `CREATE`/`ADD COLUMN`; down migrations must use `IF EXISTS` on all `DROP` statements |
199
+
| Contraction guard *(target)*| Blocks undeclared destructive DDL — `DROP`/`RENAME` of shipped objects, type narrowing, new constraints on shipped tables (see [Backward compatibility and contraction](#backward-compatibility-and-contraction)) |
200
+
| Schema-agnostic lint *(target)*| Rejects `CREATE SCHEMA`, schema-qualified DDL, `SET search_path`, and `ALTER ... SET SCHEMA` (see [Schema-agnostic SQL](#schema-agnostic-sql)) |
138
201
139
202
**Adding a new track**: add the track directory name to the `tracks` slice in each test so the new track is covered by the same checks.
140
203
141
204
These tests catch policy violations at PR time without needing a running database. They complement the integration tests in `runner_test.go`, which verify the runner's rollback and concurrency behavior against a real Postgres instance.
142
205
206
+
## Upgrade and rollback testing
207
+
208
+
Static analysis covers file *content*; round-trip tests cover *behavior* against a real Postgres. Beyond `runner_test.go` (rollback and concurrency), two release-to-release tests make the rollback promise real. Both are *Target — not yet enforced*.
209
+
210
+
**Previous-minor round-trip.** Seed a database at the previous minor's latest release with representative data, apply migrations up to `HEAD`, and assert the schema matches a clean `HEAD` install and the data survives; then reverse to the previous minor and assert the schema matches a clean previous-minor install and the data survives. This exercises every changed down file rather than only reviewing it.
211
+
212
+
**Query-level backward compatibility.** Run the previous minor's database test suite against a `HEAD`-migrated schema, proving old code's queries run against the newer schema — the exact property [ahead-schema tolerance](#rollback-and-ahead-schema-tolerance) relies on.
213
+
143
214
## Downstream Extension Model
144
215
145
-
The migration layer is designed for downstream consumers to extend with their own migrations alongside OSS. The extension points are:
216
+
The migration layer is designed for downstream consumers to extend with their own migrations. The extension points are:
146
217
147
218
1.**SQL files as the contract.** The migration files in `go/core/pkg/migrations/core/` and `vector/` are the stable interface. Downstream consumers sync these files into their own repos and build their own migration runners. Don't move or reorganize migration file paths without considering downstream impact.
148
219
149
-
2.**`MigrationRunner` DI callback.** Downstream consumers pass a custom `MigrationRunner` to `app.Start` to take full ownership of the migration process — running OSS migrations alongside their own in whatever order they need. The signature `func(ctx context.Context, url string, vectorEnabled bool) error` is stable.
220
+
2.**`MigrationRunner` DI callback.** Downstream consumers pass a custom `MigrationRunner` to `app.Start` to take full ownership of the migration process — running the core and vector migrations alongside their own in whatever order they need. The signature `func(ctx context.Context, url string, vectorEnabled bool) error` is stable.
150
221
151
222
3.**Vector track stays separate.** The vector track is conditionally applied and has its own tracking table. Downstream extensions should not modify vector-owned tables (enforced by `TestNoCrossTrackDDL`).
152
223
153
-
### What this means for OSS development
224
+
### What this means for development
154
225
155
-
-**Migration immutability is cross-repo.**Once a migration file is merged and tagged, downstream consumers may have synced it. Modifying it breaks their trackingtable state.
226
+
-**Migration immutability is cross-repo.**[Immutability](#one-linear-history) binds from the moment a migration merges to `main`, not from release: downstream consumers may have synced a merged file before it ships. Modifying it breaks their tracking-table state.
0 commit comments