67 KiB
Scrabble Game — implementation plan
Living plan and stage tracker. Each stage is implemented in its own session;
the rules for starting and finishing a stage are in CLAUDE.md.
The architecture/decision record is docs/ARCHITECTURE.md;
behaviour is docs/FUNCTIONAL.md. When a stage produces a
decision, bake it back here and into the affected docs/code in the same PR.
Context
Greenfield multiplatform Scrabble. Players arrive from a platform (Telegram
first; later VK/MAX/iOS/Android) or standalone web (email / guest). Three
executables — gateway, backend, ui — plus per-platform side-services.
Deliberately simpler than the sibling ../galaxy-game (idea donor, not a
template). The ../scrabble-solver engine is embedded in-process as a library.
Locked decisions (recap — full record in docs/ARCHITECTURE.md)
Stack: go.work monorepo, modules scrabble/<name>, Go 1.26.x, backend
gin+pgx+Postgres(schema backend)+goose+zap+OTel (deps added when first used).
Wire: Connect-RPC + FlatBuffers (client↔gateway), REST/JSON + X-User-ID
(gateway↔backend), gRPC server-stream for live events. Auth: platform-native,
thin opaque session token, no Ed25519/signing, likely no Redis. UI: pure
HTML5/CSS, plain Svelte + Vite, Capacitor for native. MVP surfaces: Telegram +
web (email + ephemeral guest) + link/merge. Variants: ru/en/Эрудит.
Legality: validate-at-submit. End: empty bag+rack / 6 scoreless / 24h timeout.
Hint: top-1. Word-check: unlimited + complaint. Robot: P(win)≈0.40, margin
targeting, [2,90]min skewed timing, sleep 00:00–07:00 opp-tz, nudge logic.
Dictionary: pin per game. History: structured + GCG export, dictionary-
independent (see ARCHITECTURE §9.1).
Stage tracker
| # | Stage | Status |
|---|---|---|
| 0 | Scaffolding (go.work, backend skeleton, docs, CI) | done |
| 1 | Backend foundation (config, server, Postgres+goose, sessions, accounts) | done |
| 2 | Engine package over scrabble-solver | done |
| 3 | Game domain (lifecycle, rules, hint, word-check, history+GCG, stats) | done |
| 4 | Lobby & social (matchmaking, friends, block, chat, profile, nudge) | done |
| 5 | Robot opponent | done |
| 6 | Gateway edge (Connect/FB, platform auth, sessions, push bridge, admin) | done |
| 7 | UI — playable slice + UX polish (Svelte+Vite, board, lobby, chat, hint/word-check, i18n) | done |
| 8 | UI — social/account/history (friends, blocks, invitations, profile edit, stats, history/GCG) | done |
| 9 | Telegram integration (bot side-service, deep-link, push) | done |
| 10 | Admin & dictionary ops (complaint review, version reload) | done |
| 11 | Account linking & merge | done |
| 12 | Observability & performance (telemetry, metrics, guest GC) | done |
| 13 | Alphabet on the wire (UI alphabet-agnostic) | todo |
| 14 | CI & deploy (multi-service, dictionary artifacts) | todo |
Scaffolding is incremental: go.work lists only existing modules; each stage
adds the modules it needs.
Stages
Each stage: read this plan + relevant docs, interview the owner on the open details below, implement within scope, then update plan/docs/code and get CI green before marking done.
Stage 0 — Scaffolding (done)
Scope: go.work (Go 1.26.3, use ./backend); minimal runnable backend
(gin, zap, /healthz, /readyz, env config); docs skeleton; PLAN.md;
CLAUDE.md; .gitea/workflows/go-unit.yaml; README; .gitignore.
Acceptance: go build ./backend/... + go vet + gofmt clean +
go test ./backend/... green; CI green on push.
Stage 1 — Backend foundation
Scope: config/server route groups (/api/v1/{public,user,internal,admin},
probes), Postgres (pgx) + embedded goose migrations + schema backend,
telemetry (OTel) wiring, in-memory cache scaffolding, thin sessions + accounts +
platform identities.
Open details: Postgres version + DSN/search_path convention; jet vs
sqlc/sqlx (default jet); migration naming; exact session-token shape (opaque
random length, TTL, revocation); account/identity table shape; whether the
admin bootstrap lands here or in Stage 10.
Stage 2 — Engine package
Scope: backend/internal/engine over scrabble-solver — versioned DAWG
load/registry, GenerateMoves/ValidatePlay/ScorePlay wrappers, bag/rack, the
dictionary-independent game-state model + decode helpers. Add
replace scrabble-solver => ../scrabble-solver to go.work here and solve the
CI sibling-checkout (clone gitea.iliadenisov.ru/.../scrabble-solver).
Open details: how CI obtains the solver (clone sibling vs publish/tag the
solver module); in-memory game-state representation; how blanks and exchanges
are modelled; Эрудит specifics to verify against the solver.
Stage 3 — Game domain
Scope: create/join, turn order, submit play/pass/exchange/resign, validate-at-submit, scoring, end-conditions, 24h timeout/auto-resign, hint, word-check + complaint capture, structured history + GCG writer, stats on finish. Open details: GCG dialect details (blanks, exchanges, notation); exact stats edge cases; turn-timeout scheduler mechanism (cron vs per-game timer); complaint payload shape.
Stage 4 — Lobby & social
Scope: matchmaking pool, friends, block, per-game chat, profile + email confirm-code, nudge. Open details: pool fairness/keying confirmation; deep-link format per platform; chat length limit + retention; friend-request lifecycle; email-code provider (SMTP relay choice).
Stage 5 — Robot opponent
Scope: human-like player — balance ~0.40, margin targeting, skewed [2,90]min timing + sleep + nudge logic, friend/DM blocking, name pool. Open details: exact delay distribution + parameters; margin band; name pool source; how the scheduler drives robot moves; metrics for tuning balance.
Stage 6 — Gateway edge
Scope: Connect/gRPC-Web (h2c), Telegram initData validation → session →
X-User-ID, in-memory rate-limit, admin Basic-Auth passthrough, FlatBuffers
transcoding, in-app push stream bridging backend push gRPC stream, email +
ephemeral-guest paths.
Open details: FlatBuffers schema layout + message_type catalog; rate-limit
classes/limits; admin surface routing; session cache shape at the gateway.
Stage 7 — UI
Scope: plain Svelte + Vite static; Connect-web + FlatBuffers client; lobby (my games, profile tabs); board (HTML5/CSS grid, drag-n-drop, no assets); chat; hint/word-check; in-app stream; i18n en/ru; in-memory session (+IndexedDB if available); Capacitor-ready structure. Open details: detailed game-board UX (deferred by the owner to this stage); client routing; offline/refresh behaviour; design system / theming.
Suggested layouts (lobby + game screen)
User note:
Detailed interview about UI/UX is strongly required. Too much to discuss.
┌────────────────────┐
│ Display_Name =│- Profile
├────────────────────┤- Settings
│ Invitations │- About
│ - list │
├────────────────────┤
│ Active games │
│ - list │
├────────────────────┤
│ Finished games │
│ - list │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
├────────────────────┤
│ ┌───┐ ┌───┐ ┌───┐│
│ New │ Stats Tourn│
│ └───┘ └───┘ └───┘│
└────────────────────┘
┌────────────────────┐
Lobby│◄ ==│- History
├────────────────────┤- Chat
│You Ann Kaya Rick│- Check word
│136 700 179 39│- Drop game
├────────────────────┤
│ │
│ │
│ │
│ c │
│ words │
│ o │
│ s │
│ s │
│ │
│ │
├──┬──┬──┬──┬──┬──┬──┤ ┌──┐
│A │Q │Z │* │N │I │W │◄│ │MakeMove/Reset
├──┴──┴──┴──┴──┴──┴──┤ └──┘
│ ┌───┐ ┌───┐ ┌───┐ │
│ Draw│ Skip│ Shfl│ │
│ └───┘ └───┘ └───┘ │
└────────────────────┘
Stage 8 — UI: social, account & history surfaces
Scope: the UI surfaces deferred from Stage 7's playable slice, wiring the matching
backend/gateway operations as each screen needs them (the Stage 6 vertical-slice
pattern): friends (request/accept/decline/list), per-user blocks, friend-game
invitations (create 2–4 player, accept/decline, invitations list), profile editing
(account.UpdateProfile + the email confirm-code binding UI), the statistics screen,
and the history viewer with GCG export/download.
Open details: friends/invitations UX; stats presentation; history/GCG viewer + download
mechanics; any new validation the profile-editing forms need.
Stage 9 — Telegram integration
Scope: bot side-service, deep-link invites, platform push (your-turn / nudge), Mini App launch/auth; backend↔platform internal API. Open details: bot framework/library; deep-link scheme; push message templates; internal API contract; Mini App hosting/origin.
Stage 10 — Admin & dictionary ops
Scope: admin endpoints (users, games, complaint review queue, dictionary versions + reload), complaint→dictionary update pipeline. Open details: whether a server-rendered console is wanted or JSON-only; the dictionary rebuild/deploy pipeline; complaint resolution workflow.
Stage 11 — Account linking & merge
Scope: link-via-confirm; merge-into-A (stats sum, transfer games/friends, dedupe). High blast-radius — focused regression tests. Open details: conflict resolution (active games on both, duplicate friends, display-name collisions); irreversibility/audit; confirm-flow per platform.
Stage 12 — Observability & performance
Scope: wire a configurable OTLP exporter (alongside none/stdout), shared in a
new pkg/telemetry; add telemetry to the gateway and the Telegram connector
(providers + otelgrpc on the gRPC hops) for parity with the backend; add
domain/operational metrics close to the business (game replay/validate timings,
started/abandoned games, live-cache size, chat/nudge counts, the edge roundtrip, Go
runtime metrics); discharge TODO-3 (abandoned-guest GC). The OTLP collector and
dashboards are stood up with the deploy (Stage 14); the default exporter stays none,
so CI needs no collector. Performance is operational-metric instrumentation, not
speculative optimisation (the standing "evidence first" rule — no measured hotspot yet).
Open details: exporter default and whether a collector is stood up now; the metric set
and its attributes; the guest-reaper trigger given revoke-only sessions.
Stage 13 — Alphabet on the wire (TODO-4)
Scope: make the UI alphabet-agnostic. On game-screen load the client receives the
variant's alphabet table (letter, index, value) for display only, caches it in
memory by variant (a request flag gates whether the table is included, so it is not
resent on every state poll); live play then exchanges letter indices both ways, and
word-check sends indices, constraining input to the variant's alphabet. The engine
already works in alphabet-index bytes, so the wire does less decoding in live play; the
durable journal / history / GCG stay decoded concrete characters (the §9.1
dictionary-independent invariant is untouched). The alphabet comes from the solver's
rules (not the DAWG), so the wire table is pinned by the solver version. Index-drift
caveat: the running solver, the DAWGs (built against it — Stage 14 / TODO-2) and the
wire table must agree, or letter indexing silently corrupts. Blast radius: pkg/fbs
(a new Alphabet table; index fields in StateView/rack and in
SubmitPlay/Exchange/check_word) → backend DTO encode/decode → UI
codec.ts/premiums.ts → board/rack render, the move/exchange/word-check senders, the
mock transport and the Vitest tests.
Open details: the fbs shape and include_alphabet flag placement; whether to keep
concrete-letter fields during the transition; whether tile exchange moves fully to
indices; the premiums.ts parity-test rework.
Stage 14 — CI & deploy
Scope: the full multi-service production deploy plus the observability backend, also
discharging TODO-1 and TODO-2. Backend + gateway Dockerfiles (multi-stage
distroless, mirroring the Stage 9 connector image); the gateway gains static UI
serving (the §13 single-origin model — mini-landing at /, Mini App under
/telegram/), documented since Stage 9 but not yet implemented; prod UI build vars
(VITE_TELEGRAM_BOT_ID for the Login Widget, the Mini App URL / share link); a root
deploy/docker-compose.yml (backend + gateway + Postgres + connector + the OTLP
collector / Grafana stack) on the external edge network behind the host caddy, the VPN
sidecar only for the connector; a deploy workflow mirroring ../15-puzzle (host-mode
runner, docker compose up -d --build, no external registry, env from Gitea secrets, a
post-deploy probe). Stand up the OTLP collector + dashboards (the export wiring landed
in Stage 12).
- TODO-1 — publish & version the solver: tag/publish
scrabble-solver, drop thego.workreplace + the CI clone, pin a version inbackend/go.mod(or keep cloning the sibling as the minimal-diff fallback). The DAWGs are delivered separately regardless. - TODO-2 — versioned dictionary artifacts: a new versioned repo for the wordlist
parsers + built DAWGs, delivered as a release artifact (Gitea release / OCI / object
store — not
go get; DAWGs are data). One semver labelvX.Y.Zfor the whole set, additive: a deploy drops a newBACKEND_DICT_DIR/<version>/subdir;engine.OpenWithVersionsloads every present subdir at boot;BACKEND_DICT_VERSIONselects the default for new games. A new version never breaks a running backend (each game pins itsdict_version; versions are additive); only active games need a dictionary (validate-at-submit — finished games replay the dictionary-independent journal), so a version is safe to retire once no active game pins it. The dict repo must build against the samedafsa/alphabet/solver the backend runs, or letter indexing drifts (ties into Stage 13). Open details: embed-vs-mount for the UI build and the DAWG set; the OTLP collector / dashboard stack; solver-publish vs clone-in-build; load expectations.
Refinements logged during implementation
-
Stage 0: solver
replacedeferred to Stage 2 (nothing imports it yet; adding the path now would break CI, which checks out only this repo). Docker / compose deferred to a stage that has something to deploy. Trunk ismaster(owner preference);feature/*+ PR from Stage 1; the genesis commit lands onmasterby necessity. -
Stage 1 (interview + implementation):
- Query layer: go-jet over
database/sql(pgx stdlib) + otelsql; acmd/jetgentool regenerates the committed code from a throwaway container. Postgres 17 pinned for jetgen, tests and prod. - Sessions: opaque token stored only as a SHA-256 hash (kept as hex
text, notbytea— avoids jet bytea-literal friction), revoke-only (no TTL); revocation-audit table deferred. Backend keeps a warmed write-through session cache that gates/readyz. - Data model: UUIDv7 PKs; one unified
identitiestable (kind ∈ telegram|email, widen tovk/maxlater); no soft-delete / actor-audit columns yet. - HTTP surface: service/store/cache layer only.
/api/v1/{public,user, internal,admin}groups +X-User-IDmiddleware are scaffolding (exposed viaServergroup accessors); the session/account REST handlers land with the gateway in Stage 6. Admin bootstrap deferred to Stage 10. - Telemetry: providers + request-timing middleware + otelsql; exporters
none(default) /stdout; OTLP + dashboards deferred to Stage 12. - Tests/CI: integration tests behind the
integrationbuild tag inbackend/internal/inttest+ newintegration.yaml(testcontainers, Ryuk off, serial), firing on push and PR. Backend now hard-depends on Postgres at boot (migrations at startup) — a deliberate contract change from Stage 0, documented in both READMEs. All code stays in the existingbackendmodule underinternal/(+cmd/jetgen);go.workuntouched.
- Query layer: go-jet over
-
Stage 2 (interview + implementation):
- Scope:
internal/engineis a self-contained library (registry, bag,Gamestate machine, decode/replay). Noconfig/main/serverwiring this stage — there is no consumer yet; wiring lands in Stage 3, mirroring Stage 1's deferred handlers. - Pure rules engine (interview): the engine owns the in-memory
Game, pure transitions (play/pass/exchange/resign + draw) and end-condition detection, including the standard end-game rack-adjustment scoring — a deliberate slice of Stage 3's "scoring/end-conditions" that the pure-engine boundary implies. Stage 3 keeps scheduling, the 24h timeout, persistence and GCG. - Solver wiring:
replace scrabble-solver => ../scrabble-solveringo.work;backend/go.modrequiresscrabble-solver(placeholder version, redirected by the replace) andgithub.com/iliadenisov/dafsadirectly (fordawg.Load). CI clones the public solver repo at master HEAD anonymously into../scrabble-solver(no token); both Go workflows gained the step (the engine's untagged tests run under the integration workflow too) and setBACKEND_DICT_DIR. - Dictionaries: registry loads the committed DAWGs from a directory
parameter;
dict_versionis an explicit string label; the latest version per variant is tracked. Smoke tests validate a known word per variant (English/Russian/Эрудит). Эрудит is handled uniformly — every real difference is already inrules.Erudit(); the move.go "single orientation per turn" note needs no special code (any single play is one-directional). - Bag/blanks/exchange: own deterministic
Bag(Draw + Return) becauseselfplay.Bagcannot return tiles; exchange is legal only when the bag holds at least a rack and draws replacements before returning the swapped tiles. A blank isPlacement{Blank:true}carrying its designated letter; the history keeps the concrete letter plus a blank flag (decoded viaAlphabet.Character/Decode).ReplayBoardreusesscrabble.Apply, so nointernal/encodingdependency. - Deviation from the approved plan:
docs/FUNCTIONAL.md(+_ru) was left unchanged. Stage 2 adds no user-visible behaviour; the variant, per-game dictionary and dictionary-independent-history user stories already live in Stages 3–4, so a "light touch" here would have duplicated or pre-empted them.
- Scope:
-
Stage 3 (interview + implementation):
- Scope, as in Stages 1–2: domain service/store layer + engine wiring, no
HTTP (
internal/game). The gateway↔backend REST surface lands in Stage 6; the only active driver this stage is a background turn-timeout sweeper started frommain. The robot (Stage 5) will consume the same service API. - Persistence = event-sourcing + warm cache (interview): durable state is
the
gamesrow plus an append-only decoded move journal (game_moves); the live position is anengine.Gamekept in an in-memory cache with a ~24h idle TTL and rebuilt by replaying the journal on a miss (the seeded bag makes replay exact). Each game is serialised by a per-game mutex; a persistence failure evicts the live game so the next access rebuilds. §9 reworded from "stored structurally" to this model. - Resign/timeout split (interview): 2-player resign/timeout only this stage
(the other player wins); multiplayer drop-out-and-continue + resigned-tiles
disposition deferred to Stage 4. Per-game turn-timeout duration setting
(5/10/15/30 min, 1/2/3/6/12/24 h; default 24 h) and a per-user away window
(
accounts.away_start/away_end, default 00:00–07:00 local, honoured by the sweeper with midnight-cross handling) added now; profile editing of the away window is Stage 4 and the robot's sleep (Stage 5) reuses it. - Engine
Resignfix (interview, ininternal/engine): the resigner keeps their accumulated score (no end-game rack adjustment) and never wins;winnerexcludes the resigner, so a two-player resign/timeout gives the win to the other player regardless of score. Timeout reusesResign, so the game domain needs no winner override. - Additive engine domain API:
Direction,Game.SubmitPlay/SubmitExchange/ EvaluatePlay/HintView/Hand,MoveRecord.{Dir,MainRow,MainCol},Registry.Lookup,ParseVariant— sointernal/gamenever importsscrabble-solver(keeps the §5 single-importer invariant). - Create = atomic with seats (interview):
Createseats all accounts and starts; lobby seat-filling is Stage 4. Sweeper = periodic goroutine (interview; default 60 s,BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL). - Hint = settings + wallet (interview): per-game
hints_allowed+hints_per_player, plus a profile walletaccounts.hint_balance(spent after the allowance; purchases later). Category defaults (random 1 / tournament 0 / friendly 1-or-0) are the caller's job (lobby/tournaments). - Stats (interview):
account_statswithdrawsadded beyond §9's wins/losses;max_word_points= best single move score; ties draw, resign/timeout is a loss, guests get no stats. - Complaint (interview): full payload with
game_id; word-check is scoped to the game's pinned(variant, dict_version). Stage 10 owns the resolution lifecycle, so thestatuscolumn carries no value CHECK yet. - GCG (interview): standard Poslfit dialect (UTF-8,
#player/#lexiconpragmas,8G/H8coordinates, lower-case blanks,.pass-throughs,-TILESexchange) plus#notelines for resign/timeout; derived from the journal, so dictionary-independent. - Engine wiring + config:
mainloads the registry (engine.Open, a hard boot dependency like migrations) and starts the sweeper. New config:BACKEND_DICT_DIR(required),BACKEND_DICT_VERSION(defaultv1),BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL(60 s),BACKEND_GAME_CACHE_TTL(24 h). No CI change — both Go workflows already clone the solver sibling and exportBACKEND_DICT_DIR.accountsgainedaway_start/away_end/hint_balanceand theaccountpackage gainedSpendHint(it owns its table).
- Scope, as in Stages 1–2: domain service/store layer + engine wiring, no
HTTP (
-
Stage 4 (interview + implementation):
- Scope, as in Stages 1–3: domain service/store layer, no HTTP — REST/stream
is Stage 6. Chat and nudges are persisted now; live delivery (push /
in-app stream) is Stage 6/8. New packages
internal/social(friends, blocks, chat+nudge) andinternal/lobby(matchmaking + invitations); profile editing and the email confirm-code extendinternal/account. The services have no active driver this stage, somainbuilds them and hands them to the server, which exposes them via accessors (the Stage 1 scaffolding-accessor pattern) for the Stage 6 handlers. - Friends (interview): request → accept on a single
friendshipstable; decline/cancel delete the pending row; blocking severs any friendship. - Blocks (interview): the existing global toggles plus a per-user
blockstable; block effects are mutual (a block either way suppresses chat visibility and prevents requests/invitations between the pair). - Friend games (interview): invitation → accept; the game starts only when all invitees accept, any decline cancels it, and a pending invitation lazily expires after 7 days (checked on access — no new sweeper).
- Chat (interview): ≤ 60 runes, stored with the game forever, the
sender IP kept for moderation (as
text, following Stage 1's no-byteaprecedent; the gateway forwards it in Stage 6), input content-filtered (links/emails/phone numbers incl. obfuscated forms) viamvdan.cc/xurls/v2plus a compact leet/separator normaliser and a ≥7-digit phone heuristic — the one new dependency. Nudge is a chat message (kind='nudge'), rate-limited to once per hour per game per sender. - Matchmaking (interview): an in-memory FIFO pool keyed by variant only (variant fixes the board language), pairing two humans (seat order randomised). The 10 s wait and robot substitution are deferred to Stage 5. The pool does not consult blocks (auto-match is anonymous) — a deliberate simplification of the plan's optional block-skip that also avoids a DB call under the pool lock.
- Email confirm-code (interview): 6-digit code, 15-min TTL, ≤ 5 attempts,
stored as a SHA-256 hash; a
Mailerseam with an SMTP relay (BACKEND_SMTP_*) and a default log mailer. It binds an email to the current account; an email already confirmed by another account →ErrEmailTaken(merge is Stage 11); email-as-login is Stage 6 and reuses this mechanism. - Multi-player drop-out (interview; discharges the Stage 3 deferral): the
engine's
Resignnow drops a seat and the rest play on while ≥ 2 are active, finishing (last-survivor wins) when one remains;winnerexcludes all resigned seats. A per-gamedropout_tilessetting (removedefault |return) governs the leaver's rack, which is never revealed to the others. Timeout reusesResign, so a multi-player timeout drops one seat and play continues;game.commit/timeoutGamewere already keyed ong.Over(), so they only needed the setting threaded through create/replay. - Build/deps:
go mod tidyis not run — the bare-pathscrabble-solverreplace lives only ingo.work, sotidy/go getcannot resolve it; thexurlsdependency was added withgo mod edit -require+go mod download, its checksums recorded in the committedgo.work.sum. No CI workflow change (both Go workflows already clone the solver sibling and exportBACKEND_DICT_DIR).
- Scope, as in Stages 1–3: domain service/store layer, no HTTP — REST/stream
is Stage 6. Chat and nudges are persisted now; live delivery (push /
in-app stream) is Stage 6/8. New packages
-
Stage 5 (interview + implementation):
- Scope, as in Stages 1–4: domain layer, no HTTP — the robot consumes the
public game API as an ordinary seated player (
internal/robot), so onlyinternal/enginestill imports the solver. New:engine.Candidates()(decoded ranked plays) and a thingame.Service.Candidates+RobotTurnsread. - Account model (interview): a pool of durable accounts, each a single
identitiesrowkind='robot'(migration00004widens the kind CHECK — a CHECK-only change, no jetgen). A curated ~16-name pool in code;EnsurePoolprovisions them idempotently at boot (a hard dependency, like the registry) withblock_chat/block_friend_requestsset, which is all the friend/DM blocking needs (no special-casing). - Driver + state (interview): a background sweeper goroutine
(
robot.Service.Run/Drive, mirroring the timeout sweeper); every per-game and per-turn choice is derived deterministically from the gameseed(FNV-1a mix, restart-stable — nothash/maphash), so the robot keeps no extra state.playToWin = mix(seed,"win")%100 < 40; per-turndelay; sleepdrift. - Timing (interview): per-move delay
2 + 88·u^kminutes,u~U(0,1), k≈3.5 → median ~10 min, clamped to [2,90]. A daytime nudge on the robot's turn pulls the move into a 2–10 min reply window; the robot proactively nudges after 12 h idle on the human's turn (reusingsocial.Nudge's once-per-hour guard;social.LastNudgeAtadded to detect the human's nudge). - Sleep (interview — resolves the §7-vs-
account.gomismatch): the robot sleeps 00:00–07:00 in the opponent's timezone shifted by a per-game drift ∈ [−3,+3]h (so its night overlaps the human's rather than running anti-phase), computed on the fly per game — no profile mutation, no concurrency cap. Theaccount.goaway-window comment was corrected accordingly. - Margin (interview): pick the candidate whose resulting margin (own+move−opp) is closest to [1,30] when playing to win / [−30,−1] when playing to lose, tie-broken toward the conservative edge; no legal play → exchange the full rack when the bag can refill it, else pass.
- Substitution (interview): a matchmaker reaper (
Reap/RunReaper) substitutes a pooled robot after a 10 s wait (BACKEND_LOBBY_ROBOT_WAIT),NewMatchmakernow takes aRobotProvider. A waiter learns of a match — human pairing or substitution — through a newPoll+ results map; production delivery is a match-found notification (session/in-app push + side-service), Stage 6/8 — noted in §10. - Metrics (interview, 1+2): robots are durable accounts, so
account_statsis the authoritative, complete balance ground-truth (target ~40% robot wins); an OTel counter (robot_games_finished_total, exporternonetoday) and a structured log cover robot-finished games for live observation. - Config:
BACKEND_ROBOT_DRIVE_INTERVAL(30 s),BACKEND_LOBBY_ROBOT_WAIT(10 s),BACKEND_LOBBY_REAPER_INTERVAL(1 s). No CI change (both Go workflows already clone the solver sibling and exportBACKEND_DICT_DIR).
- Scope, as in Stages 1–4: domain layer, no HTTP — the robot consumes the
public game API as an ordinary seated player (
-
Stage 6 (interview + implementation):
- Scope = framework + vertical slice (interview): the whole edge mechanism
is built and the backend's REST surface + the live-event seam are opened for
the first time, but only a representative slice of operations is wired
end-to-end — auth (
auth.telegram/auth.guest/auth.email.request/auth.email.login),profile.get,game.submit_play/game.state,lobby.enqueue/lobby.poll,chat.post, all five push events, and the admin passthrough. The remaining domain operations reuse the identical transcode pattern and are wired as the UI needs them: the play-loop ops (pass/exchange/ resign, hint, evaluate, word-check/complaint, history, my-games list, chat list/nudge) in Stage 7; the social/account ops (friends, blocks, invitations, profile editing, stats, GCG export) in Stage 8. - Wire contracts in a new shared
scrabble/pkgmodule (interview): the backend push proto (pkg/proto/push/v1) and the FlatBuffers edge payloads (pkg/fbs, onescrabblefbnamespace) live here with committed generated Go, imported by both backend and gateway. The Connect envelope proto lives ingateway/proto/edge/v1. Codegen is dev-time (buf generatewith local plugins +flatc, driven by per-moduleMakefiles, mirroringcmd/jetgen); CI only builds the committed output.pkgandgatewayare bare-path modules likescrabble-solver, sogo.workcarriesuse ./pkg,use ./gatewayand areplace scrabble/pkg v0.0.0 => ./pkg(the no-dot path is not VCS-fetchable); deps were added withgo mod edit+go work sync(the established no-tidy pattern).flatcis pinned to 23.5.26 to match theflatbuffersGo runtime. - Guest = durable account +
is_guest(interview): migration00005addsaccounts.is_guest; a guest is a durable row with no identity (so thesessions/game_playersforeign keys hold) that is excluded from statistics (the finish-time recompute skips guest seats) and from friends/history. The earlier "guests never reach this table" comments and §3/§9 were softened to "no profile/friends/stats persisted". Guest-row GC is a logged TODO (TODO-3). - Push = in-process
Publisher+ backend gRPC listener (interview): a newinternal/notifyhub (aPublisherinterface defaulting toNop, installed ongame/social/lobbyviaSetNotifierduring boot — additive, existing tests unchanged) is drained by a new backend gRPC server (internal/pushgrpc,BACKEND_GRPC_ADDRdefault:9090) servingPush.Subscribe. Emission lives ingame.commit(so robot-driver and timeout-sweep moves emityour_turn/opponent_movedtoo — the background sources a handler-only design would miss),social(chat_message/nudge) and the matchmaker (match_found). Event payloads are FlatBuffers-encoded in the backend (it importspkg/fbs); the gateway forwards them verbatim. Revoke/session-invalidation and cursor-resume are deferred (single-instance MVP). - Edge envelope = minimal, token in header (interview): the
GatewayConnect service isExecute(message_type, payload, request_id)+Subscribe; the session token rides inAuthorization: Bearer; auth ops are unauthenticated and return the token in the FlatBuffersSession. Domain outcomes ride back in theExecuteResponse.result_code(HTTP 200); only edge failures (rate limit, missing session, unknown type, internal) are Connect error codes. No Ed25519/signing (the galaxy donor's crypto stack was dropped, per §3). - Admin = gateway validates Basic-Auth (interview): the gateway checks
GATEWAY_ADMIN_USER/_PASSWORDand reverse-proxies to backend/api/v1/admin/*; the backend admin surface is a singlepinguntil Stage 10. - Rate-limit = 2 dimensions, 3 classes (interview): public per-IP (30/min,
burst 10), authenticated per-user (120/min, burst 40), admin per-IP (60/min,
burst 20), plus an email-code per-IP sub-limit (5/10 min); token bucket
(
golang.org/x/time/rate) with a lazy stale-bucket sweep. - Email-as-login (discharges the Stage 4 deferral):
account.EmailServicegainedRequestLoginCode/LoginWithCode, reusing the confirm-code mechanism but provisioning-or-finding the account by email identity (it does not refuse an already-confirmed address — that is the returning user). - CI: both Go workflows gained
gateway/**(andpkg/**where backend depends on it) path filters and now build/vet/test./backend/... ./pkg/... ./gateway/...(unit) — integration stays./backend/...(the only module with tagged tests). The solver clone +BACKEND_DICT_DIRsteps are unchanged.
- Scope = framework + vertical slice (interview): the whole edge mechanism
is built and the backend's REST surface + the live-event seam are opened for
the first time, but only a representative slice of operations is wired
end-to-end — auth (
-
Stage 7 (interview + implementation):
- Scope = playable slice (interview): the whole UI shell plus the core play
loop end-to-end; the social/account/history surfaces were split out into a new
Stage 8 and the later stages shifted +1 (Telegram→9, Admin→10, Linking→11,
Polish→12). Stage 7 wires only the operations the slice needs (the Stage 6
"as the UI needs them" pattern): the new gateway/transcode + backend-REST ops
games.list,game.{pass,exchange,resign,hint,evaluate,check_word,complaint, history},chat.{list,nudge}. The only new domain code isgame.ListForAccount(the "my games" query) and seatdisplay_nameresolution (server DTO layer);SeatViewgained a trailingdisplay_name. Friends/blocks/invitations, profile-editing, stats and the history/GCG viewer are Stage 8. - Stack (interview): plain Svelte 5 (runes) + TypeScript + Vite, no
SvelteKit;
@connectrpc/connect-web+ theflatbuffersruntime, with the edge TS bindings generated from the sameedge.proto(protoc-gen-es) andscrabble.fbs(flatc --ts) and committed underui/src/gen/(dev-time codegen, likecmd/jetgen/pkg/Makefile; CI builds the committed output). - No board on the wire (discovered):
StateViewcarries no grid, so the client replays the decoded move journal (game.history, newly wired) onto an empty board; premium squares + tile values are a client-side map ported fromscrabble-solver/rules/rules.gowith a Vitest parity test. - Board UX (interview): full-width, borderless; tiles placed by Pointer-Events drag or tap (no HTML5 DnD — it has no touch support); a contextual MakeMove control (short tap → make/reset popup, ~1 s press-and-hold → commit); per-tile recall by tapping a pending tile; a two-state zoom (15↔9 cells) on touch only (auto-zoom-in on placement, double-tap / pinch manual); a blank-letter chooser. All board/tiles/effects are pure HTML5/CSS + Unicode — no image/font/SVG asset.
- Theming (interview): own CSS custom-property tokens, light/dark via
prefers-color-scheme, Telegram-themeParams-ready (a runtime hook can override the tokens; the SDK is wired in the Telegram stage). Navigation (interview): dependency-free hash router; session token in memory + IndexedDB, re-resolved on reload (reopen Subscribe, refetch the open game); stream reconnect on focus. i18n en/ru is a hand-rolled typed catalog (compile-time key parity + a test). - Mock transport (owner request): a build-flagged in-memory fake (
VITE_MOCK,pnpm start) drives lobby → active game → board with no backend, tree-shaken out of production; it is the same fixture the Playwright smoke uses. - Tests/CI (interview): Vitest units (board replay, placement machine,
premium parity, i18n parity, FlatBuffers codec) + a Playwright smoke against
the mock; a new
ui-test.yamlworkflow (type-check, unit, build with a bundle-size budget — prod is ~67 KB gzip JS — and a chromium e2e). The Go workflows already cover the new backend/gateway/pkg code; agame.ListForAccountintegration test and gateway transcode tests for the new ops were added. - UX polish (follow-up PR): a mobile-app app shell (growing nav bar, content
pinned to the bottom) + a one-line announcement banner (client-side mock
rotation now; server-driven channel later — §10); a mobile-OS tab bar and a
reusable HoldConfirm press-and-hold control (MakeMove 🏁 + game-action confirms);
board zoom reworked to a width-based zoom in a fixed viewport (real native
scroll, double-tap; pinch/swipe dropped) with constant
cqwlabels, corner-letter tiles, contrasting grid lines, last-word dark-tile highlight, and a Settings bonus-label style (beginner/ classic/none); hint lays its tiles on the board (no spend when no move — a newno_hint_availableresult code); the history opens as an in-place slide-down (not a modal); word-check is alphabet/length-limited, cached and throttled. Design details live in the newdocs/UI_DESIGN.md.
- Scope = playable slice (interview): the whole UI shell plus the core play
loop end-to-end; the social/account/history surfaces were split out into a new
Stage 8 and the later stages shifted +1 (Telegram→9, Admin→10, Linking→11,
Polish→12). Stage 7 wires only the operations the slice needs (the Stage 6
"as the UI needs them" pattern): the new gateway/transcode + backend-REST ops
-
Stage 8 (interview + implementation):
- Scope = vertical slice continued: the social/account/history operations were
opened end-to-end (UI → gateway transcode → backend REST → existing domain
services). The only new backend logic is
lobby.ListInvitations,account.Store.GetStats, agame.SharedGameseam (self-join ongame_players), the friend-code mechanism, and the friendshipsdeclined-status change. - Friends — two add paths (interview, a deliberate plan change): one-time
friend codes (the player to be added issues a 6-digit numeric code, 12 h TTL,
SHA-256-hashed like email codes, single active per issuer, single-use, redeem
rate-limited) and a play-gated request (
SendFriendRequestnow requires a shared game — active or finished). An explicit decline is permanent (blocks re-send), an ignored request lazily expires after 30 days and may be re-sent, and a code from the same person bypasses a prior decline. This supersedes Stage 4's "declining/cancelling deletes the row" (cancel by the requester still deletes; decline now setsstatus='declined'). Migration 00006 widensfriendships_status_chkand addsfriend_codes(jetgen regen). No public ID or name search — discovery is codes + befriend-an-opponent. - Badges = poll + push (interview): a new generic
notifypush event (notify.KindNotification, sub-kinds friend_request/friend_added/invitation/ game_started) drives the lobby hamburger + "Friends" badge; emitted on friend- request and invitation create and on the invitation's game start. The client polls incoming requests + open invitations on lobby open and on focus (a missed push while hidden), and re-polls on thenotifyevent. Cursor-resume stays deferred (single-instance MVP, §10). - Language single-control (interview): the Settings language control writes
through to the durable account's
preferred_language(profile.update); guests keep only the client preference. Seeding the language from the platform/client on first provider login is a Stage 9 forward-note. - Guests = durable-only (interview): friends/blocks/invitations/statistics and history management are durable-account-only; a guest sees a sign-in prompt. Binding an email to an existing guest (account linking) stays Stage 11.
- GCG = finished-only + share (interview):
game.ExportGCGrefuses an active game (game.ErrGameActive) to avoid leaking the live journal mid-play; the client exports via the Web Share API where available, else a Blob download (game-<id>.gcg). Capacitor-native file save lands with the native wrapper. - IA = as the mockup (interview): Friends (friends + blocks) is its own screen from the lobby menu; Invitations is a lobby section + a "play with friends" mode in New game; Stats is a lobby tab-bar button; profile editing is on Profile; history + GCG stay in the game.
- Wire/codegen: new fbs tables (friends/blocks/invitations/profile-update/email-
bind/stats/gcg +
NotificationEvent;Profilegained trailing away fields) inpkg/fbs, regenerated to committed Go + TS; ~21 new gateway transcode ops; new REST handlers under/api/v1/user/{friends,blocks,invitations,profile,email,stats}and…/games/:id/gcg. UI grows to ~82 KB gzip JS (budget 100 KB). No CI workflow change (the Go and UI workflows already cover the new code). - UI polish (owner review follow-up): a copyable friend code (📋 + toast); the
lobby notification badge fixed (it had inherited the hamburger-bar style) and made
a proper count dot; Safari flex inputs given
min-width:0; profile-edit validation on both UI and backend — display-name format (letters + single␠/./_, ≤ 32 runes), a UTC-offset timezone picker (account.ResolveZoneparses±HH:MMor IANA; DST is traded for the simple picker), a 10-minute away grid capped at 12 h (wrap-aware), email format — with Save disabled and invalid fields red-bordered while any field is invalid; language stays in Settings; in a game, an "add to friends" item flips to a disabled "request sent"; chat send/nudge became ⬆️/🛎️ icons; a finished game drops its last-word highlight, hides Check word / Drop game, disables zoom, and draws an inert footer (greyed rack + tab bar) instead of hiding it. Two iPhone-simulator passes then made the chat and modals keyboard-aware (dvhplus avisualViewportlistener that sizes the modal backdrop to the area above the keyboard), reserved the rack height so a finished footer does not collapse, and compacted the play-with-friends form (a searchable bounded-scroll friend list, a pinned invite, and an explicit, required game type — a smart default is TODO-6). On the owner's call, every profile / new-game picker is a native<select>(the away window as hour + 10-minute selects, the timezone as a UTC-offset select): native time/wheel inputs render differently per OS and can't be forced to match, and a select also avoids the iOS "clear" button that would empty a time field.
- Scope = vertical slice continued: the social/account/history operations were
opened end-to-end (UI → gateway transcode → backend REST → existing domain
services). The only new backend logic is
-
Stage 9 (interview + implementation):
- Connector as its own container (interview): the Telegram side-service is a
standalone module
platform/telegram(binarycmd/telegram) holding the bot token only there; gateway and backend reach it by unauthenticated gRPC on the trusted internal network, and it egresses toapi.telegram.orgthrough a VPN sidecar (deploy/docker-compose.yml, mirroring../15-puzzle). Bot librarygithub.com/go-telegram/bot(one new dep), long-poll updates. - initData validation moved off the gateway (interview): the gateway's HMAC
validator was relocated into the connector (
internal/initdata, now also returninglanguage_code); the gateway callsconnector.ValidateInitDataover gRPC duringauth.telegram. The hop is negligible (loopback gRPC, once per login).GATEWAY_TELEGRAM_BOT_TOKENis gone;GATEWAY_CONNECTOR_ADDRreplaces it. Thegateway/internal/authpackage was deleted. - Connector gRPC API (
pkg/proto/telegram/v1, serviceTelegram): the generic methods are platform-agnostic, keyed by the identityexternal_id(so a future VK/MAX connector reuses them); onlyValidateInitDatais Telegram-specific. Methods:ValidateInitData,Notify(the out-of-app push — renders a localized message + a Mini App deep-link button from the FlatBuffers payload),SendToUserandSendToGameChannel(arbitrary admin messages — built and unit-tested now, wired to the admin surface in Stage 10; the game channel id lives only in connector config). - Push = fallback, gateway-routed, de-dup by presence (interview): the gateway
already consumes the firehose and knows in-app presence (
push.Hub.HasSubscribers), so it decides in-app vs out-of-app atomically: for a recipient with no live in-app stream it fetches a new backend/internal/push-target({external_id, language, notifications_in_app_only}) and callsconnector.Notifyonly when they have a Telegram identity and have not set the new flag. Push set:your_turn,nudge,match_found, and thenotifysub-kindsinvitation/friend_request(the connector skips the rest). Delivery runs in a goroutine so a slow connector never stalls the firehose; best-effort (no cursor resume — single instance, §10). - Profile flag
notifications_in_app_only(interview, default true → push is opt-in): migration00007(+ jetgen), threaded throughaccount.Profile/UpdateProfile, the REST DTOs, the fbsProfile/UpdateProfileRequest(defaulttruein the schema so an unset field reads conservatively), and a Profile-screen toggle. Flagged at review: the channel is silent until a user turns it off. - Language seeding from the platform (discharges the Stage 8 forward-note):
account.ProvisionTelegramseeds a brand-new account'spreferred_languagefrom the Telegramlanguage_codeand its display name fromfirst_name/username(existing accounts untouched); the UI'sadoptSessionalready adopts the server language when the user has not locked a locale, so no extra UI seeding was needed. The gateway forwards the fields fromValidateInitData. - Mini App =
/telegram/+ guard (interview): the gateway serves the one SPA build under/telegram/(Vite relative base; the hash router is path-agnostic). The UI detects a Telegram launch byTelegram.WebApp.initData, appliesthemeParams, authenticates via the existingauth.telegramop (UIauthTelegramcodec/client/transport/mock added), and routes the deep-linkstart_param(g/i/f→ game / lobby-invitation / friend-code redeem). On the/telegram/path without initData it redirects to the site root. The officialtelegram-web-app.jsloads fromindex.html(harmless outside Telegram). - Deep-link scheme (shared Go
platform/telegram/internal/deeplink↔ TSui/src/lib/deeplink.ts):g<game uuid>/i<invitation uuid>/f<6-digit code>/ empty = lobby. A friend-code share-to-Telegram link is shown whenVITE_TELEGRAM_LINKis configured (partially discharges TODO-5; QR still open). TheNotifybutton and the bot/startreply both wrap the payload as<MiniAppURL>?startapp=<payload>. - Test environment (interview nuance): the Bot API base is overridable for
Telegram's test environment —
TELEGRAM_TEST_ENV=truesuffixes the token with/testso the client hits/bot<token>/test/METHOD(TELEGRAM_API_BASE_URLoverrides the host for a mock/self-hosted server). - Deploy groundwork (interview):
platform/telegram/Dockerfile(builds the connector standalone — drops backend/gateway and the solver replace from a copy ofgo.work, validated withdocker build) + the connector-scoped compose with the VPN sidecar; a root.dockerignore. No public ingress for the connector (long-poll + sidecar egress); the host reverse proxy routes only to the gateway port, which serves the Mini App. The full multi-service deploy is Stage 12. - Wire/codegen/CI: new proto
pkg/proto/telegram/v1(committed Go); fbsProfile/UpdateProfileRequestgainednotifications_in_app_only(committed Go- TS).
go.workgainsuse ./platform/telegram; deps viago mod edit+go work sync(no-tidy).go-unit.yamlgained theplatform/**path filter and builds/vets/tests./platform/telegram/.... UI grows to ~86 KB gzip JS (budget 100 KB). The connector's unit tests use an httptest fake Bot API; a Playwright smoke drives the Mini App launch + guard with an injectedwindow.Telegram.
- TS).
- Stage 10 forward-note: the admin surface will wire
connector.SendToUser/SendToGameChannel(backend gains its own connector client) for operator broadcasts to a user and the game channel. - Verification-time fixes (caught by the CI gate): (1) the gateway transcode
dropped
notifications_in_app_onlyin four places (ProfileResp,encodeProfile,profileUpdateHandler, theUpdateProfilebody) so the toggle never reached the backend — fixed, with a round-trip transcode test added. (2) The e2e suite was made hermetic (a sharedui/e2e/fixtures.tsblocks the realtelegram-web-app.js): the render-blocking CDN<script>hung every page load on the CI runner, where telegram.org is unreachable, timing out all non-Telegram specs. (3) A pre-existing time-of-day flake inTestTimeoutSweep(the default 00:00–07:00 away window made the sweeper skip when CI ran withnow-1hinside it) was made deterministic by clearing the test account's away window.
- Connector as its own container (interview): the Telegram side-service is a
standalone module
-
Stage 10 (interview + implementation):
- Admin console = backend-rendered
/_gm, gateway Basic-Auth (interview, two rounds): the owner chose a dedicated web console but, pointing at../galaxy-gameand asking to keep it simple, the deliverable is server-rendered Gohtml/template+ one embedded CSS (backend/internal/adminconsole: a framework-agnostic renderer + page view-models,//go:embedtemplates/assets, zero JS, no build step), not a SPA. It lives in the backend on its own route/_gm/*; the gateway (the project's built-in reverse proxy) gates/_gm/*with the existingGATEWAY_ADMIN_USER/PASSWORDBasic-Auth on its public listener and proxies verbatim to backend/_gm/*(mounted on the edge mux below the h2c wrap so Connect keeps working). This supersedes Stage 6's gateway-fronts-/api/v1/adminmodel: the separate admin portGATEWAY_ADMIN_ADDRis dropped (only the port — user/password stay), the backend/api/v1/admingroup +pingare removed, andgateway/internal/adminis repurposed to the verbatim proxy. The backend keeps no operator identity and noadmin_accountstable; CSRF on the console's POSTs is a same-origin check (Origin/ReferervsHost, the gateway preserves the inbound Host) — no token. Discharges Stage 1's "admin bootstrap" (it is config, not a DB seed). - Complaint resolution + dictionary pipeline (interview): migration 00008
(+ jetgen) adds
disposition/resolution_note/resolved_at/applied_in_versiontocomplaintsand the deferredstatusCHECK (open|resolved) — discharges Stage 3's deferral (noresolved_by: operator identity is not tracked). Resolution sets a disposition (reject/accept_add/accept_remove); accepted complaints are derived by query into a pending dictionary-change list (no new table), stampedapplied_in_versiononce a rebuilt version is loaded. NewgamereadsListComplaints/GetComplaint/CountComplaints/ResolveComplaint/DictionaryChanges/MarkChangesApplied; admin list/count readsaccount.ListAccounts/CountAccounts/Identitiesandgame.ListGames/CountGames/ GameByID. - Dictionary hot-reload = per-version subdir (interview): the launch version stays
in the flat
BACKEND_DICT_DIR(CI/dev untouched); a reloaded versionXloads fromBACKEND_DICT_DIR/X/via the newRegistry.LoadAvailable(present variants only), and boot re-loads every subdirectory viaengine.OpenWithVersionsso reloaded versions survive a restart. Partially addresses TODO-2 (the runtime reload contract; the offline DAWG generator stays future work). - Operator broadcasts (discharges Stage 9's forward-note): the backend gains its
own connector gRPC client (
backend/internal/connector,BACKEND_CONNECTOR_ADDR, nil when unset) over the existingpkg/proto/telegram/v1; the console messages a user byaccount_id(backend resolves the Telegramexternal_id) and posts to the game channel viaSendToUser/SendToGameChannel. - Config/CI: backend adds
BACKEND_CONNECTOR_ADDR; gateway dropsGATEWAY_ADMIN_ADDR(keeps user/password). No new module and no fbs/proto/UI codegen (the console is server-rendered Go). The Go workflows already span./backend/... ./gateway/... ./pkg/...; integration stays./backend/....
- Admin console = backend-rendered
-
Stage 11 (interview + implementation):
- Scope = link-via-confirm + merge for email and Telegram (interview): the
current account is the merge primary; a linked identity that already has its
own account is merged into the current one and the secondary is retired as an
audit tombstone (
accounts.merged_into/merged_at, migration00009- jetgen). Linkable this stage: email (the existing confirm-code) and
Telegram via the Login Widget (the web sign-in). New
internal/accountmerge(the single-transaction data merge) andinternal/link(the orchestrator over account + accountmerge + session).
- jetgen). Linkable this stage: email (the existing confirm-code) and
Telegram via the Login Widget (the web sign-in). New
- Tombstone, not delete (interview): the secondary row is kept so a shared
finished game's no-cascade
game_players/chat/complaintsforeign keys stay valid; its seat in such a game is left in place. The merge is refused (ErrActiveGameConflict) only when the two share an active game. - Merge algorithm (one tx): stats summed (wins/losses/draws) + max kept;
hint_balancesummed; identities repointed; non-sharedgame_playerstransferred (shared kept);chat_messages/complaintsreassigned; friendships/blocks repointed with self-edge drop and dedupe (friendships by status precedence accepted>pending>declined); invitations: secondary's as inviter deleted, invitee rows deduped; secondary'semail_confirmations/friend_codesdropped; secondary tombstoned. Sessions are handled one layer up:session.Service.RevokeAllForAccount(+Cache.RemoveByAccount) retires the secondary's sessions after the tx. - Primary direction + guest inversion (interview): primary = the current account,
except when the initiator is a guest and the linked identity already has a
durable owner — then the durable account wins, the guest's active games
transfer into it, the guest is retired, and a fresh session for the durable
account is minted and returned (the client adopts it). Binding a free identity
to a guest is a plain upgrade (clear
is_guest, same session). Discharges Stage 8's "guest email-binding is Stage 11". - API/UX = dedicated ops; reveal only after the code (interview): new edge ops
link.email.request/confirm/merge(Email-rate-limited) andlink.telegram.confirm/merge.requestalways mails a code (no pre-send "taken" signal, so a probe cannot enumerate registered addresses); a required merge is revealed only after the code is verified, gating an explicit irreversible merge step (the Profile screen's confirmation dialog). This supersedes Stage 8'semail.bind.*ops (and their fbsEmailBindRequest/EmailConfirmRequesttables), which were retired from the gateway/UI for that reason; the backendEmailService.RequestCode/ConfirmCodeprimitives stay (still covered by inttest). - Field policy (interview):
display_name= primary's; profile prefs/flags (language, timezone, away window, block toggles,notifications_in_app_only) = primary's;hint_balance= sum. A new service columnpaid_account(bool, default false; lifetime one-time-payment marker, no purchase flow yet) is added in00009and ORed on merge (truealways wins). It is not user-editable and is shown read-only on the admin account-detail page. - Telegram Login Widget (interview, owner chose the broader scope): the connector
validates it (
internal/loginwidget, secret =SHA-256(bot_token), distinct from initData) via a newTelegram.ValidateLoginWidgetRPC; the gateway validates the widget payload and passes the trustedexternal_idto the backend link route (same trust model asauth.telegram). The UI offers "Link Telegram" only in a plain web context (loginWidgetAvailable), driving the popupTelegram.Login.auth; it is inert in production until BotFather/setdomainregisters the site domain andVITE_TELEGRAM_BOT_IDis configured (a deploy concern, Stage 12). e2e mocks the widget (telegram.org is blocked on CI). - Wire/CI: new fbs
LinkEmailRequest/LinkEmailConfirm/LinkTelegramRequest/LinkResult(committed Go + TS); new proto RPC (committed Go); new REST routes under/api/v1/user/link/*. The Go workflows already span./backend/... ./gateway/... ./pkg/... ./platform/telegram/...; integration stays./backend/.... UI ~90 KB gzip JS (budget 100 KB). New error codemerge_active_game_conflict.
- Scope = link-via-confirm + merge for email and Telegram (interview): the
current account is the merge primary; a linked identity that already has its
own account is merged into the current one and the secondary is retired as an
audit tombstone (
-
Stage 12 (interview + implementation):
- Re-scoped & split (interview): the original "Polish (observability + perf +
deploy)" was too large for one session, so it was split — Stage 12 = observability
- performance + guest GC; Stage 13 = alphabet-on-the-wire (TODO-4); Stage 14 = CI & deploy (TODO-1, TODO-2, the collector + dashboards). The latter two were written into the plan now as the agreed baseline (each still re-interviews at its own start).
- Shared telemetry (interview): a new
pkg/telemetryowns the OTel provider bootstrap (exporter selection, W3C propagators, shutdown, Go runtime metrics); the backendinternal/telemetryis now a thin facade over it (keeping its gin middleware), and the gateway and connector gained telemetry runtimes. A configurableotlpexporter was added alongsidenone/stdout; the default staysnone, the OTLP endpoint comes from the standardOTEL_EXPORTER_OTLP_*env, and the collector + dashboards are Stage 14 (so CI needs none).otelgrpcinstruments the backend push server, the gateway's backend + connector clients, and the connector's gRPC server. New configGATEWAY_SERVICE_NAME/GATEWAY_OTEL_*andTELEGRAM_SERVICE_NAME/TELEGRAM_OTEL_*; the backend's existingBACKEND_OTEL_*gained theotlpvalue. - Metrics = operational, business-near (interview): histograms
game_replay_durationandgame_move_validate_duration; countersgames_started_total,games_abandoned_total(a turn-timeout seat drop) andchat_messages_total(kind=message/nudge); an observable gaugegame_cache_active; the gatewayedge_request_duration(message_type/result); plus Go runtime/heap metrics. Game-scoped metrics carry avariantattribute (english/russian_scrabble/erudit — chosen over a coarserlanguage, which it subsumes); the gateway edge metric is variant-agnostic. Optional wiring uses the establishedSetMetrics/SetNotifiersetter pattern (default no-op meter), so existing constructors and tests are untouched. No speculative optimisation — there is no measured hotspot; the deliverable is the instrumentation (the standing "performance only with evidence" rule). pprof was not added (reframed away by the owner). - Guest GC (interview, TODO-3): age-based, no-seat-only — see the discharged TODO-3
below; new config
BACKEND_GUEST_REAP_INTERVAL/BACKEND_GUEST_RETENTION. - Deps/CI: new OTel modules (the OTLP exporters,
contrib/instrumentation/runtime,otelgrpc) added with the no-tidy pattern (go mod edit+go mod download+go work sync;pkgcarries no bare-path dep, so it tidies cleanly). No workflow change — the Go workflows already span./backend/... ./gateway/... ./pkg/... ./platform/telegram/..., integration stays./backend/..., and the defaultnoneexporter keeps CI collector-free.
- Re-scoped & split (interview): the original "Polish (observability + perf +
deploy)" was too large for one session, so it was split — Stage 12 = observability
Deferred TODOs (cross-stage)
- TODO-1 — publish & version the solver. Once
scrabble-solveris stable, give it a real module URL and switchbackendto a versioned dependency, dropping thego.workreplace and the CI clone. Removes the floatingmasterdependency accepted for now (Stage 2 interview). Planned for Stage 14 (it cleans up the backend Docker build; a clone-in-build fallback stays available). - TODO-2 — split the solver into engine vs dictionary generator + versioned
dictionary artifacts. Owner's idea, with the caveats agreed at the Stage 2
interview: the split is sound (build-time wordlist→DAWG vs runtime load have
different lifecycles and shrink the runtime dependency surface), but the
generator must pin the same
dafsa/alphabetversions and alphabet definitions as the runtime engine or the on-disk format / letter indexing drifts and silently corrupts validation. For delivery prefer Git LFS or an artifact store (Gitea releases / OCI artifact / object storage) over a raw git submodule (the ~0.5–0.7 MB DAWGs are regenerated wholesale and bloat git history); pin by tag/hash for a reproducible startup set. A submodule/LFS pull is a deploy-time way to populate the directory, not the runtime dynamic-reload mechanism (implemented in Stage 10: a per-version subdirectoryBACKEND_DICT_DIR/<version>/loaded viaRegistry.LoadAvailable, restart-restored byengine.OpenWithVersions) — keep theBACKEND_DICT_DIRdirectory as the runtime contract: a new.dawgappears in it and is loaded withdawg.Load. Planned for Stage 14, agreed resolution: a new versioned repo for the parsers + built DAWGs, delivered as a release artifact (notgo get), versioned with one semver label for the whole set (additive; old versions retired once no active game pins them — see Stage 14). The generator must build against the samedafsa/alphabet/solver as the runtime (the index-drift caveat, shared with TODO-4). TODO-3 — garbage-collect abandoned guest accounts.Done in Stage 12. A periodicaccount.GuestReaperdeletes guests (is_guest) with no game seat at all whose account age exceedsBACKEND_GUEST_RETENTION(default 30 d, swept everyBACKEND_GUEST_REAP_INTERVAL, default 1 h). Two schema facts shaped this, narrowing the original sketch: (1)game_players/chat_messages/complaintsreference accounts withoutON DELETE CASCADE, and a finished game belongs to the other players' history, so a guest with any seat is retained (a delete would be blocked anyway) — hence "no seat", not "no active game"; (2) sessions are revoke-only with no maintainedlast_seen_at, so a lingering session never expires and account age is the abandonment trigger, not "last session gone". The reaped guest'ssessions/identities/account_statsfall away via their ownON DELETE CASCADE.- TODO-4 — put the per-game alphabet on the wire (owner's idea, Stage 7). Today the
client hardcodes each variant's letters/values (ported into
ui/src/lib/premiums.tsfromscrabble-solver/rules/rules.go) and the edge exchanges plays/hints by concrete letters. Consider extendinggame.stateto carry the variant's(letter, index, value)table so the UI stops duplicating it, and optionally moving tile exchange to letter indices end-to-end. Caveat (as for the dictionaries, TODO-2): the wire table must stay pinned to the samerules.Alphabetthe engine uses, or indices drift. Planned for Stage 13, expanded (owner) to a fully alphabet-agnostic UI: the client caches the per-variant table (display only) behind aninclude_alphabetrequest flag and exchanges indices both ways, word-check included; the durable journal stays concrete characters (§9.1). See Stage 13. - TODO-5 — QR friend codes (owner's idea, Stage 8). Partially done in Stage 9:
the deep-link scheme now exists (
f<code>, shared Go ↔ TS), the bot redeems it on launch, and the UI shows a share-to-Telegram link for an issued code whenVITE_TELEGRAM_LINKis configured. Still open: render the link as a QR so a friend can add you by scanning rather than tapping/typing. The code semantics (12 h TTL, single use, one active per issuer) stay as-is; only the delivery changes. - TODO-6 — smart default for the friend-game "game type" (owner's idea, Stage 8).
The play-with-friends form has no preselected variant today (an empty, required
pick). Default it from the player's history (the variant they play most, from
account_statsor a games query), falling back to their interface language (en → English, ru → Russian/Эрудит). Until then the explicit pick avoids guessing wrong.