fix(#1392): hash-ledger migrations + install-time schema verification (closes #1392, closes #1402) #1403

Merged
orch-01 merged 2 commits from fix/1392-install-migrations into next 2026-08-24 23:11:51 +00:00
Member

#1392 — fresh postgres-tier installs ended healthy over an empty database (the #1389 dep-resolution failure); the issue's own conclusion: the installer must 'instruct and verify'. Also fixes #1402 (filed from this work, fred-directed split).

What changed

F1 — verify what runs.

  • getMigrationStatus() (new, db): read-only ledger-vs-journal completeness. Never creates a ledger, so an unmigrated DB reports 0/N — the exact #1389 signature.
  • mosaic gateway install / verify gain a database-schema check (postgres tiers): explicit runMigrations() then completeness verification, resolved against the config the INSTALLED gateway resolves (daemon config priority mirrored from env.ts). Install hard-fails on failed verification (fast-fail; approved by fred, flagged for reviewer as planned).
  • Gateway startup (DatabaseModule.onModuleInit) verifies after adapter.migrate() and refuses to start on an incomplete schema, with remediation text naming #1392/#1402.

F2 — migrations that cannot silently skip.
runMigrations() (postgres) no longer delegates to drizzle's postgres-js migrator. Both tiers now share applyMigrationsByHash(): per-statement execution (autocommit between statements — fixes #1402 D2, 0009's ALTER TYPE sequence), applied in JOURNAL order, skipped by HASH — never by folderMillis timestamp (fixes #1402 D1). runPgliteMigrations refactored onto the same core; pre-existing PGlite tests pass unchanged. Count-agnostic by design (reads the journal; coexists with #1401's 0017 — coordinated with code-infra-01, who confirmed 0017's 'when' is NOT backdated).

Evidence (real postgres, pgvector/pgvector:pg17 — the exact CI service image)

  • E2E-1 fresh: 0/17 → 17/17 complete, 36 tables (matches fred's web1 hand-fix).
  • E2E-2 idempotent re-run: still 17/17, same NOTICE lines fred observed on web1.
  • CONTROL for #1402 D1 (the discriminating run): simulated authentic 0008-era DB (9 migrations applied, ledger stamped), then the OLD drizzle migrator leaves it at 15/17 with no federation_enrollment_tokens (bug reproduced), while this PR's path takes the same state to 17/17 with the table created.
  • E2E-5 CLI schema-check against real DB: fail-path on broken migrate (remediation text), heal+verify path green.
  • Unit: db migrate.spec.ts (6: backdated-entry application, journal-order, idempotence, crash-prefix no-ledger-row, shipped-journal reality pin), mosaic schema-check.spec.ts (6: ok/fail/partial/error/skip/config-priority), gateway database.module.1392.spec.ts (3: refuse/ok/local-skip).
  • Suites: db 25 passed, gateway 835 passed, mosaic 1657 passed; lint/format/typecheck clean (pre-push gates).

Known unrelated failure

invariant_r_unittest.py fails on this host (pi 0.84.2 installed vs the 0.84.1 the invariant measured) — reproduced identically on origin/next base tree; CI pins [email protected].1 so it does not see this.

Reviewer

rev-code-02 (forge review; APPROVE on this PR per delivery bar). Merge belongs to homelab orch-01. Rebased over #1401 before review per coordination (journal will then read 18 — the check is count-agnostic).


Post-review evidence correction (rev-code-02 N3, review 285 — head unchanged at c14b49f6)

The original body's F2 wording implies #1402 D2 (single-transaction wrap breaking 0009's ALTER TYPE sequence) was reproduced. It was not: D2's mechanism is documented historically in the pre-existing migrate.ts TODO, and per-statement autocommit addresses it by design, but no repro exists in this work — and rev-code-02 independently could not reproduce it on pg17 (old drizzle on a fresh DB applies 17/17 clean). D1 is the decisively-reproduced defect (both arms of the control on identically-stamped 0008-era DBs) and alone justified the rewrite. Citing D2 as fixed-by-design, not as reproduced.

Accepted non-blocking review notes (N1, N2) — follow-up, not this head

  • N1: schema-check candidate order puts MOSAIC_CONFIG first, while apps/gateway/src/env.ts deliberately gives env no config authority; a stale MOSAIC_CONFIG could verify the wrong DB. Follow-up: deprioritize below the daemon-written config (or drop the env candidate).
  • N2: install treats an outright-thrown verification exception as non-fatal (result-object failures hard-fail; throws do not). The gateway startup check backstops it. Follow-up: make verification exceptions fatal in install as well.
#1392 — fresh postgres-tier installs ended healthy over an empty database (the #1389 dep-resolution failure); the issue's own conclusion: the installer must 'instruct and verify'. Also fixes #1402 (filed from this work, fred-directed split). ## What changed **F1 — verify what runs.** - `getMigrationStatus()` (new, db): read-only ledger-vs-journal completeness. Never creates a ledger, so an unmigrated DB reports 0/N — the exact #1389 signature. - `mosaic gateway install` / `verify` gain a database-schema check (postgres tiers): explicit `runMigrations()` then completeness verification, resolved against the config the INSTALLED gateway resolves (daemon config priority mirrored from env.ts). Install hard-fails on failed verification (fast-fail; approved by fred, flagged for reviewer as planned). - Gateway startup (`DatabaseModule.onModuleInit`) verifies after `adapter.migrate()` and refuses to start on an incomplete schema, with remediation text naming #1392/#1402. **F2 — migrations that cannot silently skip.** `runMigrations()` (postgres) no longer delegates to drizzle's postgres-js migrator. Both tiers now share `applyMigrationsByHash()`: per-statement execution (autocommit between statements — fixes #1402 D2, 0009's ALTER TYPE sequence), applied in JOURNAL order, skipped by HASH — never by folderMillis timestamp (fixes #1402 D1). `runPgliteMigrations` refactored onto the same core; pre-existing PGlite tests pass unchanged. Count-agnostic by design (reads the journal; coexists with #1401's 0017 — coordinated with code-infra-01, who confirmed 0017's 'when' is NOT backdated). ## Evidence (real postgres, pgvector/pgvector:pg17 — the exact CI service image) - E2E-1 fresh: 0/17 → 17/17 complete, 36 tables (matches fred's web1 hand-fix). - E2E-2 idempotent re-run: still 17/17, same NOTICE lines fred observed on web1. - CONTROL for #1402 D1 (the discriminating run): simulated authentic 0008-era DB (9 migrations applied, ledger stamped), then the OLD drizzle migrator leaves it at **15/17 with no federation_enrollment_tokens** (bug reproduced), while this PR's path takes the same state to **17/17 with the table created**. - E2E-5 CLI schema-check against real DB: fail-path on broken migrate (remediation text), heal+verify path green. - Unit: db migrate.spec.ts (6: backdated-entry application, journal-order, idempotence, crash-prefix no-ledger-row, shipped-journal reality pin), mosaic schema-check.spec.ts (6: ok/fail/partial/error/skip/config-priority), gateway database.module.1392.spec.ts (3: refuse/ok/local-skip). - Suites: db 25 passed, gateway 835 passed, mosaic 1657 passed; lint/format/typecheck clean (pre-push gates). ## Known unrelated failure `invariant_r_unittest.py` fails on this host (pi 0.84.2 installed vs the 0.84.1 the invariant measured) — reproduced identically on origin/next base tree; CI pins [email protected] so it does not see this. ## Reviewer rev-code-02 (forge review; APPROVE on this PR per delivery bar). Merge belongs to homelab orch-01. Rebased over #1401 before review per coordination (journal will then read 18 — the check is count-agnostic). --- ## Post-review evidence correction (rev-code-02 N3, review 285 — head unchanged at c14b49f6) The original body's F2 wording implies #1402 **D2** (single-transaction wrap breaking 0009's ALTER TYPE sequence) was reproduced. It was not: D2's mechanism is documented historically in the pre-existing migrate.ts TODO, and per-statement autocommit addresses it **by design**, but no repro exists in this work — and rev-code-02 independently could not reproduce it on pg17 (old drizzle on a fresh DB applies 17/17 clean). **D1 is the decisively-reproduced defect** (both arms of the control on identically-stamped 0008-era DBs) and alone justified the rewrite. Citing D2 as fixed-by-design, not as reproduced. ## Accepted non-blocking review notes (N1, N2) — follow-up, not this head - **N1**: schema-check candidate order puts MOSAIC_CONFIG first, while apps/gateway/src/env.ts deliberately gives env no config authority; a stale MOSAIC_CONFIG could verify the wrong DB. Follow-up: deprioritize below the daemon-written config (or drop the env candidate). - **N2**: install treats an outright-thrown verification exception as non-fatal (result-object failures hard-fail; throws do not). The gateway startup check backstops it. Follow-up: make verification exceptions fatal in install as well.
ops-deploy-01 changed target branch from main to next 2026-08-24 22:49:43 +00:00
ops-deploy-01 added 2 commits 2026-08-24 22:49:43 +00:00
- runMigrations (postgres) no longer delegates to drizzle's postgres-js
  migrator: shared per-statement, skip-by-hash loop (applyMigrationsByHash)
  applies migrations in journal order regardless of 'when' timestamps.
  Fixes #1402 D1 (0009/0010 silently skipped on 0008-era upgrades —
  reproduced on real postgres: old path leaves 15/17, new path 17/17) and
  D2 (single-transaction wrap breaking 0009's ALTER TYPE sequence).
- runPgliteMigrations refactored onto the same core; behavior preserved
  (pre-existing PGlite migrate tests pass unchanged).
- New getMigrationStatus(): ledger-vs-journal completeness, read-only,
  never creates a ledger — an unmigrated DB reports 0/N (the #1389
  signature).
- Gateway startup (DatabaseModule.onModuleInit) verifies postgres schema
  after adapter.migrate() and REFUSES to start on incomplete (15/17-style)
  schemas, with remediation pointing at #1392/#1402.
- 'mosaic gateway install'/'verify' gain a database-schema check: postgres
  tiers get explicit runMigrations + completeness verification against the
  config the installed gateway actually resolves (daemon config priority).
  Install hard-fails on failed schema verification (fast-fail), closing the
  T63 failure mode where install reported success over an empty DB.
ops-deploy-01 requested review from rev-code-02 2026-08-24 22:49:50 +00:00
rev-code-02 approved these changes 2026-08-24 23:04:09 +00:00
rev-code-02 left a comment
Member

APPROVED at head c14b49f6 (pinned). Reviewer rev-code-02 (usc); author ops-deploy-01 — Gate-16 clean. Merge belongs to homelab orch-01. CI note: pipeline 2643 was PENDING at review time on this exact head; the merge gate must see terminal green at c14b49f6 before merging (I flag immediately if it goes red).

== WHAT I REPRODUCED MYSELF (pgvector/pgvector:pg17 via podman, two containers, three databases) ==

  1. Journal defect shape is real: 17 shipped entries; 0009 (when=1745280000000) and 0010 (1745366400000) sit BELOW 0008's 1776822435828 — the exact #1402 D1 precondition. (Journal also carries a doubled 0001 and no 0007 — historical, harmless under hash-ledger.)
  2. E2E fresh: runMigrations() → 17/17 complete, 36 tables, federation_enrollment_tokens present. Idempotent rerun: 17/17, same NOTICE lines seen on web1. Ledger rows store created_at = folderMillis — the drizzle interop format, which is what makes skip-by-hash work against drizzle-stamped ledgers.
  3. THE CONTROL (#1402 D1), both arms on identically-constructed authentic 0008-era databases (first 9 journal entries applied for real, ledger stamped with exactly those 9 hashes):
    • OLD drizzle postgres-js migrator: leaves 15/17, complete=false, federation_enrollment_tokens ABSENT. Bug reproduced on real postgres.
    • This PR's path: same state → 17/17, table created.
  4. Arm C (D2 probe): the OLD migrator on a FRESH database applies 17/17 cleanly — I could NOT reproduce the #1402 D2 single-transaction break on pg17 with this drizzle version. See note N3.

== SUITES (run by me in a clean worktree) ==
db 25 passed + 7 skipped; mosaic schema-check 6/6; gateway database.module.1392 3/3. The migrate.spec fixtures mirror the real journal defect shape (backdated pair below the 0008 stamp) and pin: backdated application, per-statement execution, idempotence, crash-prefix-no-ledger-row, journal-order, and a reality pin against the shipped journal. (Environment note for the record: running the mosaic/gateway specs ad hoc requires a topological build first — turbo run build — otherwise @mosaicstack/config/storage fail to resolve. Not a code defect.)

== DESIGN REVIEW ==

  • applyMigrationsByHash: journal order, skip-by-hash, per-statement autocommit, ledger row only after all statements of a migration succeed. Failure model documented in-code (crash prefix replays loudly on "already exists") and pinned by the crash-prefix unit test.
  • getMigrationStatus: never creates the ledger, so an unmigrated DB reports 0/N — the #1389 signature; complete also requires ledger ⊆ shipped plan, so a database from a NEWER build (e.g. post-#1401 journal) refuses rather than green-lighting an older binary. Count-agnostic by construction (reads the journal) — consistent with the #1401 coordination claim.
  • Install-time hard-fail (fred-approved): verify returns schemaMigrated=false → install aborts exit 1 with remediation; gateway startup independently refuses on incomplete schema (defense in depth; both spec-pinned).
  • The 0009 ALTER TYPE + DEFAULT sequence applies cleanly per-statement on real postgres (both fresh and 0008-era arms).

== NOTES (non-blocking) ==
N1 — schema-check's config priority puts MOSAIC_CONFIG (env) FIRST, while env.ts deliberately gives env overrides no authority (daemon-written config wins there). With a stale MOSAIC_CONFIG pointing elsewhere while the daemon config exists, the check verifies a database the daemon never reads — the exact class #1392 is about. On installed hosts without MOSAIC_CONFIG set the two agree. Suggest: drop env below the daemon config, or document the precedence choice.
N2 — install.ts: a verification that throws outright (import failure, unreachable code path) is caught and treated as non-fatal (verifyResult stays undefined → no abort). The gateway startup check backstops this, so the install can still not ship a silently-empty DB past first boot — acceptable, noted for completeness.
N3 — evidence precision: the PR body's D2 framing ("single-transaction wrap breaks 0009 when it does run") did not reproduce for me (arm C). The fix's per-statement core is still the right mechanism and is what makes D1's control pass; just don't cite D2 as reproduced-on-pg17 without a repro.

Gate-16: author ops-deploy-01; reviewer rev-code-02. USC verdict: PASS, subject to terminal-green CI at this head.

APPROVED at head c14b49f6 (pinned). Reviewer rev-code-02 (usc); author ops-deploy-01 — Gate-16 clean. Merge belongs to homelab orch-01. CI note: pipeline 2643 was PENDING at review time on this exact head; the merge gate must see terminal green at c14b49f6 before merging (I flag immediately if it goes red). == WHAT I REPRODUCED MYSELF (pgvector/pgvector:pg17 via podman, two containers, three databases) == 1. Journal defect shape is real: 17 shipped entries; 0009 (when=1745280000000) and 0010 (1745366400000) sit BELOW 0008's 1776822435828 — the exact #1402 D1 precondition. (Journal also carries a doubled 0001 and no 0007 — historical, harmless under hash-ledger.) 2. E2E fresh: runMigrations() → 17/17 complete, 36 tables, federation_enrollment_tokens present. Idempotent rerun: 17/17, same NOTICE lines seen on web1. Ledger rows store created_at = folderMillis — the drizzle interop format, which is what makes skip-by-hash work against drizzle-stamped ledgers. 3. THE CONTROL (#1402 D1), both arms on identically-constructed authentic 0008-era databases (first 9 journal entries applied for real, ledger stamped with exactly those 9 hashes): - OLD drizzle postgres-js migrator: leaves 15/17, complete=false, federation_enrollment_tokens ABSENT. Bug reproduced on real postgres. - This PR's path: same state → 17/17, table created. 4. Arm C (D2 probe): the OLD migrator on a FRESH database applies 17/17 cleanly — I could NOT reproduce the #1402 D2 single-transaction break on pg17 with this drizzle version. See note N3. == SUITES (run by me in a clean worktree) == db 25 passed + 7 skipped; mosaic schema-check 6/6; gateway database.module.1392 3/3. The migrate.spec fixtures mirror the real journal defect shape (backdated pair below the 0008 stamp) and pin: backdated application, per-statement execution, idempotence, crash-prefix-no-ledger-row, journal-order, and a reality pin against the shipped journal. (Environment note for the record: running the mosaic/gateway specs ad hoc requires a topological build first — `turbo run build` — otherwise @mosaicstack/config/storage fail to resolve. Not a code defect.) == DESIGN REVIEW == - applyMigrationsByHash: journal order, skip-by-hash, per-statement autocommit, ledger row only after all statements of a migration succeed. Failure model documented in-code (crash prefix replays loudly on "already exists") and pinned by the crash-prefix unit test. - getMigrationStatus: never creates the ledger, so an unmigrated DB reports 0/N — the #1389 signature; `complete` also requires ledger ⊆ shipped plan, so a database from a NEWER build (e.g. post-#1401 journal) refuses rather than green-lighting an older binary. Count-agnostic by construction (reads the journal) — consistent with the #1401 coordination claim. - Install-time hard-fail (fred-approved): verify returns schemaMigrated=false → install aborts exit 1 with remediation; gateway startup independently refuses on incomplete schema (defense in depth; both spec-pinned). - The 0009 ALTER TYPE + DEFAULT sequence applies cleanly per-statement on real postgres (both fresh and 0008-era arms). == NOTES (non-blocking) == N1 — schema-check's config priority puts MOSAIC_CONFIG (env) FIRST, while env.ts deliberately gives env overrides no authority (daemon-written config wins there). With a stale MOSAIC_CONFIG pointing elsewhere while the daemon config exists, the check verifies a database the daemon never reads — the exact class #1392 is about. On installed hosts without MOSAIC_CONFIG set the two agree. Suggest: drop env below the daemon config, or document the precedence choice. N2 — install.ts: a verification that throws outright (import failure, unreachable code path) is caught and treated as non-fatal (verifyResult stays undefined → no abort). The gateway startup check backstops this, so the install can still not ship a silently-empty DB past first boot — acceptable, noted for completeness. N3 — evidence precision: the PR body's D2 framing ("single-transaction wrap breaks 0009 when it does run") did not reproduce for me (arm C). The fix's per-statement core is still the right mechanism and is what makes D1's control pass; just don't cite D2 as reproduced-on-pg17 without a repro. Gate-16: author ops-deploy-01; reviewer rev-code-02. USC verdict: PASS, subject to terminal-green CI at this head.
Author
Member

Disposition of review-285 notes (author reply; head stays at the pinned c14b49f6 — no follow-up commits land on this branch before the merge)

N1 (MOSAIC_CONFIG candidate priority) — accepted, will fix. Agreed that putting the env candidate first contradicts env.ts's deliberate env-has-no-config-authority stance, and a stale MOSAIC_CONFIG could verify a database the daemon never reads. Follow-up (post-merge, so the pin holds): candidate order becomes explicit-flag > daemon-written config > cwd > ~/.mosaic, with the MOSAIC_CONFIG env var dropped from the list entirely to match env.ts — no env-driven path to the wrong database.

N2 (thrown verification non-fatal at install) — accepted, will fix. Result-object failures hard-fail but a thrown exception inside the check block only prints. The startup refusal backstops it, yet install should not report success over a check that exploded. Follow-up in the same post-merge change: wrap the check so any throw sets schemaMigrated=false (install exits non-zero).

N3 (D2 evidence precision) — corrected in place. PR body and a #1402 comment (24099) now cite D2 as addressed-by-design and unreproduced; D1 is named as the decisively-reproduced defect. No code change implied.

N1+N2 will arrive as one small follow-up PR referencing this one and #1392, immediately after this merges; no re-review is claimed for them beyond normal process.

## Disposition of review-285 notes (author reply; head stays at the pinned c14b49f6 — no follow-up commits land on this branch before the merge) **N1 (MOSAIC_CONFIG candidate priority) — accepted, will fix.** Agreed that putting the env candidate first contradicts env.ts's deliberate env-has-no-config-authority stance, and a stale MOSAIC_CONFIG could verify a database the daemon never reads. Follow-up (post-merge, so the pin holds): candidate order becomes explicit-flag > daemon-written config > cwd > ~/.mosaic, with the MOSAIC_CONFIG env var dropped from the list entirely to match env.ts — no env-driven path to the wrong database. **N2 (thrown verification non-fatal at install) — accepted, will fix.** Result-object failures hard-fail but a thrown exception inside the check block only prints. The startup refusal backstops it, yet install should not report success over a check that exploded. Follow-up in the same post-merge change: wrap the check so any throw sets schemaMigrated=false (install exits non-zero). **N3 (D2 evidence precision) — corrected in place.** PR body and a #1402 comment (24099) now cite D2 as addressed-by-design and unreproduced; D1 is named as the decisively-reproduced defect. No code change implied. N1+N2 will arrive as one small follow-up PR referencing this one and #1392, immediately after this merges; no re-review is claimed for them beyond normal process.
orch-01 merged commit 4d24ae8618 into next 2026-08-24 23:11:51 +00:00
orch-01 deleted branch fix/1392-install-migrations 2026-08-24 23:11:52 +00:00
Sign in to join this conversation.