Skip to content

Commit 2c2c162

Browse files
Merge pull request #69 from wack/claude/generate-import-integrity-EXRML
2 parents 4213fe0 + b6d4936 commit 2c2c162

1 file changed

Lines changed: 387 additions & 0 deletions

File tree

Lines changed: 387 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,387 @@
1+
# Design Document: Integrity Verification for Generate and Import Commands
2+
3+
## Overview
4+
5+
This document describes integrity verification requirements for the `tern generate` and `tern import` commands. These commands create new migrations by comparing a "source" state (derived from existing migrations) against a "target" state (from schema.sql or a live database). Ensuring the source state is accurate is critical for generating correct migrations.
6+
7+
This document complements the main [Migration Integrity Verification](./migration-integrity-verification.md) design, which covers verification during `migrate up` and `migrate down` commands.
8+
9+
## Command Comparison
10+
11+
| Command | Source State | Target State | Database Connection |
12+
|---------|--------------|--------------|---------------------|
13+
| `tern generate` | Local migrations | Local `schema.sql` via PGLite | No |
14+
| `tern import` | Local migrations | Live database | Yes |
15+
16+
Both commands share the same source: the schema state reconstructed by replaying local migration operations. If the local migration files are corrupted or inconsistent, the source state will be wrong, and the generated migration will be incorrect.
17+
18+
## Why Verification Matters
19+
20+
### The Source State Problem
21+
22+
When generating a migration, Tern computes:
23+
24+
```
25+
diff(source_state, target_state) → migration_operations
26+
```
27+
28+
If `source_state` is wrong, the diff produces incorrect operations:
29+
30+
**Scenario: Broken migration chain**
31+
```
32+
Migration 1: CREATE TABLE users (id INT)
33+
Migration 2: ADD COLUMN users.name [parent_hash: <hash of state after migration 1>]
34+
Migration 3: ADD COLUMN users.email [parent_hash: WRONG - file was corrupted]
35+
36+
Tern reconstructs state from migrations...
37+
→ source_state is indeterminate or wrong
38+
→ generate produces incorrect diff
39+
```
40+
41+
**Scenario: Modified migration file (import)**
42+
```
43+
Migration 3 applied to database with: ADD COLUMN status VARCHAR(50)
44+
Developer modifies migration 3 locally: ADD COLUMN status user_status_enum
45+
Developer runs `tern import`...
46+
47+
Local source_state: has ENUM column
48+
Database target_state: has VARCHAR column
49+
Diff: ALTER COLUMN status TYPE user_status_enum ← Wrong! Should be no diff for this column
50+
```
51+
52+
### Different Verification Needs
53+
54+
The two commands have different verification requirements because they serve different purposes:
55+
56+
| Aspect | `generate` | `import` |
57+
|--------|------------|----------|
58+
| Purpose | Capture local schema.sql changes | Capture live database drift |
59+
| Has DB connection | No | Yes |
60+
| Can verify against `tern.migrations` table | No | Yes |
61+
| Schema checksum mismatch expected? | No | **Yes** (that's the point) |
62+
63+
## Proposed Verification
64+
65+
### For `tern generate`
66+
67+
Since there's no database connection, we can only verify local consistency.
68+
69+
**Verification: Migration chain integrity**
70+
71+
Before generating, verify that the local migration chain is self-consistent:
72+
- Each migration's `parent_state_hash` matches the previous migration's `resulting_state_hash`
73+
- No gaps in the chain
74+
- Index file matches actual migration files
75+
76+
```
77+
generate (proposed flow):
78+
79+
1. Load local migration index
80+
2. VERIFY: Migration chain integrity
81+
→ For each migration[i], check:
82+
migration[i].parent_state_hash == migration[i-1].resulting_state_hash
83+
→ If mismatch: Error "Migration chain is broken"
84+
3. Reconstruct source_state from migrations
85+
4. Load target_state from schema.sql (via PGLite)
86+
5. Compute diff and generate migration
87+
6. Record new migration
88+
```
89+
90+
**Error message for broken chain:**
91+
```
92+
Error: Migration chain integrity check failed
93+
94+
Migration 00004 (d4e5f6a7) has an invalid parent hash.
95+
96+
Expected parent: b2c3d4e5... (from migration 00003)
97+
Actual parent: 9a8b7c6d... (in migration 00004)
98+
99+
This indicates the migration file was modified or corrupted.
100+
101+
To resolve:
102+
Option 1: Restore migration 00004 from version control
103+
Option 2: Run 'tern verify chain' for detailed diagnostics
104+
Option 3: Use --force to skip verification (dangerous)
105+
```
106+
107+
### For `tern import`
108+
109+
Since `import` connects to a database, we can verify against the `tern.migrations` table.
110+
111+
**Verification 1: Migration chain integrity (local)**
112+
113+
Same as `generate` — verify local files are self-consistent.
114+
115+
**Verification 2: Migration hash verification (against database)**
116+
117+
Before importing, verify that local migration files match what was applied to this database:
118+
- For each migration in `tern.migrations`, compute BLAKE3 hash of local `up_operations`
119+
- Compare against stored `migration_hash`
120+
- If mismatch, the local "source" state doesn't match what the database was built from
121+
122+
**NOT verified: Schema checksum**
123+
124+
The schema checksum should NOT be verified during `import` because:
125+
- The purpose of `import` is to capture schema drift
126+
- A mismatching schema is expected — that's why you're running `import`
127+
- Requiring schema match would make `import` useless
128+
129+
```
130+
import (proposed flow):
131+
132+
1. Load local migration index
133+
2. VERIFY: Migration chain integrity (local)
134+
3. Connect to database
135+
4. Load tern.migrations table
136+
5. VERIFY: Migration hash integrity
137+
→ For each recorded migration, compare local hash vs stored hash
138+
→ If mismatch: Error "Local migrations don't match applied migrations"
139+
6. Reconstruct source_state from local migrations
140+
7. Load target_state from live database
141+
8. Compute diff and generate migration
142+
9. Record new migration (locally and in tern.migrations)
143+
```
144+
145+
**Error message for migration hash mismatch:**
146+
```
147+
Error: Local migration files don't match applied migrations
148+
149+
Migration 00003 (a1b2c3d4) has been modified since it was applied to this database.
150+
151+
Applied hash: 5e6f7a8b9c0d...
152+
Local hash: 1a2b3c4d5e6f...
153+
154+
The local migration file differs from what was applied to the database.
155+
Running 'import' would generate an incorrect migration.
156+
157+
To resolve:
158+
Option 1: Restore migration 00003 from version control
159+
Option 2: Pull the correct migration files from the team
160+
Option 3: Use --force to skip verification (dangerous)
161+
162+
Warning: Proceeding with mismatched migrations can cause schema
163+
inconsistencies between environments.
164+
```
165+
166+
## Verification Summary
167+
168+
| Check | `generate` | `import` | Rationale |
169+
|-------|------------|----------|-----------|
170+
| Migration chain integrity (local) | **Yes** | **Yes** | Ensures source state is derived from valid chain |
171+
| Migration hash vs `tern.migrations` | N/A | **Yes** | Ensures local files match what was applied |
172+
| Schema checksum vs `tern.migrations` | N/A | **No** | Drift is the purpose of import |
173+
174+
## Implementation Changes
175+
176+
### 1. Add Chain Verification to Generate
177+
178+
**File:** `src/cli/generate/mod.rs`
179+
180+
```rust
181+
impl Generate {
182+
pub async fn dispatch(self) -> miette::Result<()> {
183+
let backend = load_backend(self.path.as_deref());
184+
ensure_backend_initialized(&backend).await?;
185+
186+
// NEW: Verify migration chain integrity
187+
if !self.force {
188+
backend.verify_chain().await
189+
.map_err(|e| miette::miette!("Migration chain verification failed: {}", e))?;
190+
}
191+
192+
// Load the source state (current state from migrations)
193+
let source = backend.get_current_state().await.into_diagnostic()?;
194+
// ... rest of existing implementation
195+
}
196+
}
197+
```
198+
199+
### 2. Add `--force` Flag to Generate
200+
201+
**File:** `src/cli/generate/mod.rs`
202+
203+
The `--force` flag already exists for skipping destructive change confirmation. Extend its meaning to also skip chain verification:
204+
205+
```rust
206+
#[derive(Debug, Clone, Args)]
207+
pub struct Generate {
208+
// ... existing fields ...
209+
210+
/// Skip confirmation prompt for destructive changes AND skip integrity verification
211+
#[arg(long)]
212+
pub force: bool,
213+
}
214+
```
215+
216+
Update the help text to clarify:
217+
```
218+
--force Skip confirmation prompts and integrity verification (dangerous)
219+
```
220+
221+
### 3. Add Migration Hash Verification to Import
222+
223+
**File:** `src/cli/import/mod.rs`
224+
225+
```rust
226+
use crate::db::execution::tracker::MigrationTracker;
227+
228+
impl Import {
229+
pub async fn dispatch(self) -> miette::Result<()> {
230+
let backend = load_backend(self.path.as_deref());
231+
ensure_backend_initialized(&backend).await?;
232+
233+
// NEW: Verify local chain integrity
234+
if !self.force {
235+
backend.verify_chain().await
236+
.map_err(|e| miette::miette!("Migration chain verification failed: {}", e))?;
237+
}
238+
239+
println!("Connecting to database...");
240+
let client = db::connect(&db_url).await.into_diagnostic()?;
241+
242+
// NEW: Verify migration hashes against database
243+
if !self.force {
244+
let tracker = MigrationTracker::new(&client, &self.schema);
245+
self.verify_migration_hashes(&backend, &tracker).await?;
246+
}
247+
248+
// ... rest of existing implementation
249+
}
250+
251+
async fn verify_migration_hashes(
252+
&self,
253+
backend: &LocalFileBackend,
254+
tracker: &MigrationTracker<'_>,
255+
) -> miette::Result<()> {
256+
// Load all local migrations with their hashes
257+
let local_migrations = backend.get_all_migrations().await.into_diagnostic()?;
258+
let local_hashes: Vec<(MigrationId, String)> = local_migrations
259+
.iter()
260+
.map(|m| (m.id, compute_migration_hash(m)))
261+
.collect();
262+
263+
// Verify against database
264+
let diverged = tracker.verify_history(&local_hashes).await
265+
.map_err(|e| miette::miette!("Failed to verify migration history: {}", e))?;
266+
267+
if let Some((id, expected, actual)) = diverged.first() {
268+
return Err(miette::miette!(
269+
"Local migration files don't match applied migrations\n\n\
270+
Migration {} has been modified since it was applied.\n\n\
271+
Applied hash: {}\n\
272+
Local hash: {}\n\n\
273+
Restore the original migration from version control or use --force.",
274+
&id[..16.min(id.len())],
275+
&expected[..16.min(expected.len())],
276+
&actual[..16.min(actual.len())]
277+
));
278+
}
279+
280+
Ok(())
281+
}
282+
}
283+
```
284+
285+
### 4. Add `--force` Flag to Import
286+
287+
**File:** `src/cli/import/mod.rs`
288+
289+
```rust
290+
#[derive(Debug, Clone, Args)]
291+
pub struct Import {
292+
// ... existing fields ...
293+
294+
/// Skip integrity verification (dangerous)
295+
#[arg(long)]
296+
pub force: bool,
297+
}
298+
```
299+
300+
### 5. Expose `compute_migration_hash` Function
301+
302+
The `compute_migration_hash` function is currently private in `executor.rs`. Move it to a shared location:
303+
304+
**File:** `src/db/execution/mod.rs`
305+
306+
```rust
307+
mod hash;
308+
pub use hash::compute_migration_hash;
309+
```
310+
311+
**File:** `src/db/execution/hash.rs`
312+
313+
```rust
314+
use crate::db::state::Migration;
315+
316+
/// Computes the BLAKE3 hash of a migration's up_operations.
317+
///
318+
/// This hash is used to detect if a migration has been modified since it was
319+
/// applied. Only the up_operations array is hashed, not the description or
320+
/// timestamps, allowing descriptions to be updated without triggering
321+
/// divergence errors.
322+
pub fn compute_migration_hash(migration: &Migration) -> String {
323+
let mut hasher = blake3::Hasher::new();
324+
let ops_json = serde_json::to_vec(&migration.up_operations)
325+
.expect("operations should be serializable");
326+
hasher.update(&ops_json);
327+
hasher.finalize().to_hex().to_string()
328+
}
329+
```
330+
331+
## Handling Edge Cases
332+
333+
### Fresh Database (No `tern.migrations` Table)
334+
335+
When `import` connects to a database without a `tern.migrations` table:
336+
- Skip migration hash verification (nothing to verify against)
337+
- This is the baseline import scenario
338+
339+
### Partial Migration History
340+
341+
If the database has fewer migrations than local:
342+
- Only verify the migrations that exist in both
343+
- The import will capture changes from where the DB left off
344+
345+
### More Migrations in Database Than Local
346+
347+
If the database has migrations not present locally:
348+
- This is a sync error — local is behind
349+
- Error with suggestion to pull latest migrations
350+
351+
```
352+
Error: Database has migrations not present locally
353+
354+
The database has 10 migrations applied, but only 8 are present locally.
355+
Your local migration history is behind the database.
356+
357+
To resolve:
358+
Pull the latest migrations from your team's repository.
359+
```
360+
361+
## Testing Strategy
362+
363+
### Unit Tests
364+
365+
1. `verify_chain` catches broken parent hash chain
366+
2. `verify_migration_hashes` catches modified local files
367+
3. `--force` bypasses both verifications
368+
4. Fresh database (no tracking table) doesn't error
369+
370+
### Integration Tests
371+
372+
1. Generate with valid chain succeeds
373+
2. Generate with broken chain fails (without --force)
374+
3. Generate with broken chain and --force succeeds
375+
4. Import with matching hashes succeeds
376+
5. Import with mismatched hashes fails (without --force)
377+
6. Import with mismatched hashes and --force succeeds
378+
7. Import to fresh database (no tern.migrations) succeeds
379+
380+
## Summary
381+
382+
| Command | Chain Verification | Hash Verification | Schema Verification |
383+
|---------|-------------------|-------------------|---------------------|
384+
| `generate` | ✅ Add | N/A | N/A |
385+
| `import` | ✅ Add | ✅ Add | ❌ Skip (intentional) |
386+
387+
Both commands will gain a `--force` flag to bypass verification for emergency situations, with appropriate warnings about the risks.

0 commit comments

Comments
 (0)