Skip to content

fix(sync-metadata): refuse deletes all-or-nothing — no partial insert/update apply (SA) - #146

Merged
mousebrains merged 2 commits into
mainfrom
sa-1-sync-all-or-nothing
Jun 8, 2026
Merged

fix(sync-metadata): refuse deletes all-or-nothing — no partial insert/update apply (SA)#146
mousebrains merged 2 commits into
mainfrom
sa-1-sync-all-or-nothing

Conversation

@mousebrains

Copy link
Copy Markdown
Owner

What

Dataset-separation SA, first sub-slice. Makes levels sync-metadata all-or-nothing when a CSV diff contains deletions and --allow-deletes is not passed: it now refuses before any write (exit 2, zero commits), instead of committing the inserts/updates and only refusing the deletes.

This satisfies SA acceptance criterion #8: "Refused metadata deletes begin no write transaction and leave logical table checksums/counts unchanged."

Why

Today (src/kayak/cli/sync_metadata.py) a delete-containing diff without --allow-deletes still runs upsert_csvs() — committing every insert/update — and only refuses the deletes (exit 2). That leaves a half-applied DB: on a deploy that unexpectedly drops a row, the safe half is already committed when deploy.sh aborts.

Change

  • Move the refusal ahead of the with conn: transaction. If plan.has_deletes and not args.allow_deletes → print the plan + per-source observation-drop counts (both already computed pre-transaction) and return 2 with no writes. The in-transaction delete branch is now reached only with --allow-deletes, so a recovery run applies the whole batch (inserts/updates AND deletes) atomically, and is idempotent.
  • Deploy flow unchanged, strictly safer: deploy.sh step 3.1 still runs sync-metadata --backup without --allow-deletes; a delete still exits 2 and aborts the set -e deploy — but now the DB is untouched rather than half-applied. The operator's manual --allow-deletes run applies everything in one transaction, then re-runs deploy.

Tests

  • test_deletes_refused_without_flag now asserts nothing applied, proven via PRAGMA data_version on a separate connection (bumps iff another connection commits) — a direct check that no write transaction committed.
  • New test_refused_delete_then_allow_deletes_applies_whole_batch — the deploy recovery flow: refuse → byte-unchanged → --allow-deletes applies the whole batch → idempotent.
  • New test_pure_upsert_without_allow_deletes_applies — a delete-free diff still applies without the flag (guards the common deploy + kayak_data CI path).
  • Doc/comment sweep: module docstring, --allow-deletes help, deploy.sh step-3.1 comment, add-gauges runbook.

Verification

  • Full local gate green: ruff check, ruff format --check, mypy (CI scope), pytest -m "not slow" = 1382 passed, wheel-smoke.
  • Manual end-to-end repro on a scratch DB: refused pass leaves the would-be-renamed row unchanged (exit 2); --allow-deletes re-run applies rename + delete (exit 0); third run is a no-op.
  • Two-round adversarial self-review (parallel finders + verify) — dry: no regressions, no stale docs, data_version assertion verified robust in both journal modes.

Scope / not in this PR

No schema, model, or migration change. kayak_data needs no change — its CI smoke (init-db --no-seedsync-metadata ×2 → build) syncs into an empty DB (all inserts, zero deletes), so the gate never fires.

Next SA sub-slices (separate PRs): SA-2 move fetch_url.last_fetched_at to a runtime table; SA-3 CI writer-boundary guard; then the reverse-sync teardown.

🤖 Generated with Claude Code

…/update apply (SA)

Dataset-separation SA acceptance criterion #8: "Refused metadata deletes begin
no write transaction and leave logical table checksums/counts unchanged." Today
`levels sync-metadata` does the opposite — when a CSV diff contains deletions and
`--allow-deletes` is not passed, it still runs `upsert_csvs()` (committing every
insert/update) and only refuses the deletes (exit 2), leaving a half-applied DB.

Move the refusal BEFORE the write transaction: if the plan has deletes and
`--allow-deletes` is absent, print the plan + per-source observation-drop counts
(already computed by `compute_plan` and printed by `_print_plan`, both
pre-transaction) and return 2 with zero writes. The in-transaction delete branch
is now reached only when `--allow-deletes` is set, so a recovery run applies the
whole batch (inserts/updates AND deletes) atomically — and is idempotent.

The deploy flow is unchanged and strictly safer: `deploy.sh` step 3.1 still runs
`sync-metadata --backup` without `--allow-deletes`, a delete still exits 2 and
aborts the `set -e` deploy — but now leaves the DB untouched instead of
half-applied; the operator's manual `--allow-deletes` run applies everything in
one transaction, then re-runs deploy.

