The thing that clicked today: a default is a decision you make once and then forget you made, and the safest default is usually no default at all.
Prospect is a local-first job-application tracker. A browser extension captures a posting into structured fields while you are looking at it, and the whole thing writes into a SQLite database I own. Today I was building a scratch clone to test a schema change safely. I did the opposite.
Built / shipped
The advisory feature I was actually working on: a bounded per-listing judgment pass, gated behind an environment flag and off by default, writing into its own append-only table so a re-survey adds a generation instead of overwriting one. That part went fine.
The schema mechanism it sits on is worth describing, because it is the reason today was recoverable. Migrations are numbered files, migrations/NNN_something.sql, applied by a small runner you have to call on purpose, and the database carries a version number so the runner knows which ones have been applied. They are additive: add a column, create a table, never rewrite or drop. And they are run by hand. Nothing applies a migration on service boot, because a service that migrates itself on restart is a service that can rewrite your data because you power-cycled a machine.
Problems & fixes
While assembling the scratch-clone command sequence, one command ran the migration runner bare, with no database path set for that specific call. The runner's default is the live database. Migration 013 applied to production.
I caught it immediately, because the next thing I did was check the database's version number and saw one I did not expect. What followed was the part I actually want to remember:
I established what had happened before deciding what to do about it. The migration was purely additive: it added one optional column and created one table. I checked the new column was empty on every existing row, the new table was empty, and every other column was byte-identical to what it had been. SQLite's own integrity check came back clean. The running service had never restarted, so the live process did not even know the file had changed.
Then I took a backup using SQLite's own backup command, which is safe to run while the database is in use, rather than copying the file out from under a live writer, and recorded its hash.
Then I deliberately did not roll it back. A rollback is itself a live mutation with its own failure modes, and the state I was in was inert and identical to what a legitimate enable pass would have produced anyway. Undoing it would have been motion for the sake of feeling better.
The genuine cost was subtler: the live schema was now ahead of git, since the migration file was written but not committed. That is the kind of drift that bites you weeks later when a deploy assumes the database is behind the code and it is not.
Decisions
Every call to the runner names its database in the same command, and the runner is off-limits to any automated pass that does not need it. The runner itself still carries the live-database default; taking that out is the real fix and it is on the list. A default that is right 95% of the time and catastrophic the other 5% is worse than no default, because it trains you to stop supplying the argument.
Verification comes before remediation. Every instinct said undo it. Ten minutes of checking established there was nothing to undo, which is a much better place to make that decision from.
Learned
"I caught it immediately" is doing a lot of work in that sentence, and it was luck as much as discipline: I happened to check the version number next because that is how the scratch-clone sequence continues. If the next command had been anything else, I would have found this days later, by which time "was the schema always like this?" is a genuinely hard question.
Additive-only migrations turned an incident into a footnote. The same mistake against a migration that dropped a column would have been a restore-from-backup evening.
Still open / next
Remove the runner's default database path so a bare call refuses instead of guessing. And the environment-variable literals for the model endpoint are now duplicated across five files, which is exactly the sort of thing that drifts. Both folded into follow-ups rather than smuggled into this build.