Files
summercms/.planning/phases/15-cutover/15-CONTEXT.md
2026-10-04 14:30:25 +02:00

16 KiB

Phase 15: Cutover - Context

Gathered: 2026-10-04 Status: Ready for planning

## Phase Boundary

The Go backend (fonoteka.go on SummerCMS) replaces the PHP WinterCMS backend in production at https://plytarium.com. vue-fonoteka-app and fonoteka-mcp keep running unchanged. The phase covers four pieces of work:

  • Data move: production data moves from MariaDB (production runs DB_CONNECTION=mysql with utf8mb4_polish_ci, per fonoteka/docs/deploy/plytarium.com.md) into the Go Postgres schema. The Go stack is Postgres-only and MySQL support stays out of scope (PROJECT.md), so the cutover is the one place a MySQL reader exists.
  • Final parity gate: every manifest route is green with zero pending entries.
  • Production switch: a freeze-import-swap on rome with a rollback path, the Go runtime under supervisor, and a Postgres backup timer.
  • Daily-use acceptance: a 7-day soak in production with a scripted Nuxt and MCP session.

Repos: summercms.go (the generic import runner, framework mappers, docs) and fonoteka.go (app and plugin mappers, CUTOVER.md runbook, scripts, supervisor/nginx/backup configs). The shared plugin repos (sm-user-plugin, sm-golem-plugin, sm-feedback-plugin) receive their own import mappers.

Not in scope: anything on the PHP side after the swap. The user handles the PHP sign-off, stopping its cron and queue worker, and the PHP/MariaDB decommission outside the plans.

## Implementation Decisions

Data move (MariaDB → Postgres)

  • D-01: The move is a Go import command that reads MariaDB over a DSN and writes into the schema the Go migrations created, table by table. It coerces types along the way: tinyint to bool, JSON text to jsonb, datetime and timezone handling, and a sequence reset at the end. pgloader and SQL-dump rewriting were rejected. A MySQL driver is added as a dependency for this command only. The phase decision names it, as CLAUDE.md requires, and the researcher picks the driver (expected: github.com/go-sql-driver/mysql). — Reversibility: reversible — the dependency is confined to the import path.

  • D-02: The import logic is split between the framework and the plugins. summercms.go ships a generic summer import:winter runner: the MySQL source, table copy with coercion, batching, sequence reset, and a Laravel-decrypt hook built on lagoon/laravel_decrypt.go. Each plugin implements an optional capability interface (working name HasWinterImport, alongside the pact capabilities) that declares its tables and any per-column transforms. Framework-owned tables (backend_users, backend_user_roles, system_files, settings) get framework mappers. The runner is reusable for the next WinterCMS port (the blog project). Framework READMEs and docs must not name the application. — Reversibility: costly — the capability interface becomes part of the plugin API that shared plugins implement.

  • D-03: Encrypted columns are decrypted from Laravel's AES-256-CBC with the PHP APP_KEY and re-encrypted to the Go AES-256-GCM format during the import (Phase 5 D-10). The import is the only caller of DecryptLaravelPayload, which never enters the live Scan/Value path. Golem models arrive through the existing golem:import-settings and feedback:import-settings importers (Phase 14 D-18), which the runner calls or the runbook sequences.

  • D-04: The following data is not carried over:

    • queue and job state (Laravel jobs, failed_jobs, Redis queue contents);
    • sessions, cache and event/request logs;
    • the JWT blacklist (Phase 7 D-06: live JWTs keep working through the shared JWT_SECRET);
    • the Typesense index, which is rebuilt with fonoteka:reindex after the import;
    • notifications older than the prune window.

    Everything else is copied, and primary keys are preserved so IDs in URLs, share links and fixtures stay valid. Pending CSV import sessions are drained before the freeze rather than migrated into River.

  • D-05: The import is rehearsed before the real switch on a restored production mariadb-dump (the nightly dump from backup-restore.md). A verify step runs after each import:

    • row counts per table;
    • checksums of key columns;
    • a decrypt round-trip on every encrypted value;
    • a file-existence check for every system_files row against the copied uploads tree.

    The import is repeated until it is clean. One timed dress rehearsal then sizes the downtime window. The import must be re-runnable: it truncates its target tables or refuses to start on a non-empty target.