Tests: `test_deletes_refused_without_flag` now asserts NOTHING applied, proven
via `PRAGMA data_version` on a separate connection (bumps iff another connection
commits) — a direct check that no write transaction committed. Add
`test_refused_delete_then_allow_deletes_applies_whole_batch` (the deploy recovery
flow: refuse → byte-unchanged → `--allow-deletes` applies the whole batch →
idempotent) and `test_pure_upsert_without_allow_deletes_applies` (a delete-free
diff still applies without the flag — guards the common deploy + kayak_data CI
path). Sweep the module docstring, `--allow-deletes` help, `deploy.sh` step-3.1
comment, and the add-gauges runbook to describe all-or-nothing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@mousebrains

Copy link
Copy Markdown
Owner Author

Adversarial review of 1defe7a: no findings.

I focused on the operational safety claim: a delete-containing sync without --allow-deletes must return before any DB write, while delete-free updates must still apply on the normal deploy path. The new gate is in the right place: after the read-only plan/dry-run path and before the with conn: write transaction.

Live-DB copy verification using the fresh ../DB/kayak.db:

  • Copied the live DB to /private/tmp and built a source.csv diff that both removed source 1 and renamed source 2.
  • sync-metadata without --allow-deletes returned 2, printed the observation-drop count for source 1 (10,474 observations), left PRAGMA data_version unchanged on a separate monitor connection, kept source count at 328, kept source 1, and did not apply the rename.
  • A delete-free live-copy diff that renamed source 2 returned 0 without --allow-deletes and applied the rename, preserving the normal deploy/CI path.

Additional verification:

  • uv run --extra dev pytest -q tests/test_cli/test_sync_metadata.py tests/test_scripts/test_metadata_roundtrip.py tests/test_cli/test_pipeline.py → 38 passed, 1 existing METADATA_DIR deprecation warning.
  • git diff --check de31d847c3eef3d2895a9a5917fa25afb92074f3..HEAD clean.
  • uv run --extra dev python -m kayak.cli.main sync-metadata --help shows the updated all-or-nothing --allow-deletes wording.
  • gh pr checks 146 is green.

Residual notes, not blockers:

  • sync-metadata still prints insert/delete counts only; update-only changes can still display as “no changes” in the plan. That predates this PR and is already called out in the runbook.
  • With --backup, the backup file is still created before a delete refusal. The reviewed guarantee holds for the live DB state/transaction, not for sidecar backup-file creation.

@mousebrains

Copy link
Copy Markdown
Owner Author

Adversarial review — PR #146 (sync-metadata all-or-nothing delete refusal, SA)

Verified the core fix on a copy of the fresh live DB (629 MB, 432 reaches / 328 sources) — not just the fixture. The change is correct and the bug it fixes is real. One precise, low-severity imprecision in the "no writes on refusal" claim.

The fix works — proven on real data

Constructed a CSV diff with both halves the old code handled inconsistently — an UPDATE (rename source id 1 BCLO3SYNCTEST_RENAMED) and a DELETE (drop reach 432) — and ran #146's sync-metadata against a copy of the live DB:

  • Refused (no --allow-deletes): exit 2, "NO changes applied". DB after: reaches still 432 (delete skipped) and first source still BCLO3 (the UPDATE skipped). This is the bug being fixed — the old code would have committed the rename and only refused the delete, leaving the half-applied state. Now neither lands. data_version and row counts unchanged confirm no transaction committed (AC fetch: don't abort batch on transient errors; close login open-redirect #8).
  • --allow-deletes recovery: whole batch applies atomically — "deleted 4 row(s) across 4 table(s)" (reach 432 + its 3 ON DELETE CASCADE children), reaches→431, name→**SYNCTEST_RENAMED**, exit 0. Second run: "no changes — DB already matches", exit 0 (idempotent).

Code-flow confirms it: the refusal moved ahead of with conn:, and nothing between _preflight (read-only) and the gate mutates the DB — the empirical untouched-DB result backs that.

Finding [low]: --backup runs before the refusal gate

sync_metadata runs the --backup block (lines 164–168, writes kayak.db.pre-sync via the online-backup API) before the delete-refusal gate. So on the exact deploy path this PR targets — deploy.sh step 3.1 calls sync-metadata --backup — a refused delete-containing deploy still writes a full ~629 MB backup before returning 2.

  • AC fetch: don't abort batch on transient errors; close login open-redirect #8 ("refused deletes begin no write transaction, leave table checksums/counts unchanged") is still satisfied — the backup doesn't touch the live DB's tables (verified: data_version/counts unchanged).
  • But the PR's prose ("refused before a single write", "the DB untouched") is imprecise: a backup file is written. It's harmless (same .pre-sync name, overwritten each run, no accumulation) and arguably even desirable, but if "zero writes on refusal" is the intent, moving the --backup block to after the refusal gate makes it literally true — a refused deploy would then do no I/O beyond reading. One-block move; your call whether the pre-refusal backup is a feature (a snapshot right before the operator's manual --allow-deletes) or noise.

Confirmed clean

  • deploy.sh step 3.1 change is comment-only — the command (sync-metadata --backup, no --allow-deletes) and the set -e abort-on-exit-2 are unchanged, so deploy behavior is identical except the DB is now untouched on refusal. ✓
  • The refusal condition is byte-for-byte the same predicate as before (has_deletes and not allow_deletes), only relocated — so when it refuses is unchanged; only what state it leaves differs. ✓
  • The in-transaction if plan.has_deletes: correctly implies --allow-deletes (refusal returned above), so upsert + deletions commit together. The feat(generate-sources): author fetch_url.unknown_station_policy from sources.yaml (S1-fetch-2) #145 _reset_absent_optional_columns (inside upsert_csvs) is also gated behind the refusal now — an all-or-nothing diff holds the policy reset too. ✓
  • Tests: the data_version-on-a-second-connection assertion is a sound way to prove no commit; the new whole-batch + pure-upsert cases cover the deploy recovery and common paths.

Net: correct, well-tested, real-data-verified. The only thing I'd want a decision on is the pre-refusal --backup write — keep it (intentional pre-recovery snapshot) or move it after the gate (true zero-write refusal).

🤖 Generated with Claude Code

…ow-up)

Adversarial review of #146 flagged that --backup runs BEFORE the delete-refusal
gate, so a refused delete-containing run still wrote the full ~629 MB .pre-sync
sidecar before returning 2 — making the "refused before a single write / DB
untouched" claim imprecise (AC #8 held for the live DB's tables, but a file was
written) and wasting a large write on every refused deploy.

