Skip to content

fix(migrations): baseline adoption for never-migrated DBs + strict Ansible invocation (#10001, #10026) - #10039

Merged
mrveiss merged 12 commits into
Dev_new_guifrom
issue-10001
Jun 12, 2026
Merged

mrveiss merged 12 commits into
Dev_new_guifrom
issue-10001

Conversation

@mrveiss

@mrveiss mrveiss commented Jun 12, 2026

Copy link
Copy Markdown
Owner

Fixes #10001. Closes the blocking part of #10026 (case 3 + legacy upgrade-path gates); contributes the CI gate requested in #10002.

Thinking Path

The ordering is the mission: both Ansible migration tasks invoked alembic without -c migrations/alembic.ini (guaranteed "No config file" failure) and swallowed it with failed_when: false — so native Postgres deployments never migrated and their schemas (built by historical create_all) carry no alembic_version stamp. 32/37 table-creating migrations use unguarded op.create_table, so making the invocation strict first would strand every such database at migration 001. Therefore: reproduce the stranding (A) → baseline adoption logic (B) → close the create_all faucet (C) → only then strict invocation (D) → permanent gates (E). One commit per phase, every phase proven red-first (details in the report).

What Changed

  • python -m migrations.baseline (new): classifies the DB into 4 states (empty / stamped / schema-without-stamp / foreign stamp). Adoption brackets the schema with a self-maintaining probe ladder (per-revision artifacts AST-extracted from the migration files; TIMESTAMPTZ markers for type-only revisions) and stamps the bracketed revision; ambiguous schemas are refused loudly (exit 3/4, nothing written) with a pointer to the new recovery runbook. Never guesses a stamp.
  • Faucet closed: migrations/schema_bootstrap.py guard — runtime metadata.create_all allowed only for app-owned SQLite data files unless AUTOBOT_DB_CREATE_ALL=true; static schema-authority sweep keeps the pattern from returning.
  • Both playbooks strict (setup-user-backend.yml, update-all-nodes.yml): gate on Postgres-backed mode → pg_dumpall backup → baseline adoption → alembic -c migrations/alembic.ini upgrade head with failed_when removed; update-all-nodes Play 2 gets any_errors_fatal: true and migrations moved after the .env regen / before the restart.
  • Permanent gates: migration-gate.yml CI workflow (postgres:16 service) running the (a) empty→head, (b) stamped-intermediate→head, (c) unstamped→baseline→head, (d) head→downgrade-1→head matrix + all static checks; docs/operations/migration-recovery.md operator runbook.

Verification

  • 27/27 tests green against disposable Postgres 16 (12 fleet states incl. probe-ladder bracketing of a deliberately old 20260315_010 schema, refusal cases, downgrade round-trip).
  • Red-first evidence per phase recorded in MIGRATION_BASELINE_REPORT.md (Phase A committed failing; C guard 4-red; D contract 5-red against old playbooks; sweep red-proven via scratch offender).
  • ansible-playbook --syntax-check passes for both playbooks.
  • Full suite re-run green after rebase onto latest Dev_new_gui.

Report: MIGRATION_BASELINE_REPORT.md (state machine, ladder contents, fleet states, deliberate non-goals incl. #9980 confirmation and the remote-Postgres backup gap).

Model Used

Claude Fable 5 (claude-fable-5)

🤖 Generated with Claude Code

mrveiss and others added 7 commits June 12, 2026 12:14
…est for baseline adoption (#10001)

Phase A of #10001/#10026: native deploys built schemas via create_all while
the Ansible alembic task silently failed (no -c flag + failed_when: false),
leaving schema-without-stamp databases. 32/37 table-creating migrations use
unguarded op.create_table, so a strict 'upgrade head' aborts at 001 with
DuplicateTable.

- test_raw_upgrade_on_unstamped_schema_fails: pins the stranding mechanism
  (green; permanent documentation of why baseline adoption must exist)
- test_bootstrap_adopts_unstamped_schema_and_reaches_head: acceptance test
  for the Phase B baseline entrypoint — RED until #10001 lands
- disposable-Postgres fixtures (AUTOBOT_MIGRATION_TEST_ADMIN_URL) + new
  migration_gate pytest marker

Verified red-first: 1 failed (No module named migrations.baseline), 1 passed.

Discovery confirmed while building fixtures: LLCBase.metadata.create_all
fails on Postgres at head (#9980 enum NAMES vs value defaults), so stranded
fleet schemas are UM-Base-shaped only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
)

Phase B: 'python -m migrations.baseline' classifies the database before
'alembic upgrade head' and adopts schema-without-stamp deployments:

1. EMPTY -> no-op; upgrade runs the full chain
2. STAMPED (known revision) -> no-op
3. SCHEMA_NO_STAMP -> adoption:
   a. autogenerate diff vs head models empty -> stamp head
   b. else probe-ladder bracketing: per-revision artifacts (created tables,
      added columns, TIMESTAMPTZ markers) AST-extracted from the migration
      files themselves (self-maintaining); minimal-candidate selection so
      unobservable revisions re-run rather than being skipped
   c. ambiguous bracketing -> exit 3, REFUSE with recovery-doc pointer;
      never guess a stamp
4. STAMPED unknown revision -> compatibility table (empty today: #9759
   changed zero IDs, #8225 kept its ID) else exit 4, refuse

env.py URL resolution extracted to migrations/db_url.py (shared, unchanged
behavior). Exit codes: 0 proceed, 2 error, 3 ambiguous, 4 foreign stamp.

Tests (16, all green; Phase A acceptance test flipped red->green):
- every state-machine branch incl. probe-ladder bracketing of a 010-era
  schema, dry-run, mixed-schema refusal, interleaved create_all refusal,
  foreign-stamp refusal
- ladder self-checks: full chain coverage, literal create_table enforcement,
  observability allowlist (043/045/052), anchor spot-checks

Bugs caught red-first by the suite: helper-function create_table calls
(migration 010) invisible to upgrade()-only extraction -> bracketed one rung
low; cross-metadata FK resolution broke autogenerate fast path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… only schema authority (#10001)

Phase C: runtime metadata.create_all can no longer build schema on the
alembic-managed database:

- migrations/schema_bootstrap.py: ensure_create_all_allowed(dialect) —
  SQLite (app-owned local data files) stays allowed; anything else refuses
  with a pointer to migrations.baseline + the recovery doc unless
  AUTOBOT_DB_CREATE_ALL=true is set explicitly (dev/compose profiles only)
- lifespan._init_skills_tables (the one remaining runtime create_all, skills
  SQLite engine) now calls the guard before create_all
- static schema-authority sweep: AST scan of all backend runtime code fails
  on any metadata.create_all outside the conscious allowlist; proven by
  scratch-offender injection (trips on offender, green on clean tree)
- guard unit tests for all three branches (sqlite / blocked / opt-in) +
  AST check that lifespan actually calls the guard

Ground truth recorded: the historical Postgres faucet was already removed
from runtime code — fleet DBs were stranded by past create_all paths; this
phase prevents recurrence. Compose smoke unaffected (single_user = SQLite).

Verified red-first: 4 failed before the guard existed, 5 passed after;
sweep red-proven via scratch offender.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… + no swallowed failures (#10001, #10026)

Phase D, safe only now that baseline adoption (#10001 Phase B) exists.

Both playbooks (setup-user-backend.yml, update-all-nodes.yml):
- gate the sequence on Postgres-backed AUTOBOT_USER_MODE (single_user has
  no Postgres schema; previously alembic ran unconditionally and failed)
- pg_dumpall backup before migrating (backup-node-data.yml pattern); loud
  warning when Postgres is remote and no local backup is possible
- 'python -m migrations.baseline' adoption step BEFORE upgrade head
- alembic invoked WITH '-c migrations/alembic.ini' (the old invocation
  could never find its config) and .env sourced so env.py can resolve the
  database URL
- failed_when: false REMOVED — migration failure aborts the deploy with
  the captured alembic output

update-all-nodes.yml additionally:
- migration block moved after the .env regen (fresh credentials) and
  before the service restart (post-#9989 ORM needs columns at startup)
- Play 2 sets any_errors_fatal: true — a failed node stops the rollout
  instead of continuing to the next node with a diverged fleet

Contract pinned by tests/migrations/test_ansible_migration_contract.py
(5 tests, proven red against the old playbooks, green after).
ansible-playbook --syntax-check passes for both.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nbook (#10026, #10002)

Phase E — permanent gates:

- .github/workflows/migration-gate.yml: postgres:16 service, runs the full
  matrix on every migrations/models/playbook change:
  (a) empty -> head, (b) stamped-intermediate -> head,
  (c) schema-without-stamp -> baseline adoption -> head,
  (d) head -> downgrade -1 -> head (new test, green on first run — the
  current chain's downgrade is healthy; this is a regression gate)
  plus baseline state-machine refusals, probe-ladder self-checks, the
  schema-authority sweep and the Ansible invocation contract.
  Minimal dependency install (exactly what env.py imports) keeps the gate
  fast and prevents silent dependency growth in migrations.

- docs/operations/migration-recovery.md: how to read your revision, how
  adoption decides (state table + probe ladder), the manual re-stamp
  procedure for the refused/ambiguous case, foreign-stamp handling.

None of the five #9759 failure classes was CI-detectable before this gate.
Full local run: 27 passed against disposable Postgres 16.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ion (#10001, #10026)

State machine diagram, probe-ladder contents, 12 fleet states tested,
red-first evidence per phase, ambiguity go/no-go assessment, and the
explicit ordering statement: #10026 strictness landed only after #10001
adoption logic.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jun 12, 2026 •

Copy link
Copy Markdown
Contributor

✅ SSOT Configuration Compliance: Passing

🎉 No hardcoded values detected that have SSOT config equivalents!

github-actions Bot and others added 2 commits June 12, 2026 09:52
Applied Black, isort, and autoflake to match code-quality checks.
Triggered by workflow auto-fix-formatting.yml.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
…dowing (#10001)

Self-review findings on PR #10039:

- requirements-ci had no alembic, so ci.yml's repo-wide pytest (runs on
  push to Dev_new_gui) would fail the probe-ladder self-checks post-merge.
  Added alembic==1.18.4 to requirements-ci/storage.txt AND
  pytest.importorskip('alembic') in the self-check module so any
  environment without alembic skips instead of failing.
- registered ansible var 'backend_user_mode' shadowed the identically
  named template variable consumed by backend.env.j2 (render currently
  happens before the register, but the landmine is real) — renamed the
  register to 'backend_user_mode_probe' in both playbooks.

27/27 tests green; both playbooks pass ansible-playbook --syntax-check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@mrveiss

mrveiss commented Jun 12, 2026

Copy link
Copy Markdown
Owner Author

Self-review pass (requested follow-up audit) produced one hardening commit and three filed issues:

Fixed on the branch (commit 22b56c955):

  • requirements-ci had no alembic → ci.yml's repo-wide pytest on push to Dev_new_gui would have failed the probe-ladder self-checks post-merge. Added alembic==1.18.4 to requirements-ci/storage.txt + pytest.importorskip guard.
  • Registered ansible var backend_user_mode shadowed the template variable consumed by backend.env.j2 → renamed to backend_user_mode_probe in both playbooks.

Filed as follow-ups (none blocking this PR):

27/27 tests re-verified green after the hardening commit; both playbooks pass ansible-playbook --syntax-check.

@mrveiss

mrveiss commented Jun 12, 2026

Copy link
Copy Markdown
Owner Author

Review (high effort — 7 finder angles, verified)

Verdict: no blocking findings — approving for merge. Zero confirmed correctness bugs; CI fully green (smoke-test, code-quality, migration-matrix all pass; hardened-smoke-test is the pre-existing #9663 chromadb flake).

Findings (all non-blocking, severity-ranked)

[
  {"file": "autobot-slm-backend/ansible/playbooks/update-all-nodes.yml", "line": 593, "summary": "Mode gate silently skips migrations when .env lacks AUTOBOT_USER_MODE", "failure_scenario": "PLAUSIBLE edge: node misconfigured so .env says single_user while operator intended Postgres → migrations skip. Consistent-by-design: the gate reads the exact file systemd feeds the service (#2824), so a skipped migration implies the runtime is also single_user. Residual risk documented; #10045 will make the backup/migration path fail-closed."},
  {"file": "autobot-slm-backend/ansible/playbooks/update-all-nodes.yml", "line": 428, "summary": "any_errors_fatal: true covers all of Play 2, not just the migration block", "failure_scenario": "A transient non-migration failure (e.g. health check) now aborts the whole rollout. Conscious trade-off per the #10026 mission ('failure on one node must not continue'); documented in the commit. Narrowing to a dedicated migrate play would conflict with the required ordering (after .env regen, before restart)."},
  {"file": "autobot-backend/migrations/schema_bootstrap.py", "line": 27, "summary": "create_all_opted_in() hand-parses an env bool; autobot_shared.env_registry.env() exists", "failure_scenario": "Reuse: registering AUTOBOT_DB_CREATE_ALL in the env registry would consolidate semantics — but couples the dependency-light guard to autobot_shared. Noted on #9841 (env-helper consolidation) rather than blocking."},
  {"file": "autobot-backend/tests/migrations/conftest.py", "line": 76, "summary": "run_alembic/run_baseline duplicate subprocess env setup; script_head() duplicates baseline._script_directory() config", "failure_scenario": "Test-internal duplication (~15 lines). Ride-along candidate for the #10044 PR which touches the same tooling."},
  {"file": "autobot-backend/migrations/db_url.py", "line": 36, "summary": "as_async_url() has 3 string-replace siblings in autobot-slm-backend/migrations", "failure_scenario": "Cross-service divergence risk on URL driver coercion; SLM has its own migration system by design. Consolidation note only."},
  {"file": "autobot-backend/migrations/baseline.py", "line": 289, "summary": "stamp_revision() writes alembic_version via raw SQL instead of alembic API", "failure_scenario": "PLAUSIBLE maintenance cost if alembic ever changes its version-table schema (stable for a decade). In-process alembic stamp would re-enter env.py's async engine path — raw SQL is the deliberate, simpler choice; covered by tests asserting stamp == alembic's own table shape."}
]

Notable refutations (claims checked against code)

  • "baseline exit 3/4 isn't caught" — refuted: ansible shell fails on rc≠0 by default; the block aborts and any_errors_fatal stops the rollout.
  • "observability allowlist lets new type-only migrations skip silently" — refuted: test_observability_coverage fails on any new unobservable revision, forcing a marker or a conscious allowlist entry.
  • "mode gate should use the inventory var instead of grepping .env" — refuted: the .env deployed to the code dir is what systemd feeds the service (fix: backend role should deploy .env from template during provisioning #2824); grepping it reads the runtime's authoritative truth.
  • "fresh installs never migrate (role doesn't write .env)" — refuted: roles/backend/tasks/main.yml:619 templates .env into the code dir before post_tasks run.

@mrveiss
mrveiss merged commit dd9900f into Dev_new_gui Jun 12, 2026
23 checks passed
@mrveiss
mrveiss deleted the issue-10001 branch June 12, 2026 12:26
mrveiss added a commit that referenced this pull request Jun 21, 2026
…s + tz round-trip (#10026) (#10368)

Closes the last untested fleet state of the repaired Alembic chain (umbrella #9920).
Cases 1 (stamped-intermediate) and 3 (unstamped-populated → baseline adoption) landed
via #10039; case 2 — the 018 naive→TIMESTAMPTZ conversion on a legacy DB stamped <018 —
was assumed, not tested.

Diagnosed the real chain shape: process_runs/agent_sessions are migration-created and
naive at rev 017 (so the 017→018 path IS case 2 for them); skill_* are create_all-only
(absent) → seeded naive to model a legacy fleet DB. Then:
- upgrade 018 converts all 8 of 018's (table,column) pairs to timestamptz;
- a tz-aware datetime round-trips on a converted column with its offset intact.

2 Postgres tests (migration-gate).

Co-authored-by: Claude Opus 4.8 <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