Production switch and rollback

  • D-06: Switch: put the PHP site into the existing nginx @maintenance page, take a final dump, run the import and verify, then repoint nginx's backend locations at the Go binary. Those locations are the API prefixes, the OAuth endpoints, /storage/app/uploads, the realtime/feedback routes and the admin path. The researcher derives the exact list from the manifest and the current vhost. The origin stays https://plytarium.com. Nuxt SSR and its build stay untouched. A staging subdomain and nginx mirroring were rejected: the first would need a different Nuxt build, and mirroring only covers GET requests.
  • D-07: Rollback: PHP and the MariaDB snapshot stay in place on rome, and rollback means swapping the nginx upstream back. There is no reverse sync from Go to MariaDB, so writes made on Go during the window are lost or re-entered by hand. That is acceptable for the household user base. The rollback window ends at the 7-day sign-off (D-13).
  • D-08: The runtime on rome is supervisor with two programs: fonoteka serve and a separate River worker/scheduler program, each restartable on its own. This matches the 11.2 summercms.io deploy and the existing albumy-queue program. Postgres runs on rome next to MariaDB. The configs are committed in fonoteka.go.
  • D-09: The user runs every production step, because rome is not reachable from the dev machine. The phase delivers a CUTOVER.md runbook in fonoteka.go. It covers the preflight, the freeze, the import, the verify, the swap, the smoke checks, the rollback, and the env mapping from the Winter .env names to the SUMMER_* names, with every secret left as a placeholder. Each step has its own script. The user runs them and pastes the output back as evidence, and verification records that evidence as UAT items.
  • D-10: Backups: a nightly pg_dump plus the uploads tarball replaces the MariaDB timer. It follows backup-restore.md: files are 0600, the directory is 0700 and outside the docroot, and credentials live in a 0600 file. It is installed at the swap, so production is never without a backup, and one restore drill runs against a scratch database.

"All routes green" gate

  • D-11: The acceptance set is every route in fonoteka.go/parity/manifest.yaml: 175 entries today, covering the fonoteka, user, oauth, realtime and feedback routes. Every entry must be ported, with passing recorded cases, zero pending entries, and routes.snapshot matching PHP on path, method and auth group. API-09, QA-05 and the ROADMAP success criteria are reworded at plan time from "154 routes" to "every manifest route".
  • D-12: Fixtures: the whole corpus is re-recorded from the current PHP backend (summer parity:record) shortly before the freeze, and the Go replay must be green on it. A drift report lists every fixture whose PHP response changed since it was committed, and each drift is resolved (a Go fix, or an accepted normalizer change) before the swap. In addition, a read-only smoke diff runs on the rehearsal import. A scripted set of authenticated GET requests (as real users, via minted JWTs and personal tokens) is replayed against PHP on the restored MariaDB and against Go on the Postgres imported from the same dump, and both are diffed with the tide normalizer. The smoke diff runs locally on the dump only.
  • D-13 (prerequisites): Phase 14.1 (the 3 pending routes) and Phase 12.1 (the user admin screens) are hard prerequisites, and 12.1 is added to Phase 15's depends-on in ROADMAP. Phase 14's open UAT items must also be closed: the CR-01 decision record, and the WR-02/WR-05 dispositions, which fonoteka.go commits suggest are already fixed. Phase 15's first plan opens with a preflight gate that fails unless all of this holds and the manifest has zero pending routes. Planning may start before the prerequisites close.