Move the --backup block to AFTER the refusal gate (and after the dry-run return),
so a refused or dry run does ZERO disk I/O. The backup's purpose is to protect an
actual apply (a FK-valid but logically-wrong UPDATE that commits and can't be
undone from the one-line diff); there is nothing to protect when nothing applies.
The online-backup still runs on its own fresh connections, independent of the
sync connection.

Because a refused deploy no longer leaves a snapshot behind, update deploy.sh's
step-3.1 recovery instruction to `sync-metadata --allow-deletes --backup` — the
recovery run is the one that mutates, so it should carry the snapshot.

Tests: split test_backup_writes_pre_sync_snapshot into test_backup_skipped_on_refusal
(refused run with --backup writes no .pre-sync — zero-I/O refusal) and
test_backup_writes_pre_sync_snapshot_on_apply (an apply with --backup writes a
pre-mutation snapshot: the copy still has the dropped source, the live DB does not).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@mousebrains

Copy link
Copy Markdown
Owner Author

Addressed the --backup-before-the-gate finding in 5e4b541.

Moved the --backup block to after the refusal gate (and after the dry-run return). A refused or dry run now does zero disk I/O — no .pre-sync sidecar is written, so the "refused before a single write / DB untouched" claim is now literally true (file and tables). The backup's purpose is to protect an actual apply (a FK-valid-but-logically-wrong UPDATE that commits and can't be undone from the one-line diff); there's nothing to protect when nothing applies.

Verified on a scratch DB: refused run --backup → exit 2, 0 .pre-sync files; apply run --backup --allow-deletes → exit 0, 1 .pre-sync file (a pre-mutation snapshot: the copy still has the dropped source, the live DB doesn't).

Follow-on: since a refused deploy no longer leaves a snapshot behind, I updated deploy.sh's step-3.1 recovery instruction to levels sync-metadata --allow-deletes --backup — the recovery run is the one that mutates, so it should carry the snapshot. (The common no-delete deploy path still gets its backup: the gate doesn't fire, so the backup runs right before the upsert.)

Tests: split test_backup_writes_pre_sync_snapshot into test_backup_skipped_on_refusal (zero-I/O refusal) and test_backup_writes_pre_sync_snapshot_on_apply (pre-mutation snapshot on apply). Full gate green: ruff/format/mypy, pytest -m "not slow" = 1383, wheel-smoke.

The two other residual notes you raised are intentionally out of scope: the insert/delete-only plan tally (pre-existing, called out in the runbook) and the "would drop 0 observation(s)" wording for non-source-only deletes (pre-existing; the per-table plan above the refusal already shows the non-source deletion). Happy to pick either up as a separate change if you'd like.

Still not merging — yours.

