diff --git a/.claude/rules/asset-pipeline.md b/.claude/rules/asset-pipeline.md index c29b851d4..7a5ff8025 100644 --- a/.claude/rules/asset-pipeline.md +++ b/.claude/rules/asset-pipeline.md @@ -19,15 +19,21 @@ migration — those changes will be silently overwritten by the next `make regen ## What produces systems.db -Three generators write to `systems.db` in sequence: +Two generators write to `systems.db`: -| Generator | Command | Source files | +| Generator | Command | Source files (all contribute to the meta stamp SHA) | |-----------|---------|--------------| -| `generate_brands` | `tooling/generate-brands` | `server/src/bin/generate_brands/main.rs` | -| `import_economics` | `python3 tooling/economy-db/import_economics.py` | `tooling/economy-db/import_economics.py` | +| `import_economics` | `python3 tooling/economy-db/import_economics.py` | `tooling/economy-db/import_economics.py` + the Rust brand binary sources it invokes: `server/src/bin/generate_brands/main.rs`, `server/src/bin/generate_brands/names.rs`, `tooling/generate-brands` | | `generate_atlas` | `python3 tooling/planet-gen/generate_atlas.py --seed 42` | `tooling/planet-gen/generate_atlas.py` | -`make regen-db` runs all three in the correct order. +`import_economics` shells out to the Rust `generate_brands` binary as its first +step to refresh `wiki/economics/corporations/generated_brands.toml`, then reads +the TOML and imports brand data into the DB. The Rust binary is a subroutine +of the Python importer, not an independent generator — changes to its source +invalidate the `import_economics` meta stamp even though the Python file +itself didn't change. + +`make regen-db` runs both in the correct order (economics first, atlas second). --- @@ -37,16 +43,24 @@ After every successful non-dry-run, each generator writes a row to the `meta` ta ```sql CREATE TABLE meta ( - generator_name TEXT PRIMARY KEY, -- 'import_economics' | 'generate_atlas' | 'generate_brands' + generator_name TEXT PRIMARY KEY, -- 'import_economics' | 'generate_atlas' schema_version TEXT NOT NULL, -- SHA-1 of server/data/systems-schema.sql at generation time generator_sha TEXT NOT NULL, -- SHA-1 of the generator source file(s) generated_at TEXT NOT NULL DEFAULT (datetime('now')) ); ``` -The `generator_sha` is the SHA-1 of the generator source file content. If the source -changes and `regen-db` is not re-run, the stamped SHA will differ from the current file -SHA — this is what the pre-push hook detects. +The `generator_sha` is the SHA-1 of the concatenated bytes of the generator's +source files (sorted by path, so order is deterministic). If any source file +changes and `make regen-db` is not re-run, the stamped SHA will differ from the +recomputed current SHA — this is what the pre-push hook detects. + +**What's deterministic:** the stored SHA (same sources → same recorded SHA). +**What's NOT deterministic:** the DB binary itself. `meta.generated_at` uses +`datetime('now')`, SQLite `rowid`/`autoincrement` values drift across runs, and +transaction ordering can reshape freelist pages — two consecutive `make regen-db` +calls produce byte-different SQLite files even with identical inputs. This is +fine: the freshness guarantee comes from the stamp, not from bytewise DB equality. --- @@ -89,7 +103,10 @@ Or use `/pr-push` — it detects stale generator sources and reruns `make regen- automatically before pushing. The check script is `tooling/check-systems-db-stamp`. Run it interactively with -`make check-systems-db` or `python3 tooling/check-systems-db-stamp --verbose`. +`make check-systems-db` or `python3 tooling/check-systems-db-stamp --verbose`. The +`GENERATOR_SOURCES` dict at the top of that script is the single registry — when +you add a new generator or source file, update it there and mirror the change in +the `/pr-push` skill's source-file watch list. --- @@ -108,10 +125,45 @@ Two branches that both commit `server/data/systems.db` changes produce a binary merge conflict. Git cannot diff or merge binary SQLite files. Sprint 36 hit this exact class of problem. The meta stamp + pre-push hook is the systematic fix: -- Regeneration is deterministic (same sources → same DB, byte-for-byte) +- The stamp is deterministic (same generator source → same recorded SHA) - Only one branch modifies generator sources at a time (per team scope rules) - The pre-push hook is a hard blocker before the binary conflict can land +## The migration escape hatch + +The rule above says "never run UPDATE or INSERT directly on systems.db outside +of a migration." Here's what a legitimate migration looks like, and what isn't +one: + +**Sanctioned path: the `MIGRATION_SQL` block in `import_economics.py`.** That +string is executed at the top of every import run (inside the same transaction +that clears + reimports data) and contains idempotent `CREATE TABLE IF NOT +EXISTS` / `CREATE INDEX IF NOT EXISTS` statements, plus `ALTER TABLE` additions +handled via the `COLUMN_MIGRATIONS` list. When you need a new table, column, +or index on systems.db, add it there. It'll run on the next `make regen-db` +and the meta stamp will flip because `import_economics.py` changed. + +**Also legitimate:** edits to `server/data/systems-schema.sql` (the canonical +DDL used by fresh builds) paired with matching entries in `MIGRATION_SQL` for +existing DBs. The stamp's `schema_version` field records the schema file's +SHA at generation time — change the schema, commit both files together, and +the stamp picks it up automatically. + +**NOT legitimate and forbidden:** + +- Running `tooling/db/sqlite-exec` (or any raw SQL) against `systems.db` by + hand. Any changes you make are silently reverted by the next `regen-db` run + — your edits die, not the pipeline's. +- One-off patch scripts that open `systems.db` and modify rows. +- Editing the DB file with a SQLite GUI. +- Committing `systems.db` alone, without the corresponding source change that + would explain the diff on regen. + +If you think you need an exception, the right move is to make the source +change explicit instead: either edit the wiki TOMLs / JSONs that feed the +generators, or edit `MIGRATION_SQL` / `systems-schema.sql` directly. There is +no hand-edit path that survives regen. + --- ## Future: savegame migration lineage diff --git a/.claude/skills/pr-push/SKILL.md b/.claude/skills/pr-push/SKILL.md index b64f9d7d0..be1b5ea6f 100644 --- a/.claude/skills/pr-push/SKILL.md +++ b/.claude/skills/pr-push/SKILL.md @@ -218,13 +218,19 @@ If clean, continue. ### 4a. Regen systems.db if generator sources or data changed (#858) Check whether any file in the **source-file watch list** was modified on this branch -versus `origin/main`. This list covers generator code AND the data files that feed them: +versus `origin/main`. This list covers generator code AND the data files that feed them. + +The generator-source paths below **must stay in sync** with `GENERATOR_SOURCES` in +`tooling/check-systems-db-stamp` (PR #136 review T7) — if you add a new source file +to the stamp, add it here too, and vice versa. Drift between the two lists reintroduces +exactly the silent-stale-DB class of bug this skill exists to prevent. ```bash git diff --name-only origin/main...HEAD -- \ tooling/economy-db/import_economics.py \ tooling/planet-gen/generate_atlas.py \ server/src/bin/generate_brands/main.rs \ + server/src/bin/generate_brands/names.rs \ tooling/generate-brands \ server/data/systems-schema.sql \ wiki/star-systems/ \ diff --git a/.config/hooks/pre-push b/.config/hooks/pre-push index efa373773..b55df1f12 100755 --- a/.config/hooks/pre-push +++ b/.config/hooks/pre-push @@ -139,32 +139,32 @@ else fi # --- systems.db stamp check (#857) --- -# If server/data/systems.db is in the commits being pushed and its meta -# table shows a generator SHA mismatch, reject the push. This prevents -# pushing a stale DB snapshot where generator source was modified but the -# DB was not regenerated. +# If the branch touches server/data/systems.db and the meta stamp does not +# match current generator sources, reject the push. Prevents pushing a +# stale DB snapshot where generator source was modified but the DB was not +# regenerated. # -# Only runs when there is a known remote ref (i.e. the branch has been -# pushed before). Skipped for brand-new branches — the developer is -# setting up the tracking branch for the first time, and we cannot diff -# against a ref that doesn't exist yet. -if git rev-parse --verify "$REMOTE_REF" >/dev/null 2>&1; then - DB_IN_PUSH=$(git diff --name-only "$REMOTE_REF"..HEAD -- server/data/systems.db 2>/dev/null | wc -l) -else - DB_IN_PUSH=0 # new branch — skip stamp check -fi +# Runs whenever systems.db was modified in ANY branch commit vs. main — +# including on a branch's very first push (review T5: the previous version +# skipped the check for new branches because it compared against origin/$BRANCH, +# which didn't exist yet, leaving a gap where a stale DB could ship via the +# first push). We compare against origin/main — which always exists — so the +# check covers the first-push case. +DB_IN_PUSH=$(git diff --name-only origin/main...HEAD -- server/data/systems.db 2>/dev/null | wc -l) if [ "$DB_IN_PUSH" -gt 0 ] && [ -f "$REPO_ROOT/tooling/check-systems-db-stamp" ]; then echo "pre-push: checking systems.db stamp..." rc=0 python3 "$REPO_ROOT/tooling/check-systems-db-stamp" || rc=$? if [ "$rc" -eq 1 ]; then - # rc=1 means stale; error message already printed to stderr + # rc=1 means stale / unknown generator / missing source; message on stderr echo " Fix: run 'make regen-db' then stage server/data/systems.db" echo " Or use /pr-push — it handles regen automatically before pushing." ERRORS=$((ERRORS + 1)) elif [ "$rc" -eq 2 ]; then - # rc=2 means no meta table — treat as unstamped, warn but don't block - echo "pre-push: WARNING — systems.db has no meta stamp; run 'make regen-db' after next regeneration" + # rc=2 means no meta table — treat as unstamped, warn but don't block. + # This is legitimate immediately after the meta table is introduced; + # the next `make regen-db` will populate it (H4). + echo "pre-push: WARNING — systems.db has no meta stamp — run 'make regen-db' now if this DB was generated by you" else echo "pre-push: systems.db stamp — OK" fi diff --git a/Makefile b/Makefile index 845579565..2f3ac9a94 100644 --- a/Makefile +++ b/Makefile @@ -355,15 +355,22 @@ atlas-generate: ## Generate atlas markers (cities, roads, rail) for all inhabit @python3 tooling/planet-gen/generate_atlas.py --seed 42 regen-db: ## Regenerate systems.db from all sources and stamp meta table (#855, #856) - @echo " [regen-db] Generating minor brands..." - @tooling/generate-brands - @echo " [regen-db] Importing economics data..." - @python3 tooling/economy-db/import_economics.py; ec=$$?; [ $$ec -eq 0 ] || [ $$ec -eq 2 ] || exit $$ec - @echo " [regen-db] Running atlas generator..." - @python3 tooling/planet-gen/generate_atlas.py --seed 42 - @echo "" - @echo " regen-db complete — systems.db is up to date and stamped." - @echo " Stage it with: git add server/data/systems.db" + @# Run as a single shell so `set -e` covers all steps. Without this + @# each recipe line was a fresh shell and a failure in step 1 did not + @# halt step 2, which could produce stale data with a fresh stamp + @# (PR #136 review T4). import_economics' exit code 2 is a valid + @# coverage-gate-warning state (DB and stamp committed), not an error, + @# so it's explicitly tolerated. Any other non-zero exit halts the + @# pipeline immediately. + @set -e; \ + echo " [regen-db] Importing economics data (runs generate_brands internally)..."; \ + ec=0; python3 tooling/economy-db/import_economics.py || ec=$$?; \ + if [ $$ec -ne 0 ] && [ $$ec -ne 2 ]; then exit $$ec; fi; \ + echo " [regen-db] Running atlas generator..."; \ + python3 tooling/planet-gen/generate_atlas.py --seed 42; \ + echo ""; \ + echo " regen-db complete — systems.db is up to date and stamped."; \ + echo " Stage it with: git add server/data/systems.db" check-systems-db: ## Verify systems.db meta stamp matches current generator sources (#857) @python3 tooling/check-systems-db-stamp --verbose diff --git a/server/data/systems.db b/server/data/systems.db index e635a0077..815b849e0 100644 Binary files a/server/data/systems.db and b/server/data/systems.db differ diff --git a/tooling/check-systems-db-stamp b/tooling/check-systems-db-stamp index 99a887a70..cb44341c1 100644 --- a/tooling/check-systems-db-stamp +++ b/tooling/check-systems-db-stamp @@ -7,7 +7,7 @@ generator's source file(s) matches the current file content on disk. Exit codes: 0 — DB is stamped and all generator SHAs match current sources - 1 — DB is stale (one or more generators have changed since last regen) + 1 — DB is stale, has an unknown generator, or references a missing source file 2 — DB does not have a meta table (treat as unstamped — run make regen-db) Usage (called by .config/hooks/pre-push): @@ -31,12 +31,16 @@ DB_PATH = REPO_ROOT / "server" / "data" / "systems.db" # Maps generator_name (as stored in meta.generator_name) to the source # file(s) whose SHA is stamped. The SHA is computed as SHA-1 of the # concatenated bytes of all files in sorted order. +# +# import_economics' source set includes the Rust generate_brands binary it now +# invokes as a subroutine (#136 review T2/H3). Keep this list in sync with +# IMPORT_ECONOMICS_SOURCES in tooling/economy-db/import_economics.py. GENERATOR_SOURCES: dict[str, list[Path]] = { "import_economics": [ REPO_ROOT / "tooling" / "economy-db" / "import_economics.py", - ], - "generate_brands": [ REPO_ROOT / "server" / "src" / "bin" / "generate_brands" / "main.rs", + REPO_ROOT / "server" / "src" / "bin" / "generate_brands" / "names.rs", + REPO_ROOT / "tooling" / "generate-brands", ], "generate_atlas": [ REPO_ROOT / "tooling" / "planet-gen" / "generate_atlas.py", @@ -45,11 +49,17 @@ GENERATOR_SOURCES: dict[str, list[Path]] = { def file_sha1(*paths: Path) -> str: - """SHA-1 of concatenated file contents (sorted paths, missing files skipped).""" + """SHA-1 of concatenated file contents (sorted paths). + + Missing files raise FileNotFoundError rather than silently contributing + an empty-string hash (H2): a ghost SHA could mask real breakage when + stored and current SHAs converge on the empty-bytes digest. + """ h = hashlib.sha1() for p in sorted(paths): - if p.exists(): - h.update(p.read_bytes()) + if not p.exists(): + raise FileNotFoundError(f"generator source not found: {p}") + h.update(p.read_bytes()) return h.hexdigest() @@ -80,12 +90,25 @@ def check(verbose: bool = False) -> int: return 2 stale: list[str] = [] + unknown: list[str] = [] for generator_name, stored_sha in rows: sources = GENERATOR_SOURCES.get(generator_name) if sources is None: - # Unknown generator — skip (forward compat) + # Unknown generator — fail closed (T6). A future branch adding a + # new generator without registering it here must update this map + # before the check will pass, preventing the "silent no-op" trap. + unknown.append(generator_name) continue - current_sha = file_sha1(*sources) + try: + current_sha = file_sha1(*sources) + except FileNotFoundError as exc: + # Source file moved/deleted — explicit failure instead of + # silent empty-hash (H2). + print( + f"check-systems-db-stamp: BROKEN — {generator_name}: {exc}", + file=sys.stderr, + ) + return 1 if current_sha != stored_sha: stale.append(generator_name) if verbose: @@ -95,6 +118,19 @@ def check(verbose: bool = False) -> int: f"\n current: {current_sha}" ) + if unknown: + print( + "check-systems-db-stamp: UNKNOWN generator(s) in meta table: " + f"{unknown}", + file=sys.stderr, + ) + print( + " Update GENERATOR_SOURCES in tooling/check-systems-db-stamp to " + "register them before pushing.", + file=sys.stderr, + ) + return 1 + if stale: if not verbose: print( diff --git a/tooling/economy-db/import_economics.py b/tooling/economy-db/import_economics.py index c79830652..77df10fb2 100755 --- a/tooling/economy-db/import_economics.py +++ b/tooling/economy-db/import_economics.py @@ -42,33 +42,54 @@ SCHEMA_SQL = REPO_ROOT / "server" / "data" / "systems-schema.sql" CORPORATIONS_DIR = REPO_ROOT / "wiki" / "corporations" BRANDS_TOML = REPO_ROOT / "wiki" / "economics" / "corporations" / "brands.toml" GENERATED_BRANDS_TOML = REPO_ROOT / "wiki" / "economics" / "corporations" / "generated_brands.toml" -# Source file for the generate_brands Rust binary — stamped on behalf (#855) -GENERATE_BRANDS_SRC = REPO_ROOT / "server" / "src" / "bin" / "generate_brands" / "main.rs" +# Rust sources for the generate_brands subroutine. import_economics shells out to +# tooling/generate-brands as part of its normal flow (see regenerate_brands()), so +# both Rust files contribute to this script's effective source SHA: any change to +# either must invalidate the meta stamp even though Python hasn't changed. +GENERATE_BRANDS_RS = REPO_ROOT / "server" / "src" / "bin" / "generate_brands" / "main.rs" +GENERATE_BRANDS_NAMES_RS = REPO_ROOT / "server" / "src" / "bin" / "generate_brands" / "names.rs" +GENERATE_BRANDS_WRAPPER = REPO_ROOT / "tooling" / "generate-brands" def _file_sha1(*paths: Path) -> str: """Return SHA-1 hex of the concatenated content of one or more files. - Files are sorted by path for determinism. Missing files are silently - skipped so a fresh worktree that hasn't built the Rust binary yet - doesn't fail to stamp. + Files are sorted by path for determinism. Missing files raise FileNotFoundError + rather than silently contributing an empty-string hash — a ghost SHA masks real + breakage (review comment H2: da39a3ee… convergence could produce vacuous passes). """ h = hashlib.sha1() for p in sorted(paths): - if p.exists(): - h.update(p.read_bytes()) + if not p.exists(): + raise FileNotFoundError(f"generator source not found: {p}") + h.update(p.read_bytes()) return h.hexdigest() +# Canonical source set for import_economics' meta stamp. Covers its own .py file +# plus the Rust binary it invokes (generate_brands main.rs + names.rs + wrapper +# script) so any change to the brand generation pipeline flips the stamp. Keep +# this list in sync with GENERATOR_SOURCES["import_economics"] in +# tooling/check-systems-db-stamp. +IMPORT_ECONOMICS_SOURCES: tuple[Path, ...] = ( + Path(__file__), + GENERATE_BRANDS_RS, + GENERATE_BRANDS_NAMES_RS, + GENERATE_BRANDS_WRAPPER, +) + + def _write_stamp(conn: sqlite3.Connection, generator_name: str, *source_files: Path) -> None: """Upsert a row in the meta table recording this generator's current source SHA. Called after every successful non-dry-run commit. Idempotent: running twice on the same sources writes the same sha with an updated timestamp. - Generators stamped here: - import_economics — this file - generate_brands — server/src/bin/generate_brands/main.rs (stamped on behalf) + Only one stamp is written by this module: ``import_economics``, whose source + set includes the Rust binary it invokes (see IMPORT_ECONOMICS_SOURCES). + generate_atlas writes its own stamp. generate_brands does NOT write a stamp + of its own — it's a subroutine of import_economics, not an independent DB + writer (PR #136 review T2/H3). The meta table is created by the MIGRATION_SQL block above; this function assumes it exists (caller must run migrations first). @@ -82,6 +103,42 @@ def _write_stamp(conn: sqlite3.Connection, generator_name: str, *source_files: P ) +def regenerate_brands() -> None: + """Run the Rust generate_brands binary to refresh generated_brands.toml. + + Invoked as the first step of import_economics' main flow so the TOML on disk + always matches the current Rust source before the Python import reads it. + This replaces the former split (tooling/generate-brands run separately by + make regen-db) with a single, coherent brand pipeline owned by one stamp. + + The wrapper script builds the binary on demand and runs it with the default + canonical seed=1; callers that need non-canonical seeds must still invoke + the wrapper directly (experimentation only — committed output must be seed=1). + """ + import subprocess + + if not GENERATE_BRANDS_WRAPPER.exists(): + raise FileNotFoundError( + f"generate_brands wrapper not found at {GENERATE_BRANDS_WRAPPER}" + ) + print(" [pre/10] Running generate_brands (Rust) to refresh generated_brands.toml...") + result = subprocess.run( + [str(GENERATE_BRANDS_WRAPPER)], + cwd=str(REPO_ROOT), + capture_output=True, + text=True, + ) + if result.returncode != 0: + print(result.stdout, file=sys.stderr) + print(result.stderr, file=sys.stderr) + raise _ImportAborted() + # Print the Rust binary's own summary lines (brands generated, coverage). + # Indent so they fold under the pre-step heading. + for line in result.stdout.splitlines(): + if line.strip(): + print(f" {line}") + + class _ImportAborted(Exception): """Raised internally by main() when a validation step wants a clean rollback + exit 1. Caught only by main(); error messages are printed @@ -217,6 +274,13 @@ CREATE TABLE IF NOT EXISTS meta ( generator_sha TEXT NOT NULL, generated_at TEXT NOT NULL DEFAULT (datetime('now')) ); + +-- Drop the pre-merge 'generate_brands' stamp row if it exists (PR #136 review T2/H3). +-- The Rust brand binary is now a subroutine of import_economics — its source +-- SHA contributes to the 'import_economics' stamp — so it no longer merits its +-- own meta row. This DELETE makes the check-systems-db-stamp "unknown generator" +-- path (fail-closed per T6) compatible with older DBs that still have the row. +DELETE FROM meta WHERE generator_name = 'generate_brands'; """ # Columns to add to existing tables (ALTER TABLE is idempotent via try/except) @@ -1057,6 +1121,18 @@ def main(): wiki_corps = load_wiki_corps() print(f" {len(wiki_corps)} corporation files parsed") + # Regenerate generated_brands.toml via the Rust binary before the Python + # import reads it. Single pipeline, single stamp — resolves review T2/H3 + # ("on-behalf stamping" coupling) by folding brand generation into + # import_economics' flow rather than having the caller (Makefile / user) + # remember to run it first. Skipped on --dry-run to avoid a disk + # side-effect during validation. + if not args.dry_run: + try: + regenerate_brands() + except _ImportAborted: + sys.exit(1) + conn = sqlite3.connect(str(db_path)) conn.execute("PRAGMA foreign_keys=ON") @@ -1190,10 +1266,9 @@ def main(): # issues and must not prevent the stamp from landing. if not args.dry_run: try: - _write_stamp(conn, "import_economics", Path(__file__)) - _write_stamp(conn, "generate_brands", GENERATE_BRANDS_SRC) + _write_stamp(conn, "import_economics", *IMPORT_ECONOMICS_SOURCES) conn.commit() - print(" Stamped: import_economics, generate_brands") + print(" Stamped: import_economics (covers brand pipeline Rust sources)") except Exception as exc: # noqa: BLE001 print(f" WARNING: failed to write generator stamp: {exc}", file=sys.stderr) diff --git a/tooling/planet-gen/generate_atlas.py b/tooling/planet-gen/generate_atlas.py index 38e57ce3c..03fede765 100644 --- a/tooling/planet-gen/generate_atlas.py +++ b/tooling/planet-gen/generate_atlas.py @@ -95,13 +95,16 @@ _ATLAS_SCHEMA_END_MARKER = "-- END ATLAS INDEX" def _file_sha1(*paths: Path) -> str: """Return SHA-1 hex of the concatenated content of one or more files. - Files are sorted by path for determinism. Missing files are silently - skipped — a fresh worktree lacking optional build artefacts still stamps. + Files are sorted by path for determinism. Missing files raise + FileNotFoundError rather than silently skip — a ghost hash (empty-bytes + digest) can mask real breakage when stored and current SHAs converge + (#136 review H2). """ h = hashlib.sha1() for p in sorted(paths): - if p.exists(): - h.update(p.read_bytes()) + if not p.exists(): + raise FileNotFoundError(f"generator source not found: {p}") + h.update(p.read_bytes()) return h.hexdigest() @@ -112,6 +115,10 @@ def _write_stamp(conn: sqlite3.Connection) -> None: with an updated timestamp. The meta table is created by the atlas schema migration executed in ensure_atlas_schema(); this function assumes it exists. + + Transaction ownership stays with the caller (matches the import_economics + pattern) — no inner commit here. Review H1 flagged the prior behaviour as + a double-commit with the atlas data write that precedes it. """ schema_sha = _file_sha1(SYSTEMS_SCHEMA_PATH) generator_sha = _file_sha1(Path(__file__)) @@ -121,7 +128,6 @@ def _write_stamp(conn: sqlite3.Connection) -> None: VALUES ('generate_atlas', ?, ?, datetime('now'))""", (schema_sha, generator_sha), ) - conn.commit() def _load_atlas_schema() -> str: @@ -1381,14 +1387,19 @@ def main(): print(f" [{i+1}/{len(bodies)}] {body_id:20s} ERROR: {result.get('message', '')}") if not args.dry_run: - conn.commit() - # Stamp generator metadata (#855, #856) after a successful run. - # Failure is non-fatal (metadata only) but reported. + # Stamp generator metadata (#855, #856) together with the atlas data + # in a single commit — atlas data + stamp land atomically, and the + # stamp function itself no longer commits (H1). Failure of the stamp + # write rolls back the atlas data too rather than leaving a stamped- + # but-missing-data intermediate state. try: _write_stamp(conn) + conn.commit() print(" Stamped: generate_atlas") except Exception as exc: # noqa: BLE001 + conn.rollback() print(f" WARNING: failed to write generator stamp: {exc}", file=sys.stderr) + print(" Atlas data NOT committed — regen required.", file=sys.stderr) conn.close() elapsed_total = time.time() - t_total