Daily-use acceptance

  • D-14: The soak is 7 days of normal household use on Go with no rollback-worthy issue. During it, the wishlist digest and the notification prune schedules each run at least once, and at least one CSV import and one Discogs match complete. The end of the soak is the phase sign-off and closes the rollback window.

  • D-15: The scripted manual session runs right after the swap and again before sign-off. It covers:

    • Nuxt core session: log in with an existing account and confirm the JWT survives the switch; browse collections and albums; search; edit an album; rate; upload a cover; add and remove a wishlist entry; run a CSV import and export; switch locale.
    • MCP + OAuth: run the fonoteka-mcp install/auth flow from scratch; reconnect an existing MCP client; make a representative set of tool calls (list/search/get/create/update album, wishlist, stats, cover fetch).

    Sharing/invitations and admin/ops checks were offered and not selected for the scripted session. They remain covered by the parity gate.

  • D-16: After sign-off, PHP is left running but unrouted (the user's choice, for a late rollback). Stopping the PHP cron and queue worker, the PHP sign-off and any later removal are handled by the user outside the plans, so no plan touches the PHP stack beyond the maintenance-page freeze and the nginx swap.

Claude's Discretion

  • How the runner batches rows, its ordering by foreign key and how the coercion table is laid out, the naming of the HasWinterImport interface and its method set, and the verify-report format.
  • How the runbook is split into scripts, and the layout of the evidence capture.
  • The exact supervisor program names and the log locations.
  • Whether the read-only smoke diff reuses tide replay against two base URLs or adds a dedicated parity:* subcommand.

<canonical_refs>

Canonical References

Downstream agents MUST read these before planning or implementing.

Production environment (PHP, current)

  • /media/nvme/dev/golem15/fonoteka/docs/deploy/plytarium.com.md: the production origin, same-origin SPA+API, the MariaDB/Redis/Typesense env, SSR rules, and the nginx include files.
  • /media/nvme/dev/golem15/fonoteka/docs/deploy/backup-restore.md: the current mariadb-dump + uploads backup and the restore drill. It is the model for D-10 and the source of the rehearsal dump (D-05).
  • /media/nvme/dev/golem15/fonoteka/docs/deploy/systemd/: the current backup units.
  • /media/nvme/dev/golem15/fonoteka/scripts/deploy-frontend.sh: the Nuxt deploy and maintenance-page behaviour, which stays untouched.

Prior decisions this phase builds on

  • .planning/PROJECT.md: Postgres only, the out-of-scope MySQL support, the core value.
  • .planning/REQUIREMENTS.md: API-09 and QA-05, which are reworded per D-11.
  • .planning/phases/05-data-layer-full-fidelity/05-RESEARCH.md: D-10 (Laravel decrypt is import-only), D-14/D-17 (system_files shape and thumb reuse), and the cutover import deferred to Phase 15.
  • .planning/phases/07-user-plugin-and-authentication/07-CONTEXT.md: D-06 (wire-compatible JWTs, blacklist not migrated) and D-15 (reset codes).
  • .planning/phases/09-backend-admin-authentication-and-schema-pipeline/09-CONTEXT.md: D-01 (Winter-shaped backend_users, copied straight in at cutover) and WR-17 (user-level permissions).
  • .planning/phases/14-domain-jobs-and-external-integrations/14-CONTEXT.md: D-18 (golem/feedback settings importers), D-20 (GOLEM15_SSRF_ALLOWED_HOSTS), and D-09 (the routes now in 14.1).
  • .planning/phases/14-domain-jobs-and-external-integrations/14-04-SUMMARY.md and 14-05-SUMMARY.md: the operator steps at cutover (golem:import-settings, feedback:import-settings, the SUMMER_GOLEM15__FEEDBACK__G15_OFFICE__* env vars, and the SSRF allowlist question).
  • .planning/phases/14-domain-jobs-and-external-integrations/14-UAT.md and 14-VERIFICATION.md: the open items behind the D-13 preflight.

Deploy pattern to mirror

  • ../sm-summercmsio-app/DEPLOY.md (and ../sm-summercmsio-app/deploy/): the 11.2 supervisor + nginx deploy on rome, with its rollback copy and its "Verify after cutover" pattern.

Parity tooling

  • ../fonoteka.go/parity/manifest.yaml, ../fonoteka.go/parity/routes.snapshot, ../fonoteka.go/parity/fixtures/: the acceptance set (D-11).
  • modules/tide/README.md: the record, replay, proxy, normalize and diff commands.

</canonical_refs>

<code_context>

Existing Code Insights

Reusable Assets

  • modules/lagoon/laravel_decrypt.go: a tested Laravel CBC payload decrypt helper, built for this import.
  • modules/lagoon/encrypted.go: the GCM Encrypted cast that the import re-encrypts into.
  • modules/pact/capabilities.go: the optional capability interface pattern (HasCommands, HasMigrations, …), where HasWinterImport belongs.
  • modules/tide/ (record.go, replay.go, diff.go, normalize.go, proxy.go, report.go): the re-record, replay and drift report pieces, and the smoke diff normalizer.
  • modules/bonfire: the CLI command kernel for summer import:winter and the fonoteka-side verify commands.
  • The existing importers golem:import-settings and feedback:import-settings (sm-golem-plugin, sm-feedback-plugin).
  • fonoteka:reindex (Phase 14): rebuilds the Typesense index after the import.

Established Patterns

  • Two repos: the framework code (runner, capability, framework-table mappers) goes in summercms.go and must not name the application. App and plugin mappers, the runbook and the deploy configs go in fonoteka.go and the shared plugin repos (submodules; push them with ssu).
  • Tests that need real databases use testcontainers. The import tests run MariaDB → Postgres containers and are gated like the existing integration suite.
  • The database is created with the Polish ICU default collation (fonoteka.go/README.md). Production Postgres on rome must be created the same way, to keep the sort order of utf8mb4_polish_ci.
  • Unit tests are the last plan of the phase (CLAUDE.md lean rule).

Integration Points

  • The Go migrations must have run on the target database before the import. The import writes into the migrated schema and never creates tables.
  • The uploads tree is copied as-is under storage/app/uploads. Blob keys are partition + disk_name (Phase 5), so existing thumbs are reused.
  • The nginx vhost on rome is operator-owned. The runbook gives the location blocks to change, and changes go through nginx -t and are never overwritten blindly.

</code_context>

## Specific Ideas
  • The user handles the PHP side after the swap (cron, queue worker, decommission). Plans must not include those steps.
  • "Leave PHP running but unrouted" is the user's chosen post-sign-off state.
  • The scripted session deliberately covers only the Nuxt core session and MCP + OAuth.
## Deferred Ideas
  • The Go → MariaDB reverse sync for rollback was considered and rejected, not deferred (D-07).
  • PHP/MariaDB removal: the user handles it outside the plans (D-16).
  • A benchmark of WinterCMS against SummerCMS on Płytarium (todo 2026-10-01-benchmark-the-application-on-wintercms-vs-summercms.md). The rehearsal dump and the dual-backend smoke setup (D-12) make good inputs for it, but it is not in scope.

Reviewed Todos (not folded)

  • orphan-pending-routes.md: now owned by Phase 14.1, a prerequisite (D-13).
  • rewrite-summercms-readme.md and refresh-fonoteka-readme.md: README work, not a cutover step. They can be folded into a docs quick task.
  • backend-admin-api-tokens.md, readme-go-fences-src.md, lagoon-readme-after-commit-callback-order.md, sitemap-plugin-port.md: unrelated to the cutover.

Phase: 15-cutover Context gathered: 2026-10-04