@mousebrains
mousebrains merged commit 7c7da6c into main Jun 8, 2026
9 checks passed
@mousebrains
mousebrains deleted the sa-1-sync-all-or-nothing branch June 8, 2026 18:02
mousebrains added a commit that referenced this pull request Jun 8, 2026
…(SA-teardown-A) (#149)

* refactor(import-metadata): sidecar-only; route CSV loads through sync-metadata (SA-teardown-A)

dataset-separation SA-teardown, part A. `levels sync-metadata` already applies the CSV
columns by stable id with delete-safety (#146); reach.geom/reach.gradient_profile are
EXCLUDED_COLUMNS, applied only from reaches.json/reaches-gradient.json. So make
import_metadata.py a **sidecar-only** applier and standardize CSV loads on sync-metadata:

- import_metadata.py: drop the full-CSV upsert path (and the `metadata_csv` import); it
  now applies only the geometry sidecars. No flags = both; --geom-only / --gradient-only
  = one. This removes the parallel-to-sync-metadata CSV channel that skipped #146's
  delete-safety (the #148 review's "optional hardening" — the unsafe channel is gone,
  not just gated). It stays the sanctioned sidecar applier (no refuse_configured_db),
  per the safety.py enumeration; deploy.sh 3.25/3.26 (--geom-only/--gradient-only) are
  unchanged, and 3.1 already does the CSV via sync-metadata.
- Fresh-load / recovery / onboarding runbooks now run init-db --no-seed → sync-metadata
  → import_metadata (sidecars) → pipeline: CLAUDE.md, README.md, CONTRIBUTING.md,
  deploy/SETUP.md (§4 + the §-755 block), and docs/migrations.md's recovery runbook
  (incl. the --no-seed-rationale paragraph: the collision + FK semantics are
  sync-metadata's now, not import's).
- Docstring sweep: safety.py (import_metadata has no full-CSV mode) and metadata_csv.py
  (its sole upsert caller is now sync-metadata).
- Tests: the full-CSV round-trip tests now apply the CSV half via sync_metadata
  (new `_sync_csvs` helper writes the minimal contract); the geom/gradient sidecar tests
  are unchanged. Renamed the two that were import-CSV-specific
  (test_csv_apply_preserves_geom_absent_from_snapshot, test_resync_idempotent_across_pk_shapes).

Verified on a copy of the live DB: init-db --no-seed → sync-metadata → import_metadata
reproduces prod exactly (328 sources / 231 gauges / 432 reaches incl. all 432 geoms +
432 gradients) and builds. No schema change; no kayak_data change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(import-metadata): fail loud on unmatched sidecars + DATASET_DIR in setup runbooks (review)

Address the two P2s (and the Low/Nit notes) from the #149 review.

[P2] import_metadata.py reported success while applying ZERO sidecar rows. After A
removed the CSV-load path, a no-flag run against an empty/wrong DB (the "ran before
`levels sync-metadata`" mistake) applied 0/432 geoms + 0/432 gradients and exited 0 —
invisible under `set -e`. Now `_apply_geom`/`_apply_gradient` return the count of
snapshot ids that matched no reach row, and main() rolls back + exits non-zero when any
are unmatched, with `--allow-missing-reaches` to opt into a deliberate partial apply.
Verified: empty DB no-flag → exit 1, nothing applied (rolled back); --allow-missing-reaches
→ exit 0; full DB (after sync-metadata) → exit 0. Deploy 3.25/3.26 (--geom-only/--gradient-only
against the full prod DB, after the 3.1 sync) all match, so this never false-positives on
deploy. New test: a sidecar id with no reach row fails loud + rolls back; the flag allows
the partial.

[P2] Two setup runbooks ran `levels sync-metadata` without cloning kayak_data / setting
DATASET_DIR, so they dead-ended at "csv dir does not exist: …/data/db": CONTRIBUTING.md
(standalone dev quick-start) and deploy/SETUP.md's self-contained condensed install recap
(its .env omitted DATASET_DIR and it cloned no metadata repo). Both now clone kayak_data +
set DATASET_DIR before the load. (Pre-existing — import_metadata needed DATASET_DIR too —
surfaced because A touches these blocks.)

[Low] Note in the recovery runbook that sync-metadata refuses a `status: scaffold` dataset
(--allow-scaffold to override; the real kayak_data is publishable). [Nit] CHANGELOG entry
for the import_metadata sidecar-only behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(setup): absolute import_metadata path in copy-paste-safe SETUP blocks (review)

Address the #149 re-review P3. The condensed local-dev install recap (step 6) and
the §4 "apply geometry by hand" snippet ran `python scripts/import_metadata.py` with
a relative path while every other command in those blocks uses absolute paths
(`/home/pat/.venv/bin/levels`, …) — so they read as self-contained but fail from
outside /home/pat/kayak ("can't open file '.../scripts/import_metadata.py'"). Use the
absolute `/home/pat/kayak/scripts/import_metadata.py` so both blocks are copy-paste-safe.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant