BLOXOS_FUTURE.md
Rama main · réplica de lectura del repositorio original.
# BloxOS Future — Hub Architecture Refactor
**Status:** Draft for approval
**Date:** 2026-05-02
**Author:** brainstorm session (bokiko + Claude)
**Branch (proposed):** Each PR on its own `refactor/<name>` branch off `main`
**Scope budget:** ~1 working week, 12 PRs
---
## Goal
Convert the BloxOS hub from a 14-file `package main` codebase with ~12 mutable globals into 11 properly-bounded internal packages with dependency injection, repository-pattern storage, and structured logging. Zero behavior change for end users. Sets the foundation for product-depth (A) and automation (C) work to land cleanly.
## Non-Goals
- **No new features.** Anything that adds behavior is out of scope; if a PR is tempted, it's the wrong PR.
- **No frontend work.** The 7-context consolidation gets its own design later. This refactor is hub-only.
- **No agent code reorganization.** Agent's `package main` stays — its build-tag split (Linux/Windows) is the only structural cut it currently needs.
- **No DB schema changes.** Migrations stay where they are (logically); only the runner relocates.
- **No protocol changes.** Agent WS frames, install token shape, install scripts — all unchanged.
- **No HA/multi-instance work.** That's a future product call; this PR makes it *possible* but doesn't deliver it.
---
## Final package layout (11 packages + cmd)
```
hub/
cmd/hub/main.go # ~50 LOC entrypoint: load config, build server, run
internal/
config/ # typed Config struct, env loader, slog setup
config.go
store/ # SQLite + repos + migrations
store.go # Store wraps *sql.DB, exposes repo accessors
machines.go # MachineRepo
users.go # UserRepo
alerts.go # AlertRepo + AlertHistoryRepo (room for A's history feature)
branding.go # BrandingRepo
preferences.go # PreferencesRepo (incl. pinned, saved filters)
inventory.go # raw row reads (aggregation lives in inventory/)
versions.go # per-machine version tracking
terminal.go # terminal session audit metadata
migrations.go # migration runner + embedded SQL
sse/ # Broadcaster (no domain knowledge)
broadcaster.go
auth/ # JWT, login, RBAC, middleware, route audit
jwt.go
login.go
rbac.go
middleware.go
audit.go # boot-time route-coverage check
metrics/ # ingest + history queries
ingest.go
history.go
branding/ # branding service (logo/favicon/title/welcome)
service.go
inventory/ # aggregator (computes /api/inventory response)
aggregator.go
apipoll/ # external machine pollers (Synology, Proxmox, ...)
registry.go
poller.go
tls.go
fleet/ # agent registry + connection + enrolment + versions + agent WS
manager.go # agents map + mutex + lookup
connection.go # ConnectedAgent
enrol.go # install tokens, durable secrets, first-run bootstrap
versions.go # SHA tracking, circuit breaker, announce-on-reconnect
ws.go # handleAgentWS
terminal/ # PTY sessions
session.go
ws.go # browser-side WS upgrade + relay
alerts/ # rule engine + Telegram notifier
engine.go
telegram.go
server/ # Server struct + route wiring + HTTP handlers as methods
server.go # New(*Config, *Store, ...) (*Server, error); Run()
routes.go # all route registration in one place
machines.go # handler methods
users.go
auth.go # handler shims around internal/auth
alerts.go
branding.go
preferences.go
inventory.go
versions.go
refresh.go
terminal.go
apimachines.go
httperr.go # one error-shape helper
```
**Naming convention:** every internal package exports a single primary type matching the package noun (`store.Store`, `auth.Service`, `fleet.Manager`, `sse.Broadcaster`, `alerts.Engine`, `metrics.Service`, `apipoll.Registry`, `terminal.Manager`, `branding.Service`, `inventory.Aggregator`). Repos under `store` are `MachineRepo`, `UserRepo`, etc. — accessed via `s.Store.Machines()`, `s.Store.Users()`.
---
## PR sequence (12 PRs)
Each PR has: **Goal** (one line), **Mechanics** (concrete moves), **Verification** (the test bar to clear), **Out of scope** (drift guard).
### PR 0 — Introduce `Server` struct in `package main`
**Branch:** `refactor/00-server-struct`
**LOC touched:** ~3000 (every handler + every test)
**Risk:** Highest. Pure mechanical transformation, no extraction.
> ⚠️ **PARTIALLY SUPERSEDED by #148 — do not start this from scratch, and do
> not treat it as finished either.**
>
> #148 landed a deliberately **scoped subset** of this PR, sized to what
> issue #60 actually required rather than the full transformation below:
>
> - `Server` struct exists in `package main` (`hub/server.go`)
> - **only** `db`, `agents`, and `agentsMu` moved onto it
> - most handlers became methods on `*Server`
> - `goTracked` / `Shutdown` added, so a server waits for work it spawned
>
> **Still outstanding:** most package-level mutable state is untouched —
> `sseClients`, `pendingCmds`, `termSessions`, `jwtSecret`, `rateLimiter`,
> `setupTokenValue`, `machineLatency`, `apiPollers`, the telegram config, and
> the rollout/version caches all remain package-level. `newServer` and
> `registerRoutes` exist in narrower forms than described below, and no `Run`
> method exists.
>
> The remaining scope should be reassessed against the current code before any
> of it is implemented — the description below predates #148 and no longer
> describes the starting point.
**Goal:** Move every package-level mutable global into `*Server` fields. Convert every handler to a method on `*Server`. `main()` shrinks to: build config, build server, register routes, listen.
**Mechanics:**
- Define `type Server struct { db *sql.DB; agents map[string]*ConnectedAgent; agentsMu sync.RWMutex; sseClients ...; pendingCmds ...; termSessions ...; telegramToken string; telegramChatID string; machineLatency ...; jwtSecret []byte; rateLimiter *RateLimiter; apiPollers ...; setupTokenValue string; agentVersionInfo ...; ... }`
- `func newServer() (*Server, error)` does what `main()` currently does up to route registration
- `func (s *Server) registerRoutes(e *echo.Echo)` does route wiring
- `func (s *Server) Run() error` starts listening
- Every handler `handleX(c echo.Context) error` becomes `func (s *Server) handleX(c echo.Context) error`
- Every test in `main_test.go` updated to use `s := newTestServer(t)` (helper added) instead of touching globals
- All package-level `var` declarations of mutable state deleted
- Read-only vars (regexes, valid-value maps) **stay** package-level — they're constants, not state
**Verification:**
- `go build ./...` clean
- `go test -count=1 ./...` green (same test count as before; no tests added or removed)
- `go vet ./...` clean
- Manual smoke: hub starts, agent reconnects, dashboard loads, terminal opens, metrics flow
**Out of scope:** No package extraction. No file moves. No new files except `server.go` extracted from current `main.go`. No behavior change.
---
### PR 1 — `internal/config`
**Branch:** `refactor/01-config`
**LOC touched:** ~200
**Risk:** Low
**Goal:** All config (env vars, file paths, ports, CA path, JWT secret path, DB path, Telegram creds) flows through one typed struct loaded once.
**Mechanics:**
- `internal/config/config.go`: `type Config struct { ... }; func Load() (*Config, error)`
- `Server` gets `cfg *config.Config` field, populated in `newServer`
- Replace every `os.Getenv("BLOXOS_*")` site in hub code with `s.cfg.X`
- `slog` setup lives here: `cfg.Logger *slog.Logger`, JSON handler in production / text in dev
- `Server` gets `log *slog.Logger` field; replace top-priority `log.Printf` calls (errors, lifecycle) with structured logging. Remaining `log.Printf` calls migrate opportunistically in later PRs.
**Verification:**
- All existing env-var contracts preserved (verified by reading current code, listing every `os.Getenv`, mapping to `Config` field, listing in PR description)
- Tests pass with no env changes required
- New unit tests for `Load()`: defaults, override, missing-required-fail
**Out of scope:** No CLI flags (env-var-only stays the contract). No config file (.env loading stays a deployment concern, not a code concern).
---
### PR 2 — `internal/store`
**Branch:** `refactor/02-store`
**LOC touched:** ~1500
**Risk:** Medium (every SQL query relocates)
**Goal:** All `db.Query` / `db.Exec` calls move behind repo interfaces. `Server` no longer holds `*sql.DB` directly — only `*store.Store`.
**Mechanics:**
- `store.Store` wraps `*sql.DB`, exposes `Machines() MachineRepo`, `Users() UserRepo`, etc.
- Each repo is an interface + struct impl: `type MachineRepo interface { Get(ctx, id) (*Machine, error); List(ctx, filter) ([]*Machine, error); ... }; type machineRepo struct { db *sql.DB }`
- Migrations runner: `store.Migrate(ctx, db) error`. Migration SQL embedded via `//go:embed migrations/*.sql` (a small refactor of current in-memory slice — keeps current structure, just relocated).
- Every handler that today calls `s.db.Query(...)` becomes `s.store.Machines().List(ctx, ...)` etc.
- Test helper `newTestServer` builds `Store` with `:memory:` DB; **`SetMaxOpenConns(1)` workaround stays for now** (real fix comes when tests stop sharing one DB instance — that's PR 12)
- Per **migration strategy (x)**: storage-layer tests move into `internal/store/*_test.go` as unit tests using the repo interfaces. The integration tests in `main_test.go` that exercise storage indirectly via HTTP keep passing unchanged.
**Verification:**
- All SQL accounted for: PR description includes a checklist mapping every removed `db.X` call site to its new repo location
- Repo interfaces have unit tests covering happy path + at least one error path each
- Integration tests still green
**Out of scope:** No new tables. No schema changes. No new query patterns. Repo method signatures mirror current call patterns 1:1 (refactoring those is a later concern).
---
### PR 3 — `internal/sse`
**Branch:** `refactor/03-sse`
**LOC touched:** ~150
**Risk:** Low
**Goal:** SSE broadcasting becomes a small package consumed by anything that pushes events.
**Mechanics:**
- `sse.Broadcaster` owns `map[chan []byte]struct{}` + mutex + `Subscribe`/`Unsubscribe`/`Broadcast`
- `Server.sseClients` field replaced with `Server.sse *sse.Broadcaster`
- Handler that mints SSE clients (`/api/events`) gets `s.sse.Subscribe(...)` / defer `Unsubscribe`
- Every site that today writes to `sseClients` gets `s.sse.Broadcast(payload)`
- New unit tests: subscribe/broadcast/unsubscribe, slow-consumer dropping (verify current behavior, document it)
**Out of scope:** No event-shape changes. No routing/filtering of events (every subscriber still gets every event — current behavior preserved).
---
### PR 4 — `internal/auth`
**Branch:** `refactor/04-auth`
**LOC touched:** ~900
**Risk:** Medium (JWT + RBAC are security-critical)
**Goal:** All authentication + authorization logic in one package, exposing a small surface to `server`.
**Mechanics:**
- `auth.Service` owns: JWT secret, login flow (incl. credential rotation gate), password/PIN change, SSE token mint, first-boot setup token validation
- `auth.Middleware(scope string) echo.MiddlewareFunc` replaces `permissionMiddleware`
- `auth.RouteAudit(routes []RouteEntry) error` replaces `auditRBACRouteCoverage` — runs at boot, fails server start if mismatched
- RBAC scope map (`routeScopeRequirements`) stays in `auth.go` next to `Middleware`
- Tests for JWT round-trip, scope enforcement, route-audit failure modes move into `internal/auth/*_test.go`
**Verification:**
- Boot-time audit still catches a deliberately-misregistered route (test added)
- Token refresh / rotation flow unchanged for existing clients (manual smoke from dashboard)
- Existing auth integration tests in `main_test.go` still green
**Out of scope:** No new auth methods. No 2FA. No session DB. No per-user audit log changes (that's an A item).
---
### PR 5 — `internal/metrics`
**Branch:** `refactor/05-metrics`
**LOC touched:** ~400
**Risk:** Low
**Goal:** Metrics ingest from agent WS frames + history queries for the detail-page charts live in one place.
**Mechanics:**
- `metrics.Service` exposes `Ingest(ctx, machineID string, m Metrics) error` and `History(ctx, machineID string, since time.Time) ([]Point, error)`
- `Ingest` writes via `s.store.Metrics()` and broadcasts via `s.sse.Broadcast(...)`
- The `nullableFloat` helper for `cpu_temp_c` (and any future nullable metric) lives here
- Latency tracking (current `machineLatency` global) moves here too — it's a per-machine rolling metric
- Tests: ingest happy path, nullable handling, latency aggregation
**Out of scope:** No retention. No rollups. No new metric types. (All A territory.)
---
### PR 6 — `internal/branding`
**Branch:** `refactor/06-branding`
**LOC touched:** ~260
**Risk:** Low
**Goal:** Branding domain logic (PNG validation, SHA cache-busting, single-row config CRUD) in its own package.
**Mechanics:**
- `branding.Service` exposes `Get(ctx) (*Config, error)`, `Update(ctx, patch) error`, `SetLogo(ctx, png []byte) error`, etc.
- PNG magic-bytes + decode validation logic lives here
- SHA computation for cache-busting stays here
- `Server` handlers become thin shims over the service
**Out of scope:** No new branding fields. Welcome message stays at 500 chars.
---
### PR 7 — `internal/inventory`
**Branch:** `refactor/07-inventory`
**LOC touched:** ~430
**Risk:** Low
**Goal:** Inventory aggregator (the logic that produces `/api/inventory` from per-machine `hardware_info` JSON) lives separately from raw storage.
**Mechanics:**
- `inventory.Aggregator` exposes `Build(ctx) (*InventoryResponse, error)` — reads via `s.store.Inventory()`, computes totals + groupings + deduped lists
- All the helpers (`appendUnique`, `dedupedGPUModels`, `inventoryMaxInt`) move here
- Pre-compiled regex package-level vars stay (they're constants)
**Out of scope:** No new inventory dimensions. No caching of the aggregated response.
---
### PR 8 — `internal/apipoll`
**Branch:** `refactor/08-apipoll`
**LOC touched:** ~500
**Risk:** Low
**Goal:** Polled-machine registry + per-machine pollers + TLS-mode parsing in one package, ready for B-style ecosystem expansion later.
**Mechanics:**
- `apipoll.Registry` owns `map[string]*Poller` + mutex, exposes `Add`, `Update`, `Remove`, `Restart`
- `apipoll.Poller` interface — current Synology + Proxmox impls become first concrete types behind it
- TLS config parsing (`parseAPITLSConfig`, `validateAPITLSConfig`) lives here
- `Server` triggers `s.apiPollers.Restart(machineID)` after a `PATCH /api/api-machines/:id` instead of the current in-process callback dance
**Verification:**
- Existing pollers behaviorally unchanged (Proxmox custom_ca, Synology system trust)
- Add+Restart cycle exercised by test using a stub Poller
**Out of scope:** No new poller types (TrueNAS, OPNsense, Unifi etc. — that's B work).
---
### PR 9 — `internal/fleet`
**Branch:** `refactor/09-fleet`
**LOC touched:** ~1400
**Risk:** **High** — touches WebSocket protocol, durable enrolment, version handshake, refresh commands
**Goal:** Agent-side everything. The single biggest extraction.
**Mechanics:**
- `fleet.Manager` owns `agents` map + mutex + `pendingCmds` map + mutex
- `fleet.ConnectedAgent` is the per-agent struct
- `fleet.Enroller` handles install token mint, durable secret rotation, first-run bootstrap
- `fleet.VersionTracker` owns SHA cache + per-OS announce + circuit breaker + reconnect monitor goroutine
- `fleet.HandleAgentWS` is the WebSocket upgrade entry point — depends on `auth.Service` (token validation), `metrics.Service` (ingest), `sse.Broadcaster` (UI events), `store.Versions()` (record running SHA)
- The `registerAgentConnection` helper extracted in Phase 8 stays — moves into this package
- All the Phase 9 per-OS routing logic (`announcedSHAFor`, `lookupAgentOS`, etc.) moves here
**Verification:**
- Live agents must reconnect after restart with zero log noise
- Phase 8 announce-on-reconnect test (`TestAgentVersionAnnouncedOnReconnect`) passes from new location
- Phase 9 per-OS SHA tests pass from new location
- Full hub test suite green
**Out of scope:** No protocol changes. No new agent commands. No new enrolment paths.
---
### PR 10 — `internal/terminal`
**Branch:** `refactor/10-terminal`
**LOC touched:** ~470
**Risk:** Medium (WS upgrade + PTY relay)
**Goal:** PTY session lifecycle, PIN-gated start, audit metadata in one package.
**Mechanics:**
- `terminal.Manager` owns `termSessions` map + mutex + `Start`, `Stop`, `Get`
- `terminal.Session` is the per-session struct
- Browser-side WS upgrade + agent-side WS relay live here
- Allowed-origins helper moves here
- Depends on `auth.Service` (PIN re-auth) and `fleet.Manager` (agent connection lookup)
**Verification:**
- Cmd+K → Open Terminal still works on a live agent
- Session limits + cleanup unchanged
**Out of scope:** No new terminal features. Windows terminal still gated off via `platformSupportsTerminal()` in agent code (unchanged).
---
### PR 11 — `internal/alerts`
**Branch:** `refactor/11-alerts`
**LOC touched:** ~425
**Risk:** Low
**Goal:** Rule engine + Telegram notifier with explicit room for the alert-history work that A will land.
**Mechanics:**
- `alerts.Engine` owns the evaluation loop, depends on `metrics.Service` (read latest values), `store.Alerts()` (rules + future history), `sse.Broadcaster` (push to UI), `alerts.Notifier` interface
- `alerts.Telegram` is first concrete `Notifier`
- Alert REST endpoints become handler methods in `server/alerts.go` calling into `alerts.Engine`
**Out of scope:** No alert history (A). No acknowledgment / silencing (A). No new notifier types (A).
---
### PR 12 — Final landing in `internal/server`
**Branch:** `refactor/12-server-finalization`
**LOC touched:** ~300
**Risk:** Low
**Goal:** Move everything still in `package main` into `internal/server`. `cmd/hub/main.go` becomes a ~50-line entrypoint. Drop the `SetMaxOpenConns(1)` test workaround now that the test architecture supports per-test fresh stores.
**Mechanics:**
- `Server` struct relocates to `internal/server/server.go`
- `routes.go` consolidates all route registration in one auditable place (used by `auth.RouteAudit`)
- All remaining handler methods relocate as `internal/server/<domain>.go`
- `httperr.go` introduced: single helper for JSON error responses, mapping `store.ErrNotFound` → 404, validation errors → 400, scope failures → 403
- `cmd/hub/main.go`: load config, build store, run migrations, build server with all dependencies, listen
- `main_test.go` is deleted; all tests now live in `internal/<package>/*_test.go`
- Test helper `newTestServer(t)` lives in `internal/server/testing.go` and produces a fresh `Server` per test (each with its own `:memory:` DB, no shared state). `SetMaxOpenConns(1)` workaround removed.
- Add `hub/hub` (compiled binary) to `.gitignore`; remove from index in this PR
**Verification:**
- `go test -count=1 -race ./...` green (race detector is the real test that globals are gone)
- Per-test parallelism enabled (`t.Parallel()` works in at least one previously-flaky test)
- `cmd/hub/main.go` < 100 LOC
- `find internal -name "*.go" | xargs grep -l "^var [a-z]" | xargs grep -L "= regexp\." | xargs grep -l "= make("` returns zero results (no mutable global maps remaining)
**Out of scope:** No feature work. No new test cases beyond what verifies the structural changes.
---
## Test migration strategy: (x) — migrate as we go
- Each extraction PR moves the tests for that domain into the new package as unit tests using the new abstractions (real or mock, whichever is cheaper)
- Integration coverage in `main_test.go` shrinks PR-by-PR as the surface it tests gets re-tested at the unit level
- `main_test.go` is fully gone by PR 12
- After PR 12: `go test -race ./...` enables real per-test parallelism (today blocked by globals + `SetMaxOpenConns(1)`)
## Logging migration
- PR 1 introduces `*slog.Logger` on `Server`
- High-priority `log.Printf` calls (errors, lifecycle events, security events) get migrated **opportunistically** in the PR that touches them
- Low-priority `log.Printf` debug lines stay until explicitly cleaned up — this isn't a logging refactor, it's an architecture refactor
- Final cleanup of remaining `log.Printf` happens in a follow-up PR after PR 12, not bundled in
## Side issues addressed
- **`hub/hub` binary in git** — addressed in PR 12 (`.gitignore` + `git rm --cached`). Adding it to PR 0 would muddy that PR's "pure refactor" character.
- **Frontend context consolidation (7 contexts)** — explicitly deferred. Gets its own design after this refactor lands.
- **Agent code reorganization** — explicitly deferred.
## Risks and mitigations
| Risk | Mitigation |
|---|---|
| PR 0 introduces a hidden bug because every handler signature changes at once | Manual smoke checklist in PR 0 description: hub boots, dashboard loads, agent reconnects, terminal opens, metrics stream, alert fires, Telegram delivers. CI must pass with the new test setup before merge. |
| PR 9 (fleet) breaks WS protocol invisibly | Stage on a non-prod hub instance with FAT-LOLO + ai-04 first; verify auto-update + version announce + refresh + terminal handoff before merging. |
| Stack of PRs gets stale because user picks up a feature mid-refactor | Each PR is independently mergeable. If A or C work has to land mid-stack, do it in the new structure where extractions are complete and old structure where they aren't. The goal is forward progress, not purity. |
| Hidden global I missed in PR 0 reappears as a nil-pointer at 2am | After PR 12, `go test -race ./...` is the proof. Until then, manual `grep "^var " hub/*.go` after each PR to confirm no new mutable package-level state was introduced. |
| Repository interfaces calcify too early — first feature in A discovers we picked the wrong abstraction | Repo interfaces in PR 2 mirror current call patterns 1:1, no speculative methods. First A feature is allowed to add methods or change shapes — accepted cost of discovering the right abstraction by use, not by guess. |
## Success criteria
- ✓ `cmd/hub/main.go` < 100 LOC
- ✓ Zero mutable package-level vars in `internal/`
- ✓ `go test -race ./...` green
- ✓ At least one previously-serial test runs under `t.Parallel()`
- ✓ `SetMaxOpenConns(1)` workaround removed
- ✓ End users notice nothing
- ✓ `hub/hub` binary out of git
- ✓ Foundation in place: A's alert-history can land as a new repo + service without touching globals; C's scheduler can be a new package consuming `fleet.Manager` + `store` without restructuring anything
## Open questions
- **UNCONFIRMED:** Should structured logging go full-bore in PR 1 (replace every `log.Printf`) or stay opportunistic? Current spec says opportunistic. Could be flipped if you want a clean `slog`-only codebase by PR 12.
- **UNCONFIRMED:** Should we add a `context.Context` propagation pass as part of this refactor? Current code uses `context.Background()` in many places. Spec assumes no — that's a separate concern. Could be folded in if you want.
- **UNCONFIRMED:** Branch naming and PR title prefix — proposing `refactor/NN-name` and conventional-commits `refactor: extract internal/X package` per the project's commit conventions. OK?
---
## Roadmap context (what comes after this refactor)
Per the 360 brainstorm session, the agreed product priority is **F → A → C**.
**F = this document.** Architecture refactor. Foundation work.
**A = Product depth (next).** Build on the new architecture:
- Alert history + acknowledgment + silencing
- Metrics retention + rollups (1m → 1h → 1d aggregation)
- Log aggregation across the fleet (tail journalctl from one place)
- GPU process visibility (nvidia-smi pmon)
- Power/cost view (watt-hours, $/kWh)
**C = Power-user automation (after A).** Programmability layer:
- Scheduled actions ("restart ollama nightly at 4am")
- Fleet-wide command runner with output collection
- Webhook triggers for external integrations
- Recipes (composable action templates)
Items explicitly deferred or paused:
- **B = Ecosystem breadth** (TrueNAS, OPNsense, Unifi, Home Assistant, smart-PDU/UPS pollers) — `apipoll.Poller` interface from PR 8 makes this easy when wanted
- **D = Reliability hardening** (HA hub, backup automation) — depends on F being done
- **E = OSS distribution** (docs, install UX, Docker compose, demo) — separate track
- **Frontend context consolidation** (7 → 3-4 contexts + TanStack Query) — own design after F
- **Agent code reorganization** — not currently needed
- **Unified Add Machine chooser** (per saved plan at `thoughts/shared/plans/2026-04-27-unified-add-machine-chooser.md` on hub VM) — paused per bokiko, "save it until I tell you when to implement it"
- **Live Windows enrolment verification** (Phase 9) — ops task, blocked on a live Windows host
- **Re-add Dasman/Dell API machines** — ops task, blocked on credential rotation
- **Decide on Ai-05 + dont-know offline WSL machines** — ops decision
---
*Authored 2026-05-02 from a `/brainstorm` session covering: 360 project review → priority pick (F→A→C) → architecture-debt scope (β) → package layout (B-modified) → migration strategy (ii one-package-at-a-time) → test strategy (x migrate-as-we-go).*