From f36d1b4d183335cecabb095a3fbc7a101c43a4ea Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Fri, 14 Aug 2026 22:46:17 +0100 Subject: [PATCH] Gate migration of the operator's store on consent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every build migrated any database it opened as a side effect of opening it, which is how an unreleased migration from a worktree once landed on real data (#417 made that detectable; this prevents it). The operator's store now carries a `protected` marker in a `store_meta` table — in the file, so it survives a symlink, a moved data directory, or a restored copy — and a protected store with pending migrations never migrates silently. The TUI asks at launch, on plain stdin before the terminal is taken, so reading the yes back is itself the proof a human is present; every CLI verb refuses, naming the new `voro migrate` verb; and `voro migrate --yes` consents from a script, recorded in the journal's `applied_by` so even the override leaves a trace. A fresh install still creates its database with no ceremony, and unprotected stores — dev, scratch, in-memory — migrate on open exactly as before. The task proposed routing by build provenance instead, so release artifacts kept migrating invisibly. Dropped after discussion: the CI stamp lives in a workflow cargo-dist regenerates and would silently vanish, and it bought one keypress per release. DESIGN.md §5 records the policy and the reversal. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01ThKpEECj8gR1xD167gAyKz --- CHANGELOG.md | 13 + .../voro-core/migrations/0020_store_meta.sql | 14 + crates/voro-core/src/error.rs | 17 ++ crates/voro-core/src/store.rs | 240 +++++++++++++++++- crates/voro/src/cli.rs | 26 ++ crates/voro/src/main.rs | 95 ++++++- docs/DESIGN.md | 6 +- 7 files changed, 395 insertions(+), 16 deletions(-) create mode 100644 crates/voro-core/migrations/0020_store_meta.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index 27a062a..5161db3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Migrating your real store now takes a yes.** Every build used to migrate + any database it opened as a side effect of opening it, which is how an + unreleased migration from a worktree once landed on real data. The operator's + store now carries a `protected` marker — in the file itself, so it survives a + symlink, a moved data directory, or a restored copy — and a protected store + with pending migrations refuses to migrate silently: launching the TUI shows + the pending count and asks before the terminal is taken, every CLI verb + refuses with a message pointing at the new `voro migrate` verb, and + `voro migrate --yes` consents from a script, recorded in the migration + journal's `applied_by` so even the override leaves a trace. A fresh install + still creates its database with no ceremony, and dev and scratch stores + migrate on open exactly as before. + - **One key gets every capped session working again.** A usage cap ends a session's turn and leaves it there — nothing retries — so recovering the fleet used to mean attaching to each capped session in turn and typing "continue", diff --git a/crates/voro-core/migrations/0020_store_meta.sql b/crates/voro-core/migrations/0020_store_meta.sql new file mode 100644 index 0000000..fb9df4a --- /dev/null +++ b/crates/voro-core/migrations/0020_store_meta.sql @@ -0,0 +1,14 @@ +-- Facts the store keeps about itself, one row per fact (DESIGN.md §5). +-- +-- `protected` marks the operator's store, written on any open at the +-- production path. It lives in the file rather than being inferred from the +-- path on each open, so the property travels with the data through a symlink, +-- a moved data directory, or a restored copy. +-- +-- A protected store never migrates as a side effect of being opened: the TUI +-- asks at launch, the CLI refuses and names `voro migrate`. + +CREATE TABLE store_meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); diff --git a/crates/voro-core/src/error.rs b/crates/voro-core/src/error.rs index 5528c05..8852b82 100644 --- a/crates/voro-core/src/error.rs +++ b/crates/voro-core/src/error.rs @@ -19,6 +19,23 @@ pub enum Error { remedy: String, }, + #[error( + "{} is protected and has {pending} pending migration(s) (schema {version} -> {known}); \ + the operator's store never migrates as a side effect of being opened. The operator \ + applies them by launching `voro` at a terminal, which asks first, or with \ + `voro migrate`. If you are an agent or a script seeing this, stop and tell the \ + operator; `voro migrate --yes` consents on their behalf and is recorded in the \ + migration journal. A snapshot is written to backups/ beside the store before anything \ + is applied.", + path.display() + )] + MigrationsPending { + path: PathBuf, + pending: usize, + version: usize, + known: usize, + }, + #[error( "this database's migration {idx} differs from the one this build carries: it was applied \ {applied_at} by {applied_by}. The version numbers agree, so nothing else will catch \ diff --git a/crates/voro-core/src/store.rs b/crates/voro-core/src/store.rs index f3b9cef..64e669f 100644 --- a/crates/voro-core/src/store.rs +++ b/crates/voro-core/src/store.rs @@ -29,6 +29,7 @@ const MIGRATIONS: &[&str] = &[ include_str!("../migrations/0017_schema_migrations.sql"), include_str!("../migrations/0018_project_viewer.sql"), include_str!("../migrations/0019_session_liveness_source.sql"), + include_str!("../migrations/0020_store_meta.sql"), ]; /// Whether a path lies inside a Cargo build directory — a `target` component @@ -47,8 +48,9 @@ fn path_is_cargo_target(path: &Path) -> bool { /// Write the journal rows for a migration pass (§5). Migrations applied before /// the journal existed are backfilled with a NULL `sql`; what this pass applies -/// is recorded verbatim, signed with the build that applied it. -fn record_in_journal(tx: &Connection, from_version: usize) -> Result<()> { +/// is recorded verbatim, signed with the build that applied it and, on a +/// protected store, with the consent that let it (§5). +fn record_in_journal(tx: &Connection, from_version: usize, consent: Option<&str>) -> Result<()> { let found: i64 = tx.query_row( "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'schema_migrations'", [], @@ -64,7 +66,7 @@ fn record_in_journal(tx: &Connection, from_version: usize) -> Result<()> { params![idx as i64], )?; } - let by = applied_by(); + let by = applied_by(consent); for idx in (from_version + 1)..=MIGRATIONS.len() { tx.execute( "INSERT OR REPLACE INTO schema_migrations (idx, sql, applied_at, applied_by) @@ -76,12 +78,15 @@ fn record_in_journal(tx: &Connection, from_version: usize) -> Result<()> { } /// How a build signs the journal: crate version and the running executable's -/// path, which is what identifies the build behind a divergence. -fn applied_by() -> String { +/// path, which is what identifies the build behind a divergence. A consented +/// migration of a protected store appends how the consent was given, so even +/// a `--yes` override leaves a trace. +fn applied_by(consent: Option<&str>) -> String { let exe = std::env::current_exe() .map(|p| p.display().to_string()) .unwrap_or_else(|_| "an unknown executable".to_string()); - format!("voro {} at {exe}", env!("CARGO_PKG_VERSION")) + let via = consent.map(|c| format!(", {c}")).unwrap_or_default(); + format!("voro {} at {exe}{via}", env!("CARGO_PKG_VERSION")) } /// The way out of a database carrying a migration this build does not have: @@ -181,11 +186,29 @@ pub struct TaskEdit { impl Store { pub fn open(path: &Path) -> Result { + Store::open_with_consent(path, None) + } + + /// Open with consent to migrate a protected store (§5): the TUI's launch + /// prompt and `voro migrate` call this after a human has answered, or with + /// `--yes` standing in for one. `consent` says how the consent was given + /// and is recorded in the journal's `applied_by`. On an unprotected store + /// it changes nothing — migration there never needed asking. + pub fn open_migrate(path: &Path, consent: &str) -> Result { + Store::open_with_consent(path, Some(consent)) + } + + fn open_with_consent(path: &Path, consent: Option<&str>) -> Result { if let Some(dir) = path.parent() { std::fs::create_dir_all(dir) .map_err(|e| Error::Invalid(format!("cannot create {}: {e}", dir.display())))?; } - Store::from_connection_at(Connection::open(path)?, Some(path)) + Store::open_at( + Connection::open(path)?, + path, + &Store::production_db_path(), + consent, + ) } pub fn open_in_memory() -> Result { @@ -254,6 +277,24 @@ impl Store { } fn from_connection_at(conn: Connection, path: Option<&Path>) -> Result { + Store::open_at_opt(conn, path, &Store::production_db_path(), None) + } + + fn open_at( + conn: Connection, + path: &Path, + production: &Path, + consent: Option<&str>, + ) -> Result { + Store::open_at_opt(conn, Some(path), production, consent) + } + + fn open_at_opt( + conn: Connection, + path: Option<&Path>, + production: &Path, + consent: Option<&str>, + ) -> Result { conn.pragma_update(None, "foreign_keys", true)?; let mut store = Store { conn }; let version = store.schema_version()?; @@ -269,12 +310,64 @@ impl Store { if version < MIGRATIONS.len() && let Some(path) = path { + // The consent gate (§5). A store with no schema at all is exempt — + // a fresh install creates its database silently, and there is + // nothing yet to protect. + if version > 0 && consent.is_none() && store.is_protected(path, production)? { + return Err(Error::MigrationsPending { + path: path.to_path_buf(), + pending: MIGRATIONS.len() - version, + version, + known: MIGRATIONS.len(), + }); + } store.snapshot(path, version)?; } - store.migrate()?; + store.migrate(consent)?; + if path == Some(production) { + store.mark_protected()?; + } Ok(store) } + /// Whether this store is the operator's (§5): opened at the production + /// path, or carrying the `protected` marker a past open there wrote — how + /// the property survives a symlink, a moved data directory, or a restored + /// copy. Runs before any migration, so it must read a store from before + /// `store_meta` existed, where only the path can answer. + fn is_protected(&self, path: &Path, production: &Path) -> Result { + if path == production { + return Ok(true); + } + let has_meta: i64 = self.conn.query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'store_meta'", + [], + |row| row.get(0), + )?; + if has_meta == 0 { + return Ok(false); + } + let marked: Option = self + .conn + .query_row( + "SELECT value FROM store_meta WHERE key = 'protected'", + [], + |row| row.get(0), + ) + .optional()?; + Ok(marked.as_deref() == Some("1")) + } + + /// `INSERT OR IGNORE` so an already-marked store takes no write at all: + /// opening must not bump `data_version` for connections polling it. + fn mark_protected(&self) -> Result<()> { + self.conn.execute( + "INSERT OR IGNORE INTO store_meta (key, value) VALUES ('protected', '1')", + [], + )?; + Ok(()) + } + /// Check the journal (§5) against the migrations this build carries. The /// counter reports a database that is *ahead*; this reports one that is /// *different*, which two branches numbering a migration alike produce. @@ -323,7 +416,10 @@ impl Store { Ok(found > 0) } - fn schema_version(&self) -> Result { + /// The store's `user_version`. An open store always reads as the count of + /// migrations its build carries; `voro migrate` reports it when there was + /// nothing to apply. + pub fn schema_version(&self) -> Result { Ok(self .conn .query_row("PRAGMA user_version", [], |row| row.get::<_, i64>(0))? as usize) @@ -372,9 +468,9 @@ impl Store { /// Migrations may rebuild tables (SQLite cannot alter CHECK constraints), /// so foreign-key enforcement is suspended for the duration and integrity /// verified afterwards — the procedure SQLite documents for schema changes. - fn migrate(&mut self) -> Result<()> { + fn migrate(&mut self, consent: Option<&str>) -> Result<()> { self.conn.pragma_update(None, "foreign_keys", false)?; - let applied = self.apply_migrations(); + let applied = self.apply_migrations(consent); let restored = self.conn.pragma_update(None, "foreign_keys", true); applied?; restored?; @@ -391,7 +487,7 @@ impl Store { Ok(()) } - fn apply_migrations(&mut self) -> Result<()> { + fn apply_migrations(&mut self, consent: Option<&str>) -> Result<()> { let tx = self.conn.transaction()?; let version: usize = tx.query_row("PRAGMA user_version", [], |row| row.get::<_, i64>(0))? as usize; @@ -399,7 +495,7 @@ impl Store { tx.execute_batch(sql)?; tx.pragma_update(None, "user_version", (i + 1) as i64)?; } - record_in_journal(&tx, version)?; + record_in_journal(&tx, version, consent)?; tx.commit()?; Ok(()) } @@ -2149,6 +2245,124 @@ mod schema_guard_tests { assert!(!Store::backup_dir_for(&path).exists()); std::fs::remove_dir_all(&dir).ok(); } + + /// A store one migration short of current, built by replaying the list — + /// the state a release upgrade or a from-source build finds the operator's + /// store in. + fn store_at_previous_version(path: &Path) { + let conn = Connection::open(path).unwrap(); + for sql in &MIGRATIONS[..MIGRATIONS.len() - 1] { + conn.execute_batch(sql).unwrap(); + } + conn.pragma_update(None, "user_version", (MIGRATIONS.len() - 1) as i64) + .unwrap(); + } + + fn open_as_production(path: &Path, consent: Option<&str>) -> Result { + Store::open_at(Connection::open(path).unwrap(), path, path, consent) + } + + #[test] + fn the_production_store_refuses_to_migrate_without_consent() { + let dir = scratch("gate-refuse"); + let path = dir.join("voro.db"); + store_at_previous_version(&path); + + let message = match open_as_production(&path, None) { + Ok(_) => panic!("a protected store with pending migrations should not open"), + Err(e) => e.to_string(), + }; + assert!(message.contains("pending migration"), "{message}"); + assert!(message.contains("voro migrate"), "{message}"); + // Refused means untouched: no migration applied, no snapshot taken. + let version: i64 = Connection::open(&path) + .unwrap() + .query_row("PRAGMA user_version", [], |r| r.get(0)) + .unwrap(); + assert_eq!(version, (MIGRATIONS.len() - 1) as i64); + assert!(!Store::backup_dir_for(&path).exists()); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn consent_migrates_the_production_store_and_is_journalled() { + let dir = scratch("gate-consent"); + let path = dir.join("voro.db"); + store_at_previous_version(&path); + + open_as_production(&path, Some("via voro migrate --yes")).unwrap(); + + let conn = Connection::open(&path).unwrap(); + let version: i64 = conn + .query_row("PRAGMA user_version", [], |r| r.get(0)) + .unwrap(); + assert_eq!(version, MIGRATIONS.len() as i64); + let by: String = conn + .query_row( + "SELECT applied_by FROM schema_migrations WHERE idx = ?1", + [MIGRATIONS.len() as i64], + |r| r.get(0), + ) + .unwrap(); + assert!(by.contains("via voro migrate --yes"), "{by}"); + // The snapshot still precedes a consented migration. + assert!(Store::backup_dir_for(&path).exists()); + std::fs::remove_dir_all(&dir).ok(); + } + + /// The marker, not the path, is what makes a moved or restored copy of the + /// operator's store keep refusing (§5). + #[test] + fn the_protected_marker_travels_with_the_file() { + let dir = scratch("gate-marker"); + let path = dir.join("voro.db"); + // A full open at its "production" path writes the marker. + open_as_production(&path, None).unwrap(); + let moved = dir.join("restored-copy.db"); + std::fs::rename(&path, &moved).unwrap(); + // Winding the copy back one version makes it pending again; the gate + // fires before any migration would re-apply, so the state is enough. + Connection::open(&moved) + .unwrap() + .pragma_update(None, "user_version", (MIGRATIONS.len() - 1) as i64) + .unwrap(); + + assert!(matches!( + Store::open(&moved), + Err(Error::MigrationsPending { .. }) + )); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn a_fresh_production_store_is_created_silently_and_marked() { + let dir = scratch("gate-fresh"); + let path = dir.join("voro.db"); + let store = open_as_production(&path, None).unwrap(); + let marked: String = store + .conn + .query_row( + "SELECT value FROM store_meta WHERE key = 'protected'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(marked, "1"); + std::fs::remove_dir_all(&dir).ok(); + } + + /// An unprotected store — scratch `--db`, the dev store — migrates on open + /// exactly as before the gate existed. + #[test] + fn an_unprotected_store_still_migrates_silently() { + let dir = scratch("gate-scratch"); + let path = dir.join("scratch.db"); + store_at_previous_version(&path); + + let store = Store::open(&path).unwrap(); + assert_eq!(store.schema_version().unwrap(), MIGRATIONS.len()); + std::fs::remove_dir_all(&dir).ok(); + } } #[cfg(test)] diff --git a/crates/voro/src/cli.rs b/crates/voro/src/cli.rs index 8e6e730..8b705de 100644 --- a/crates/voro/src/cli.rs +++ b/crates/voro/src/cli.rs @@ -166,6 +166,14 @@ tasks from a target/ directory seeds it by itself on first run; --force rebuilds it. Refused against your real store + migrate [--yes] apply pending schema migrations to your real + store, the one store that never migrates as + a side effect of being opened (the TUI asks + the same question at launch). Asks at a + terminal; --yes consents from a script and + is recorded in the migration journal. Dev + and scratch stores migrate on open and need + none of this import [--repo NAME] [--gh-repo owner/name] import open GitHub issues as proposed tasks via `gh issue list`; idempotent. @@ -311,6 +319,15 @@ enum Verb { #[arg(long)] force: bool, }, + /// Apply pending migrations to a protected store (DESIGN.md §5). The + /// pending case never reaches this handler — the store refuses to open, and + /// `main` collects consent before there is a `Store` to pass — so what is + /// left to report here is that nothing was pending. + Migrate { + /// Consent from a script; recorded in the migration journal. + #[arg(long)] + yes: bool, + }, Explain { task_id: i64, }, @@ -709,6 +726,7 @@ pub fn run(store: &mut Store, args: Vec, ctx: &DispatchCtx) -> Result next_verb(store), Verb::Stats => stats_verb(store), Verb::Seed { force } => seed_verb(store, ctx, force), + Verb::Migrate { .. } => migrate_verb(store, ctx), Verb::Explain { task_id } => explain_verb(store, task_id, ctx), Verb::Agent { cmd } => agent_verb(cmd, ctx), Verb::Dispatch { task_id, agent } => { @@ -2106,6 +2124,14 @@ fn seed_verb(store: &mut Store, ctx: &DispatchCtx, force: bool) -> Result Result { + let version = store.schema_version().map_err(|e| e.to_string())?; + Ok(format!( + "nothing pending: {} is at schema {version}, the newest this build carries", + ctx.db_path.display() + )) +} + fn stats_verb(store: &mut Store) -> Result { let c = store.state_counts().map_err(|e| e.to_string())?; let mut out = String::new(); diff --git a/crates/voro/src/main.rs b/crates/voro/src/main.rs index 186edcb..132e220 100644 --- a/crates/voro/src/main.rs +++ b/crates/voro/src/main.rs @@ -63,6 +63,95 @@ fn resolved_db() -> PathBuf { } } +/// The protected store has pending migrations and opening it refused to apply +/// them (DESIGN.md §5). Consent is collected here, before the terminal is +/// taken, on plain stdin — which is itself the test that a human is present: a +/// launch with no terminal to answer from cannot consent, and that headless +/// bare `voro` is exactly the shape the incident behind the gate took. A bare +/// launch asks and continues into the TUI on a yes; `voro migrate` is the +/// explicit spelling, asking at a terminal and taking `--yes` from a script; +/// every other verb repeats the refusal, whose message routes an agent to the +/// operator. Only a TUI-bound yes returns; every other outcome exits. +fn migration_gate( + path: &std::path::Path, + verb_args: &[String], + refusal: voro_core::Error, +) -> Store { + let voro_core::Error::MigrationsPending { + pending, + version, + known, + .. + } = &refusal + else { + unreachable!("migration_gate is only called with MigrationsPending"); + }; + let (pending, version, known) = (*pending, *version, *known); + let confirmed = |consent: &str| -> Store { + let store = or_exit(Store::open_migrate(path, consent)); + eprintln!( + "voro: applied {pending} migration(s) to {} (schema {version} -> {known}); \ + the pre-migration snapshot is in backups/ beside it.", + path.display() + ); + store + }; + let ask = || { + confirm(&format!( + "voro: {} has {pending} pending migration(s) (schema {version} -> {known}).\n\ + A snapshot is written to backups/ beside it first. Apply them now?", + path.display() + )) + }; + let interactive = std::io::IsTerminal::is_terminal(&std::io::stdin()); + let declined = || -> ! { + eprintln!("voro: left unmigrated — run `voro migrate` when ready."); + std::process::exit(1); + }; + match verb_args.first().map(String::as_str) { + Some("migrate") if verb_args.iter().any(|a| a == "--yes") => { + confirmed("via voro migrate --yes"); + std::process::exit(0); + } + Some("migrate") if interactive => { + if !ask() { + declined(); + } + confirmed("via voro migrate"); + std::process::exit(0); + } + None if interactive => { + if !ask() { + declined(); + } + confirmed("confirmed at the TUI prompt") + } + _ => { + eprintln!("voro: {refusal}"); + std::process::exit(1); + } + } +} + +/// Ask a yes/no question on the plain terminal. Anything but a terminal on +/// stdin, or any answer but a yes, is a no — an unanswerable question must +/// refuse, never hang or assume. +fn confirm(question: &str) -> bool { + use std::io::{IsTerminal, Write}; + if !std::io::stdin().is_terminal() { + return false; + } + print!("{question} [y/N] "); + if std::io::stdout().flush().is_err() { + return false; + } + let mut answer = String::new(); + if std::io::stdin().read_line(&mut answer).is_err() { + return false; + } + matches!(answer.trim(), "y" | "Y" | "yes" | "Yes") +} + /// Report a startup failure the way the CLI verbs report theirs: the error's /// own message, on stderr. Returning it from `main` would print the derived /// `Debug` form instead, which buries the sentence the operator has to read. @@ -78,7 +167,11 @@ fn or_exit(result: voro_core::Result) -> T { fn main() -> Result<(), Box> { let (path, verb_args) = split_db(std::env::args().skip(1).collect()); - let mut store = or_exit(Store::open(&path)); + let mut store = match Store::open(&path) { + Ok(store) => store, + Err(e @ voro_core::Error::MigrationsPending { .. }) => migration_gate(&path, &verb_args, e), + Err(e) => or_exit(Err(e)), + }; // The dev store carries its fixture from first use (DESIGN.md §5); empty, // it renders as a blank board indistinguishable from a broken query. if path == Store::dev_db_path() && or_exit(voro_core::seed::is_empty(&store)) { diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 5599561..7e6d76b 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -57,7 +57,7 @@ A single owned SQLite database, not a wrapper over a per-project tool: the ready **Which database a build opens.** A build running out of a Cargo `target/` directory opens `dev.db` beside the operator's store rather than `voro.db` itself, seeded on first use with the fixture in `voro-core`'s `seed` module. `VORO_DB` is declined by such a build for the same reason: dispatch exports it so a session's return path finds the store its dispatcher was on (§8), which makes it a value a process *inherits* rather than one it asks for, and every agent working in a worktree therefore has the operator's database named in its environment. Naming a store with `--db` is deliberate and is honoured; inheriting one is not, and the rule is about that distinction rather than about which path the variable holds. -This is ergonomics, not protection, and the difference matters. The check is on where the running executable lives, and `cargo install --path` builds a working checkout — unreleased migrations and all — into an ordinary install location, where it reads as installed. So the default-chooser has a blind spot on precisely the route that is easiest to take, which is survivable only because nothing depends on it: what protects the schema is the journal and the counter below, which reason about what a database actually contains. A default-chooser may have blind spots; a guard may not. +This is ergonomics, not protection, and the difference matters. The check is on where the running executable lives, and `cargo install --path` builds a working checkout — unreleased migrations and all — into an ordinary install location, where it reads as installed. So the default-chooser has a blind spot on precisely the route that is easiest to take, which is survivable only because nothing depends on it: what protects the schema is the journal, the counter, and the consent gate below, which reason about what a database actually contains rather than where a binary lives. A default-chooser may have blind spots; a guard may not. The dev store is deliberately one file shared by every worktree rather than one per worktree, which means a branch carrying a new migration does bump the shared dev store and every other dev build then finds it ahead of itself. That is a survivable trade — the dev store is disposable and rebuilt with `voro seed --force` — and it is why the guards below have to name their remedy rather than merely refuse. The fixture is generated through the ordinary store and transition APIs, never shipped as a `.db` file: a checked-in fixture freezes at the schema of the day it was made, and drifts from both the migrations and the state machine, which is exactly what the stale hand-copied demo database it replaces had done. @@ -65,7 +65,9 @@ The dev store is deliberately one file shared by every worktree rather than one **Two further guards on opening.** A store whose `user_version` exceeds the migration count this binary carries is *refused*, with an error naming the way out — reseed for the dev store, restore a snapshot for the operator's. Without it the extra version is silently skipped and the mismatch surfaces later as a missing column, an error that says nothing about what is actually wrong. And any open that is about to migrate copies the file to `backups/` beside it first, since a rename or a drop cannot be undone from the migrated file alone; that copy is what makes "restore a snapshot" advice the operator can act on rather than a suggestion. Both remedies lead with restoring rather than with running the build that migrated it, because for an unreleased migration that advice entrenches a schema no other build can open. A failure to write the snapshot is reported but not fatal — refusing to open over a full disk would be the worse outcome. -What none of this yet does is *prevent* an unreleased migration reaching the operator's store; it detects one afterwards and keeps the recovery cheap. Gating the mutation itself — a protected-store marker, provenance distinguishing a release artifact from a working checkout, and a confirmation an interactive operator can give and a headless agent cannot — is deferred to its own change, since it has to land without putting ceremony in front of users who install a release and never build anything. +**The mutation itself is gated on consent.** The journal and the counter detect damage and keep recovery cheap; what prevents it is that the operator's store never migrates as a side effect of being opened. The store is **protected**: a `protected` marker in a `store_meta` table, written on any open at the production path, so the property lives in the file and travels with it through a symlink, a moved data directory, or a restored copy rather than being re-inferred from where the file happens to sit. When a binary carrying more migrations than a protected store has opens it, who is asking decides what happens — and the two surfaces answer that without a heuristic, because the TUI is the human surface by construction. A bare `voro` puts the pending count and the schema range on plain stdout, before the terminal is taken, and asks; reading the `y` back is itself the proof a human is present, so a launch with no terminal to answer from — the incident's own shape was a `cargo run` inheriting `VORO_DB` in an agent's worktree, which would have reached this exact prompt — cannot consent and is refused. Every CLI verb refuses too, naming the explicit spelling: `voro migrate` asks the same question at a terminal, and `voro migrate --yes` consents from a script — on the operator's behalf, not an agent's, which the refusal says in as many words — with the consent recorded in the journal's `applied_by` either way, so even the override leaves a trace. Two openings stay silent because ceremony there would be noise: a store with no schema at all, since a fresh install creates its database on first run and there is nothing yet to protect, and every unprotected store — `dev.db`, a scratch `--db`, the in-memory stores tests use — which migrates on open exactly as before. + +An earlier shape of this gate routed by provenance instead of surface: a release artifact would keep migrating silently, so a crates.io upgrade stayed invisible, and the ceremony fell only on from-source builds. It was dropped for what it cost against what it bought. Telling a release build from a checkout reliably means stamping the binary in CI — an environment variable in a workflow cargo-dist regenerates, which the next `dist init` would silently drop, after which a release reads as from-source and puts prompts in front of exactly the users the machinery existed to spare. What the stamp bought was one keypress per release, at a frequency bounded by migrations landing rather than installs — a handful of times a year, at a moment worth pausing at anyway. The questions that design dragged in — whether a build installed from `main` counts as released, how to keep core free of the build-time plumbing — dissolve with it, and the dev-store default above keeps its job unchanged: it was always ergonomics about which data a build sees, and the consent gate is now the guard. ```sql CREATE TABLE projects (