diff --git a/backend/Dockerfile b/backend/Dockerfile index b593d33..c3150af 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -49,9 +49,14 @@ COPY --from=build /out/backend /usr/local/bin/backend # Own the seed dictionary as the nonroot runtime user (UID 65532): a named volume # mounted at /opt/dawg inherits this ownership on first use, so the admin console # can write new version subdirectories at runtime. The volume preserves uploaded -# versions across deploys and, once seeded, is not re-seeded — so after bootstrap -# every dictionary change goes through the console, not a rebuild (ARCHITECTURE.md §5). +# versions across deploys and, once seeded, is not re-seeded (ARCHITECTURE.md §5). COPY --from=dawg --chown=65532:65532 /dawg /opt/dawg +# A second, UNSHADOWED copy of the build's DAWGs: the /opt/dawg volume mount hides the +# image's copy there, so this is where engine.DeliverVersion reads the build version from +# to add it (add-only) to the volume on boot — the deploy-delivers path that lets a bumped +# BACKEND_DICT_VERSION go live for new games without an admin upload (ARCHITECTURE.md §5). +COPY --from=dawg --chown=65532:65532 /dawg /opt/dawg-seed ENV BACKEND_DICT_DIR=/opt/dawg +ENV BACKEND_DICT_SEED_DIR=/opt/dawg-seed ENV BACKEND_DICT_VERSION=${DICT_VERSION} ENTRYPOINT ["/usr/local/bin/backend"] diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go index 1d6b3ee..c55fa6f 100644 --- a/backend/cmd/backend/main.go +++ b/backend/cmd/backend/main.go @@ -111,6 +111,12 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { } logger.Info("database migrations applied") + // Deliver the build's dictionary version onto the persistent volume if a redeploy + // bumped it (add-only; old versions in-flight games pin are untouched), so the new + // dictionary is resident and can be activated below without an admin upload. + if err := engine.DeliverVersion(cfg.Game.DictDir, cfg.Game.DictSeedDir, cfg.Game.DictVersion); err != nil { + return fmt.Errorf("deliver dictionary version: %w", err) + } registry, err := engine.OpenWithVersions(cfg.Game.DictDir, cfg.Game.DictVersion) if err != nil { return fmt.Errorf("load dictionaries: %w", err) @@ -118,6 +124,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { defer func() { _ = registry.Close() }() logger.Info("dictionaries loaded", zap.String("dir", cfg.Game.DictDir), + zap.String("seed_dir", cfg.Game.DictSeedDir), zap.String("version", cfg.Game.DictVersion)) // Admin console: an optional backend client to the Telegram connector @@ -147,10 +154,10 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { accounts := account.NewStore(db) accounts.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/account")) games := game.NewService(game.NewStore(db), accounts, registry, cfg.Game, logger) - // Reconcile the persisted active dictionary version with the registry: a - // version activated through the admin console (and written to the dictionary - // volume) is adopted again after a restart; otherwise the configured seed - // version is kept and persisted (docs/ARCHITECTURE.md §5). + // Reconcile the active dictionary version with the registry: the build version + // (delivered above) becomes active so a redeploy that bumped it goes live for new + // games, except a newer version installed out-of-band through the admin console, + // which is not downgraded by a restart (docs/ARCHITECTURE.md §5). if err := games.InitActiveVersion(ctx); err != nil { return fmt.Errorf("init active dictionary version: %w", err) } diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index a533ea2..665cb9f 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -106,6 +106,7 @@ func Load() (Config, error) { gm := game.DefaultConfig() gm.DictDir = envOr("BACKEND_DICT_DIR", gm.DictDir) gm.DictVersion = envOr("BACKEND_DICT_VERSION", gm.DictVersion) + gm.DictSeedDir = envOr("BACKEND_DICT_SEED_DIR", gm.DictSeedDir) if gm.TimeoutSweepInterval, err = envDuration("BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL", gm.TimeoutSweepInterval); err != nil { return Config{}, err } diff --git a/backend/internal/engine/deliver.go b/backend/internal/engine/deliver.go new file mode 100644 index 0000000..36b1c5b --- /dev/null +++ b/backend/internal/engine/deliver.go @@ -0,0 +1,92 @@ +package engine + +import ( + "errors" + "fmt" + "io" + "os" + "path/filepath" +) + +// DeliverVersion makes the build's dictionary version resident on the persistent +// dictionary volume without disturbing the versions in-flight games already pin. +// +// The volume is seeded from the image once and never re-seeded, and the image's +// /opt/dawg is shadowed by that volume mount, so a redeploy that bumped +// BACKEND_DICT_VERSION cannot otherwise make the new DAWGs reachable at runtime. +// The image therefore keeps an unshadowed read-only copy at seedDir, and this +// copies it into dictDir// when the version is not already present — +// neither the flat seed's own recorded label nor an existing version +// subdirectory. It is add-only and idempotent: the flat seed and any prior admin +// uploads are never touched, so old games keep the version they pin while the new +// one becomes resident and activatable (see game.Service.InitActiveVersion). A +// blank seedDir disables delivery (the pre-existing seed-only behaviour). +func DeliverVersion(dictDir, seedDir, version string) error { + if seedDir == "" || version == "" { + return nil + } + target := filepath.Join(dictDir, version) + if _, err := os.Stat(target); err == nil { + return nil // already delivered on a prior boot + } else if !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("engine: stat delivered dictionary %s: %w", target, err) + } + // A fresh volume is populated from the image and recorded as this version by the + // marker: it is the flat dir, so there is nothing to deliver. resolveSeedVersion + // records the label on first use, matching OpenWithVersions. + seed, err := resolveSeedVersion(dictDir, version) + if err != nil { + return err + } + if seed == version { + return nil + } + // Stage into a dot-prefixed directory (skipped by OpenWithVersions' version scan) + // then rename, so a crash mid-copy never leaves a half-populated version subdir. + staging := filepath.Join(dictDir, ".staging-deliver-"+version) + if err := os.RemoveAll(staging); err != nil { + return fmt.Errorf("engine: clear deliver staging %s: %w", staging, err) + } + if err := os.MkdirAll(staging, 0o755); err != nil { + return fmt.Errorf("engine: create deliver staging %s: %w", staging, err) + } + for _, file := range dictFiles { + src := filepath.Join(seedDir, file) + if _, err := os.Stat(src); errors.Is(err, os.ErrNotExist) { + continue // a variant absent from the seed is simply absent under this version + } else if err != nil { + _ = os.RemoveAll(staging) + return fmt.Errorf("engine: stat seed dawg %s: %w", src, err) + } + if err := copyFile(src, filepath.Join(staging, file)); err != nil { + _ = os.RemoveAll(staging) + return err + } + } + if err := os.Rename(staging, target); err != nil { + _ = os.RemoveAll(staging) + return fmt.Errorf("engine: publish delivered dictionary %s: %w", target, err) + } + return nil +} + +// copyFile copies a single file, truncating the destination. +func copyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return fmt.Errorf("engine: open %s: %w", src, err) + } + defer func() { _ = in.Close() }() + out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644) + if err != nil { + return fmt.Errorf("engine: create %s: %w", dst, err) + } + if _, err := io.Copy(out, in); err != nil { + _ = out.Close() + return fmt.Errorf("engine: copy %s -> %s: %w", src, dst, err) + } + if err := out.Close(); err != nil { + return fmt.Errorf("engine: finalise %s: %w", dst, err) + } + return nil +} diff --git a/backend/internal/engine/deliver_test.go b/backend/internal/engine/deliver_test.go new file mode 100644 index 0000000..ad3bd76 --- /dev/null +++ b/backend/internal/engine/deliver_test.go @@ -0,0 +1,68 @@ +package engine + +import ( + "os" + "path/filepath" + "testing" +) + +func TestDeliverVersion(t *testing.T) { + // A blank seed directory disables delivery (the pre-existing seed-only behaviour). + if err := DeliverVersion(t.TempDir(), "", "v1.3.1"); err != nil { + t.Fatalf("blank seedDir: %v", err) + } + + // An existing volume flat-seeded as v1.3.0 gets v1.3.1 delivered as a subdirectory, + // add-only: the flat seed's marker is left intact and the new version's DAWGs land. + dict := t.TempDir() + seed := t.TempDir() + if err := os.WriteFile(filepath.Join(dict, seedMarkerFile), []byte("v1.3.0\n"), 0o644); err != nil { + t.Fatal(err) + } + for _, f := range dictFiles { + if err := os.WriteFile(filepath.Join(seed, f), []byte("dawg:"+f), 0o644); err != nil { + t.Fatal(err) + } + } + if err := DeliverVersion(dict, seed, "v1.3.1"); err != nil { + t.Fatalf("deliver v1.3.1: %v", err) + } + for _, f := range dictFiles { + got, err := os.ReadFile(filepath.Join(dict, "v1.3.1", f)) + if err != nil { + t.Fatalf("delivered %s: %v", f, err) + } + if string(got) != "dawg:"+f { + t.Errorf("delivered %s = %q, want the seed bytes", f, got) + } + } + if m, _ := os.ReadFile(filepath.Join(dict, seedMarkerFile)); string(m) != "v1.3.0\n" { + t.Errorf("flat seed marker relabelled to %q (delivery must be add-only)", m) + } + // Idempotent: a second call is a no-op and leaves no staging directory behind. + if err := DeliverVersion(dict, seed, "v1.3.1"); err != nil { + t.Fatalf("re-deliver v1.3.1: %v", err) + } + if entries, _ := os.ReadDir(dict); hasStaging(entries) { + t.Errorf("a staging directory survived delivery") + } + + // On a fresh directory the build version becomes the flat seed, so nothing is delivered + // as a redundant subdirectory. + fresh := t.TempDir() + if err := DeliverVersion(fresh, seed, "v1.3.1"); err != nil { + t.Fatalf("fresh deliver: %v", err) + } + if _, err := os.Stat(filepath.Join(fresh, "v1.3.1")); !os.IsNotExist(err) { + t.Errorf("fresh volume got a redundant v1.3.1 subdir; it should be the flat seed") + } +} + +func hasStaging(entries []os.DirEntry) bool { + for _, e := range entries { + if len(e.Name()) >= len(".staging") && e.Name()[:len(".staging")] == ".staging" { + return true + } + } + return false +} diff --git a/backend/internal/engine/seedmarker.go b/backend/internal/engine/seedmarker.go index 50514c0..07f5e51 100644 --- a/backend/internal/engine/seedmarker.go +++ b/backend/internal/engine/seedmarker.go @@ -25,11 +25,11 @@ const seedMarkerFile = ".seed_version" // BACKEND_DICT_VERSION) and return it — the seed of a fresh volume; // - already-seeded directory: return the recorded marker and ignore bootVersion. // -// So bumping the build seed on a live volume is a harmless no-op (it only takes -// effect on a future fresh volume) instead of relabelling the already-seeded bytes — -// which would void games pinned to the prior label and mis-serve new ones. New games -// still pin the active version (DB-persisted, set by the admin console), which is the -// real way a running contour moves to a new release. +// So a later build seed never relabels the already-seeded flat bytes — which would void +// games pinned to the prior label and mis-serve new ones. The new build version is instead +// delivered as a NEW subdirectory (DeliverVersion) and made active (Service.InitActiveVersion), +// so a redeploy still moves new games to it while old games keep the flat label. New games +// pin the active version (DB-persisted); a console upload can also set it out-of-band. // // A directory that cannot be written makes the first record fail; that also breaks // the admin console (which writes version subdirectories here), so the error is diff --git a/backend/internal/game/config.go b/backend/internal/game/config.go index 8d640be..0ae76bc 100644 --- a/backend/internal/game/config.go +++ b/backend/internal/game/config.go @@ -18,6 +18,13 @@ type Config struct { // DictVersion labels the dictionary version new games pin. Sourced from // BACKEND_DICT_VERSION. DictVersion string + // DictSeedDir holds the image's read-only copy of the build's DictVersion DAWGs, + // on a path the persistent DictDir volume does NOT shadow. On boot the backend + // delivers this version into DictDir// if absent (add-only), so a + // redeploy that bumped DICT_VERSION makes the new dictionary resident and activatable + // without disturbing the versions in-flight games already pin. Sourced from + // BACKEND_DICT_SEED_DIR; empty disables delivery (the pre-existing seed-only behaviour). + DictSeedDir string // TimeoutSweepInterval is how often the sweeper scans for overdue turns. // Sourced from BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL. TimeoutSweepInterval time.Duration diff --git a/backend/internal/game/service.go b/backend/internal/game/service.go index 7ba61d6..f235b2d 100644 --- a/backend/internal/game/service.go +++ b/backend/internal/game/service.go @@ -232,21 +232,31 @@ func (svc *Service) ActiveVersion() string { } // InitActiveVersion reconciles the persisted active dictionary version with the -// registry at startup. If a version was persisted and is resident, it becomes the -// active version; otherwise the configured seed version is kept and persisted as -// the initial active version. Call once during wiring, before serving traffic. +// registry at startup and makes the build's version (BACKEND_DICT_VERSION, delivered +// resident by engine.DeliverVersion) authoritative: a redeploy that bumped the build +// version thus activates it for new games without any admin step, while old games keep +// the version they pin. The one exception is a version installed out-of-band through +// the admin console that is NEWER than the build version and still resident — it is +// kept, so a restart on an older image never downgrades a hotfix. A build version that +// is not resident (delivery disabled or a partial state) falls back to a resident +// persisted version. The chosen version is persisted so the admin console reflects it. +// Call once during wiring, before serving traffic. func (svc *Service) InitActiveVersion(ctx context.Context) error { persisted, ok, err := svc.store.GetActiveDictVersion(ctx) if err != nil { return err } - if ok && svc.versionResident(persisted) { - svc.verMu.Lock() - svc.version = persisted - svc.verMu.Unlock() - return nil + target := svc.activeVersion() // the build seed version (config.DictVersion) + if ok && svc.versionResident(persisted) && compareDictVersions(persisted, target) > 0 { + target = persisted // a newer out-of-band install wins over the build version } - return svc.store.SetActiveDictVersion(ctx, svc.activeVersion()) + if !svc.versionResident(target) && ok && svc.versionResident(persisted) { + target = persisted // the build version is unloadable — keep a resident one + } + svc.verMu.Lock() + svc.version = target + svc.verMu.Unlock() + return svc.store.SetActiveDictVersion(ctx, target) } // SetActiveVersion records version as the active dictionary version, persisting it diff --git a/backend/internal/game/version.go b/backend/internal/game/version.go new file mode 100644 index 0000000..215c601 --- /dev/null +++ b/backend/internal/game/version.go @@ -0,0 +1,45 @@ +package game + +import ( + "strconv" + "strings" +) + +// compareDictVersions orders two dictionary version labels of the shape "vMAJOR.MINOR.PATCH" +// numerically, returning -1, 0 or +1 as a is less than, equal to or greater than b. Labels +// that do not parse fall back to a byte comparison, so the ordering is always total (a mixed +// or malformed pair never crashes the boot-time active-version reconcile). +func compareDictVersions(a, b string) int { + pa, oka := parseDictVersion(a) + pb, okb := parseDictVersion(b) + if !oka || !okb { + return strings.Compare(a, b) + } + for i := range pa { + if pa[i] != pb[i] { + if pa[i] < pb[i] { + return -1 + } + return 1 + } + } + return 0 +} + +// parseDictVersion parses a "vMAJOR.MINOR.PATCH" label into its three numeric parts, reporting +// whether it matched that shape. +func parseDictVersion(v string) ([3]int, bool) { + parts := strings.Split(strings.TrimPrefix(v, "v"), ".") + if len(parts) != 3 { + return [3]int{}, false + } + var out [3]int + for i, p := range parts { + n, err := strconv.Atoi(p) + if err != nil { + return [3]int{}, false + } + out[i] = n + } + return out, true +} diff --git a/backend/internal/game/version_test.go b/backend/internal/game/version_test.go new file mode 100644 index 0000000..794b88d --- /dev/null +++ b/backend/internal/game/version_test.go @@ -0,0 +1,37 @@ +package game + +import "testing" + +func TestCompareDictVersions(t *testing.T) { + cases := []struct { + a, b string + want int + }{ + {"v1.3.0", "v1.3.1", -1}, + {"v1.3.1", "v1.3.0", 1}, + {"v1.3.1", "v1.3.1", 0}, + {"v1.3.9", "v1.3.10", -1}, // numeric, not lexical + {"v1.10.0", "v1.9.0", 1}, + {"v2.0.0", "v1.9.9", 1}, + {"1.3.1", "v1.3.1", 0}, // the leading v is optional + // Non-semver falls back to a byte comparison, keeping the ordering total. + {"weird", "weird", 0}, + {"a", "b", -1}, + } + for _, c := range cases { + if got := compareDictVersions(c.a, c.b); sign(got) != c.want { + t.Errorf("compareDictVersions(%q, %q) = %d, want sign %d", c.a, c.b, got, c.want) + } + } +} + +func sign(n int) int { + switch { + case n < 0: + return -1 + case n > 0: + return 1 + default: + return 0 + } +} diff --git a/backend/internal/inttest/dictionary_update_test.go b/backend/internal/inttest/dictionary_update_test.go index c1c359b..1faaee5 100644 --- a/backend/internal/inttest/dictionary_update_test.go +++ b/backend/internal/inttest/dictionary_update_test.go @@ -103,14 +103,31 @@ func TestDictionaryUpdateFlow(t *testing.T) { t.Errorf("installed dawg missing on disk: %v", err) } - // The choice survives a restart: a fresh service over the same DB and registry - // re-adopts the persisted active version. + // The choice survives a restart on the SAME build: an out-of-band admin install newer + // than the build version is not downgraded (a restart on an older image keeps the hotfix). restarted := newGameServiceOn(dir, "v1.0.0", reg) if err := restarted.InitActiveVersion(ctx); err != nil { t.Fatalf("restart init: %v", err) } if got := restarted.ActiveVersion(); got != "v9.9.9" { - t.Errorf("restarted active version = %q, want v9.9.9 (persisted)", got) + t.Errorf("restarted active version = %q, want v9.9.9 (a newer out-of-band install is kept)", got) + } + + // A redeploy that delivered a build version NEWER than the out-of-band one activates it + // for new games without any admin step (the deploy-delivers path — docs/ARCHITECTURE.md §5). + if _, err := reg.LoadAvailable(dir, "v10.0.0"); err != nil { + t.Fatalf("make v10.0.0 resident: %v", err) + } + deployed := newGameServiceOn(dir, "v10.0.0", reg) + if err := deployed.InitActiveVersion(ctx); err != nil { + t.Fatalf("deploy init: %v", err) + } + if got := deployed.ActiveVersion(); got != "v10.0.0" { + t.Errorf("deployed active version = %q, want v10.0.0 (a newer build version wins)", got) + } + // Restore the out-of-band active version so the assertions below still read v9.9.9. + if err := restarted.SetActiveVersion(ctx, "v9.9.9"); err != nil { + t.Fatalf("restore active: %v", err) } // New games pin the new version; the in-progress game keeps its own, still resident. diff --git a/deploy/README.md b/deploy/README.md index 0b72f4a..a9d683e 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -98,7 +98,7 @@ without it Docker's resolver handles `otelcol`, `gateway` and `api.telegram.org` | --- | --- | --- | --- | | `POSTGRES_DB` | variable | `scrabble` | Database name. | | `POSTGRES_USER` | variable | `scrabble` | Database user. | -| `DICT_VERSION` | variable (shared) | `v1.3.1` | `scrabble-dictionary` release tag baked into the backend image as the **seed for a fresh volume** (build-arg). A live contour changes dictionary through the admin console, not this; on a seeded volume a changed value is ignored (the recorded `.seed_version` marker wins — the seed-drift guard, ARCHITECTURE.md §5). One shared unprefixed Gitea variable seeds both contours and pins the CI test suite. | +| `DICT_VERSION` | variable (shared) | `v1.3.1` | `scrabble-dictionary` release tag baked into the backend image (build-arg). **Deploy-authoritative**: a redeploy that bumped it **delivers** the version onto the volume (add-only) and **activates** it for new games — old games keep the version they pin, and a newer out-of-band console upload is never downgraded (ARCHITECTURE.md §5). The `.seed_version` marker still guards the flat seed's label (a bump is delivered as a new subdirectory, never a relabel). One shared unprefixed Gitea variable seeds both contours and pins the CI test suite. | | `LOG_LEVEL` | variable | `info` | Shared log level for backend / gateway / validator / bot (`debug\|info\|warn\|error`). | | `CADDY_SITE_ADDRESS` | variable | `:80` | Caddy site address. Test: `:80` (host caddy terminates TLS). Prod: a domain, so caddy does its own ACME. | | `GM_BASICAUTH_USER` | variable | `gm` | Username for the `/_gm` Basic-Auth. | @@ -176,10 +176,14 @@ For local builds set `DICT_VERSION` in `deploy/.env` (template: `.env.example`); `docker build` needs `--build-arg DICT_VERSION=vX.Y.Z`. The Dockerfiles and `compose` carry no default — a missing value fails loudly instead of baking a stale tag. -Bumping the seed is a **no-op on a live volume** (the `.seed_version` marker wins — the -seed-drift guard). A running contour/prod moves to a new release **through the admin console** -`/_gm/dictionary` (upload the tarball, preview the per-variant diff, confirm); in-flight games -keep their pinned version, new games use the new one (ARCHITECTURE.md §5). +Bumping `DICT_VERSION` and **redeploying** takes a live contour/prod to the new dictionary: +on boot the backend **delivers** the build's version onto the volume (add-only, into +`BACKEND_DICT_DIR//` from the unshadowed `BACKEND_DICT_SEED_DIR`) and **activates** it, +so new games pin it while in-flight games keep the version they started on. The `.seed_version` +marker still guards the flat seed's label (the delivery is a new subdirectory, never a relabel). +The admin console `/_gm/dictionary` (upload the tarball, preview the per-variant diff, confirm) +remains for an **out-of-band** update without a redeploy; a console version newer than the build +survives a later restart on an older image (ARCHITECTURE.md §5). ## Production rollout diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ff21a40..3ea716a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -446,22 +446,27 @@ Key points: version while new games use the new one. A restart re-loads every resident version via `engine.OpenWithVersions` (the flat seed plus each subdirectory, skipping the `.staging/` upload area) and restores the active pointer from - `dictionary_state`. The volume preserves uploaded versions across redeploys; - once seeded it is not re-seeded, so after bootstrap dictionary changes go through - the console rather than a rebuild. Because the flat DAWGs carry no embedded - version, `OpenWithVersions` records the version the flat directory was first seeded - at in a `.seed_version` marker on the volume and treats that marker as - **authoritative** (the **seed-drift guard**): on an already-seeded volume a later - `BACKEND_DICT_VERSION` is ignored, so a bumped build seed cannot relabel the - already-seeded bytes — which would otherwise silently serve the wrong dictionary - and void games pinned to the prior label. A running contour therefore moves to a - new release **through the console** (the prior version stays resident, so its games - keep replaying), and `DICT_VERSION` is the seed for a **fresh** volume only: - bumping it on a live contour is a harmless no-op that takes effect on the next - fresh volume. Set it per contour from the deploy's `TEST_`/`PROD_DICT_VERSION`. - (The dictionaries ship as a versioned **release artifact** from the - `scrabble-dictionary` repo; the build's `DICT_VERSION` selects - only the seed.) + `dictionary_state`. The volume preserves uploaded versions across redeploys, so + in-flight games keep replaying on the version they pin. The build's + `BACKEND_DICT_VERSION` is **deploy-authoritative**: on boot `engine.DeliverVersion` + copies the image's DAWGs — kept unshadowed at `BACKEND_DICT_SEED_DIR`, since the + volume mount hides the image's `BACKEND_DICT_DIR` copy — into + `BACKEND_DICT_DIR//` when that version is not already resident (**add-only**: + the flat seed and prior uploads are never touched), and `InitActiveVersion` makes it + the active version. So a redeploy that bumped `DICT_VERSION` moves new games to the + new dictionary automatically, while old games stay on theirs — no console step. The + one exception: a version installed out-of-band through the console that is **newer** + than the build version and still resident is kept, so a restart on an older image never + downgrades a hotfix (versions compare numerically). Because the flat DAWGs carry no + embedded version, `OpenWithVersions` records the version the flat directory was first + seeded at in a `.seed_version` marker and treats it as authoritative for **that flat + directory** (the **seed-drift guard**): a later `BACKEND_DICT_VERSION` never relabels + the already-seeded flat bytes — it is delivered as a **new subdirectory** instead — so + the guard still prevents mis-serving a game pinned to the flat label. The console + remains the way to update a dictionary **without** a redeploy (an out-of-band upload). + Set `DICT_VERSION` per contour from the deploy's `TEST_`/`PROD_DICT_VERSION`. (The + dictionaries ship as a versioned **release artifact** from the `scrabble-dictionary` + repo.) - Move generation/validation/scoring use `Solver.GenerateMoves` (ranked), `Solver.ValidatePlay` and `Solver.ScorePlay`; board mutation uses `scrabble.Apply`. The engine adds its own deterministic, seeded tile **bag**