Merge pull request 'feat(engine,deploy): seed-drift guard + track current dictionary release (v1.2.1)' (#92) from feature/dict-version-track-release into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 16s
CI / ui (push) Successful in 53s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m4s

This commit was merged in pull request #92.
This commit is contained in:
2026-06-20 17:39:02 +00:00
12 changed files with 164 additions and 24 deletions
+7 -2
View File
@@ -26,6 +26,13 @@ on:
push:
branches: [development]
# The dictionary release the test suite validates against — the current
# scrabble-dictionary release. Centralised here so a release bump is one edit; the
# unit/integration jobs inherit it. The deploy job overrides it per contour with
# vars.TEST_DICT_VERSION (the seed for a fresh volume), see deploy/README.md.
env:
DICT_VERSION: v1.2.1
jobs:
# changes detects which areas a PR/push touched, so the test jobs can skip when
# irrelevant. It defaults to running everything when the diff cannot be computed.
@@ -86,7 +93,6 @@ jobs:
# The engine consumes the published scrabble-solver module from this Gitea;
# GOPRIVATE makes go fetch it directly (skipping the public proxy/checksum DB).
GOPRIVATE: gitea.iliadenisov.ru/*
DICT_VERSION: v1.0.0
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -134,7 +140,6 @@ jobs:
# suite's TestMain terminates its own container, so disable it.
TESTCONTAINERS_RYUK_DISABLED: "true"
GOPRIVATE: gitea.iliadenisov.ru/*
DICT_VERSION: v1.0.0
steps:
- name: Checkout
uses: actions/checkout@v4
+1
View File
@@ -38,6 +38,7 @@ the edge before prod. Each phase maps back to the owner's raw pre-release TODO l
| BX | Asymmetric per-user block + in-game controls: a block now silently suppresses everything **from** the blocked user (chat, nudge, friend requests, invitations are kept but never delivered/surfaced, born-read) while they notice nothing, **without** deleting the friendship (unblock restores it); auto-match excludes a block-related pair (either direction); in-game opponent card gains a ✖️ **block** control (mirroring 🤝, red "Block?" confirm, mutual-hide, struck name + hidden chat composer when blocked); optimistic apply + `user_blocked`/`user_unblocked` event confirm + rollback; admin user card gains **blocks / blocked-by / friends** cross-linked lists. Blocking a disguised-robot opponent is recorded per-game in a separate **`robot_blocks`** table (migration `00011`), keyed on game+seat with the seen name — never the shared robot account — so the matchmaker keeps giving robots; it shows in the blocked list and re-marks the in-game card | owner ad-hoc | **done** |
| FM | First-move tile draw (official rules): each seated player draws a tile, the one closest to "A" leads (a blank beats every letter), ties re-drawing until a single leader; **honest per-draw `crypto/rand` entropy**, not the bag seed, so the **record** (`game_setup_draws`, migration `00013`) — not a seed — is the only account of the outcome, kept for future **tournaments** (designed as a discrete per-tile "player N draws" step). Friend/AI draws at create; **auto-match draws at *open*** against a synthetic `uuid.Nil` opponent whose draw rows are back-filled on join, so the opener's seat is fixed up front and the existing open-game pre-move is preserved (no reseating, no play-gating). Admin `/_gm/games/:id` gains the recorded draw list + a simple **step-by-step board replay** (`ReplayTimeline`). | owner ad-hoc | **done** |
| SB | Single Telegram bot + per-user variant preferences: the two per-language bots collapse into **one** (drop `accounts.service_language`, `supported_languages`, the `*_EN`/`*_RU` env vars and game-language push routing — the single bot renders in the recipient's `preferred_language`); New Game variant gating moves to a profile **`variant_preferences`** set (default Erudit only, Erudit-first, server-enforced on the caller's auto-match/vs-AI/invitation-create paths, an invited friend may accept any variant); env vars collapse to unsuffixed `TELEGRAM_BOT_TOKEN`/`TELEGRAM_GAME_CHANNEL_ID`/`VITE_TELEGRAM_LINK`/`VITE_TELEGRAM_GAME_CHANNEL_NAME` and `GATEWAY_DEFAULT_SUPPORTED_LANGUAGES` is removed; wire drops `service_language`/`supported_languages` (Session, ValidateInitDataResponse) + the push `language` routing field and adds `variant_preferences` to Profile/UpdateProfile. | owner ad-hoc | **done** |
| DV | Dictionary version hygiene: CI + image/compose seed track the current release (`v1.2.1`); a **seed-drift guard** records the flat dir's seed in a `.seed_version` marker and refuses to boot when a bumped build seed would relabel a live volume (silently serving the wrong dictionary + voiding games pinned to the prior label); `DICT_VERSION` is the fresh-volume seed only, a live contour migrates through the admin console | owner ad-hoc | **done** |
| → | Stage 18 — prod contour deploy | — | see [`PLAN.md`](PLAN.md) |
## Key findings (these reshaped the raw list — read before starting a phase)
+2 -2
View File
@@ -12,7 +12,7 @@
# --- dictionary artifact -----------------------------------------------------
FROM alpine:3.20 AS dawg
ARG DICT_VERSION=v1.0.0
ARG DICT_VERSION=v1.2.1
RUN apk add --no-cache curl tar
RUN mkdir -p /dawg \
&& curl -fsSL -o /tmp/dawg.tar.gz \
@@ -40,7 +40,7 @@ FROM gcr.io/distroless/static-debian12:nonroot
# Re-declare the build arg in this stage so it labels the seed dictionary. One
# DICT_VERSION drives both the artifact the dawg stage downloads and the version
# label the binary pins, so the resident version equals the release tag.
ARG DICT_VERSION=v1.0.0
ARG DICT_VERSION=v1.2.1
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
+9 -3
View File
@@ -190,7 +190,7 @@ internal/ratewatch/ # gateway rate-limit reports: episode window for the consol
| `BACKEND_OTEL_TRACES_EXPORTER` | `none` | `none`, `stdout` or `otlp` (gRPC; endpoint from the standard `OTEL_EXPORTER_OTLP_*`). |
| `BACKEND_OTEL_METRICS_EXPORTER` | `none` | `none`, `stdout` or `otlp`. |
| `BACKEND_DICT_DIR` | — | **Required.** Directory of committed `.dawg` dictionaries. |
| `BACKEND_DICT_VERSION` | `v1` | Dictionary version new games pin. |
| `BACKEND_DICT_VERSION` | `v1` | Seed dictionary version new games pin (the flat dir's label). Recorded in a `.seed_version` marker on first boot; the backend refuses to start if a later value drifts from it — the seed-drift guard (ARCHITECTURE.md §5). |
| `BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL` | `1m` | How often the turn-timeout sweeper runs. |
| `BACKEND_GAME_CACHE_TTL` | `24h` | Idle window before a live game is evicted from cache. |
| `BACKEND_LOBBY_ROBOT_WAIT` | `10s` | Auto-match wait before a robot is substituted for a missing human. |
@@ -212,7 +212,7 @@ internal/ratewatch/ # gateway rate-limit reports: episode window for the consol
```sh
docker run -d --name scrabble-pg -e POSTGRES_PASSWORD=dev -p 5432:5432 postgres:17-alpine
# DAWGs: extract the dictionary release artifact (or point at a local scrabble-solver/dawg):
mkdir -p /tmp/dawg && curl -fsSL https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/v1.0.0/scrabble-dawg-v1.0.0.tar.gz | tar xz -C /tmp/dawg
mkdir -p /tmp/dawg && curl -fsSL https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/v1.2.1/scrabble-dawg-v1.2.1.tar.gz | tar xz -C /tmp/dawg
BACKEND_POSTGRES_DSN='postgres://postgres:dev@localhost:5432/postgres?search_path=backend&sslmode=disable' \
BACKEND_DICT_DIR=/tmp/dawg \
GOPRIVATE='gitea.iliadenisov.ru/*' \
@@ -251,7 +251,13 @@ local solver co-development you may add a temporary replace — see `go.work`).
from the [`scrabble-dictionary`](https://gitea.iliadenisov.ru/developer/scrabble-dictionary)
repo (one semver per set); the engine loads them by `(variant, dict_version)` from
`BACKEND_DICT_DIR`. The backend loads them at startup as a hard dependency
(a missing dictionary aborts the boot).
(a missing dictionary aborts the boot). The flat directory is the seed version,
labelled `BACKEND_DICT_VERSION`; uploaded versions live in `<version>/`
subdirectories the admin console writes and a restart re-loads. Because the DAWGs
carry no embedded version, the first boot records the seed in a `.seed_version`
marker and a later boot **refuses to start** if `BACKEND_DICT_VERSION` no longer
matches it (the seed-drift guard), so a live contour's dictionary is changed through
the console, never by bumping the build seed (ARCHITECTURE.md §5).
## Tests
+10 -2
View File
@@ -71,13 +71,21 @@ func Open(dir, version string, variants ...Variant) (*Registry, error) {
// version V, the variants whose committed DAWG it carries. This is the
// restart-side of the admin dictionary reload — a version reloaded into dir/<V>/
// at runtime is resident again after a restart. A subdirectory named like the
// boot version is skipped (the flat dir already is the boot version). A partially
// loaded registry is closed before any error is returned.
// boot version is skipped (the flat dir already is the boot version). It records
// and enforces a seed-version marker on the flat dir (see checkSeedMarker), failing
// when the build seed was bumped on an already-seeded volume. A partially loaded
// registry is closed before any error is returned.
func OpenWithVersions(dir, bootVersion string) (*Registry, error) {
r, err := Open(dir, bootVersion)
if err != nil {
return nil, err
}
// Guard the seed-drift footgun before scanning for additional versions: refuse
// to boot when the flat dir was seeded under a different version than bootVersion.
if err := checkSeedMarker(dir, bootVersion); err != nil {
_ = r.Close()
return nil, err
}
entries, err := os.ReadDir(dir)
if err != nil {
_ = r.Close()
+54
View File
@@ -5,6 +5,7 @@ import (
"io"
"os"
"path/filepath"
"strings"
"testing"
)
@@ -112,6 +113,59 @@ func TestOpenWithVersionsSkipsDotDirs(t *testing.T) {
}
}
// TestOpenWithVersionsRecordsSeedMarker verifies the first boot records the seed
// version in the flat dir's marker, the marker is not mistaken for a version, and a
// reboot at the same seed version succeeds.
func TestOpenWithVersionsRecordsSeedMarker(t *testing.T) {
dir := t.TempDir()
for _, v := range Variants() {
copyDawg(t, testDictDir(), dir, v)
}
reg, err := OpenWithVersions(dir, "v1")
if err != nil {
t.Fatalf("first open: %v", err)
}
if got := reg.Versions(VariantEnglish); len(got) != 1 || got[0] != "v1" {
t.Errorf("versions = %v, want only [v1] (marker not a version)", got)
}
_ = reg.Close()
data, err := os.ReadFile(filepath.Join(dir, seedMarkerFile))
if err != nil {
t.Fatalf("read seed marker: %v", err)
}
if got := strings.TrimSpace(string(data)); got != "v1" {
t.Fatalf("seed marker = %q, want v1", got)
}
reg2, err := OpenWithVersions(dir, "v1")
if err != nil {
t.Fatalf("reboot at same seed: %v", err)
}
_ = reg2.Close()
}
// TestOpenWithVersionsRejectsSeedDrift verifies the guard refuses to boot a directory
// seeded as one version when BACKEND_DICT_VERSION (bootVersion) names another — the
// seed-drift footgun a bumped build seed on a live volume would cause.
func TestOpenWithVersionsRejectsSeedDrift(t *testing.T) {
dir := t.TempDir()
for _, v := range Variants() {
copyDawg(t, testDictDir(), dir, v)
}
reg, err := OpenWithVersions(dir, "v1")
if err != nil {
t.Fatalf("seed open: %v", err)
}
_ = reg.Close()
if _, err := OpenWithVersions(dir, "v2"); err == nil {
t.Fatal("open after seed bump: want error, got nil")
}
}
// TestReloadRegistersNewVersion verifies Load adds a second version to a variant
// already resident, moves the latest pointer and keeps the earlier version.
func TestReloadRegistersNewVersion(t *testing.T) {
+52
View File
@@ -0,0 +1,52 @@
package engine
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
)
// seedMarkerFile names the file, in the flat dictionary directory, that records the
// version the directory was first seeded as. It is dot-prefixed so OpenWithVersions'
// version scan skips it (like the .staging upload area).
const seedMarkerFile = ".seed_version"
// checkSeedMarker reconciles the flat dictionary directory's recorded seed version
// with bootVersion, guarding the seed-drift footgun.
//
// The contour's dictionary lives on a named volume seeded from the image once and
// never re-seeded (deploy/docker-compose.yml). The flat directory's DAWG files carry
// no embedded version, so their version is only the label BACKEND_DICT_VERSION gives
// them. Bumping the build seed on a live volume would therefore relabel the already
// seeded bytes: games that pinned the old label become unreplayable (voided) and new
// games would silently use the wrong dictionary. checkSeedMarker records the seed
// version on a fresh directory and, on every later boot, returns an error when
// bootVersion no longer matches the recorded seed — so an operator changes a live
// dictionary by uploading the new release through the admin console, or wipes the
// volume to re-seed, never by bumping the build seed (docs/ARCHITECTURE.md §5).
//
// A directory that cannot be written makes the first record fail; that already breaks
// the admin console (which writes version subdirectories here), so the error is
// returned rather than swallowed, matching the package's fail-loud dictionary setup.
func checkSeedMarker(dir, bootVersion string) error {
path := filepath.Join(dir, seedMarkerFile)
data, err := os.ReadFile(path)
switch {
case err == nil:
if recorded := strings.TrimSpace(string(data)); recorded != bootVersion {
return fmt.Errorf("engine: dictionary volume was seeded as %q but BACKEND_DICT_VERSION is %q; "+
"change a live dictionary by uploading the release through the admin console, or wipe the "+
"volume to re-seed — do not bump the build seed (docs/ARCHITECTURE.md §5)", recorded, bootVersion)
}
return nil
case errors.Is(err, os.ErrNotExist):
if err := os.WriteFile(path, []byte(bootVersion+"\n"), 0o644); err != nil {
return fmt.Errorf("engine: record dictionary seed marker %s: %w", path, err)
}
return nil
default:
return fmt.Errorf("engine: read dictionary seed marker %s: %w", path, err)
}
}
+7 -6
View File
@@ -10,12 +10,13 @@ POSTGRES_USER=scrabble
POSTGRES_PASSWORD=change-me # required
# --- Dictionary -------------------------------------------------------------
# scrabble-dictionary release tag baked into the image as the SEED dictionary
# (image build-arg; also labels the resident seed version). After first boot the
# dawg-data volume preserves versions uploaded through the admin console, and the
# active version lives in the DB — so bumping this on a later deploy does NOT change
# a running contour; update the dictionary through /_gm/dictionary instead.
DICT_VERSION=v1.0.0
# scrabble-dictionary release tag baked into the image as the SEED dictionary for a
# FRESH volume (image build-arg; also labels the resident seed version). After first
# boot the dawg-data volume preserves versions uploaded through the admin console and
# the active version lives in the DB. On a live volume the backend refuses to start if
# this no longer matches the recorded seed (the seed-drift guard) — change a running
# contour's dictionary through /_gm/dictionary, not by bumping this (ARCHITECTURE.md §5).
DICT_VERSION=v1.2.1
# --- Logging ----------------------------------------------------------------
LOG_LEVEL=info
+1 -1
View File
@@ -71,7 +71,7 @@ compose, but the connector **fails at boot** when it is empty.
| --- | --- | --- | --- |
| `POSTGRES_DB` | variable | `scrabble` | Database name. |
| `POSTGRES_USER` | variable | `scrabble` | Database user. |
| `DICT_VERSION` | variable | `v1.0.0` | `scrabble-dictionary` release tag baked into the backend image (build-arg). |
| `DICT_VERSION` | variable | `v1.2.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; the backend refuses to boot if it drifts from the volume's recorded `.seed_version` (the seed-drift guard, ARCHITECTURE.md §5). Set per contour as `TEST_`/`PROD_DICT_VERSION`. |
| `LOG_LEVEL` | variable | `info` | Shared log level for backend / gateway / connector (`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. |
+8 -4
View File
@@ -66,7 +66,9 @@ services:
context: ..
dockerfile: backend/Dockerfile
args:
DICT_VERSION: ${DICT_VERSION:-v1.0.0}
# Seed dictionary for a FRESH volume; the per-contour value comes from the
# deploy env (Gitea TEST_/PROD_DICT_VERSION). See the volume note below.
DICT_VERSION: ${DICT_VERSION:-v1.2.1}
restart: unless-stopped
logging: *default-logging
depends_on:
@@ -94,9 +96,11 @@ services:
# (the image's /opt/dawg is owned by the nonroot UID, which the fresh volume
# inherits). The admin console writes new version subdirectories here, and the
# volume preserves them — and the versions in-progress games pin — across
# redeploys. Once seeded the volume is not re-seeded, so bumping DICT_VERSION on a
# later deploy has no effect on an existing contour; update via the console
# instead (docs/ARCHITECTURE.md §5).
# redeploys. Once seeded the volume is not re-seeded: DICT_VERSION is the seed for
# a FRESH volume only. On a live volume the backend refuses to start if DICT_VERSION
# no longer matches the recorded seed (the seed-drift guard), so a running contour's
# dictionary is changed through the admin console, never by bumping the seed
# (docs/ARCHITECTURE.md §5).
volumes:
- dawg-data:/opt/dawg
# No container healthcheck: the distroless image has no shell/wget. Readiness
+12 -3
View File
@@ -273,9 +273,18 @@ Key points:
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. (The dictionaries ship as a versioned
**release artifact** from the `scrabble-dictionary` repo; the build's
`DICT_VERSION` selects only the seed.)
the console rather than a rebuild. Because the flat DAWGs carry no embedded
version, `OpenWithVersions` records the version the flat directory was first
opened at in a `.seed_version` marker on the volume and **refuses to boot** when a
later `BACKEND_DICT_VERSION` disagrees — the **seed-drift guard**: it stops a
bumped build seed on a live volume from relabelling the already-seeded bytes,
which would 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); `DICT_VERSION` is
the seed for a **fresh** volume only, set 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.)
- 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**
+1 -1
View File
@@ -12,7 +12,7 @@
# --- dictionary artifact -----------------------------------------------------
FROM alpine:3.20 AS dawg
ARG DICT_VERSION=v1.0.0
ARG DICT_VERSION=v1.2.1
RUN apk add --no-cache curl tar
RUN mkdir -p /dawg \
&& curl -fsSL -o /tmp/dawg.tar.gz \