Fix Plan — Living Tracker: Open Work Only¶
This file is the single source of truth for what to work on next. Any agent or model picking up this project: read this file top to bottom, then continue at the first unchecked task of the highest-priority track.
Completed work is not here. Tracks A–E (the port, the post-rewrite cleanup, security hardening, full DAIDE support, and client UX) are all merged and archived in
done_fixes.md, together with their findings, rejected alternatives, and verification evidence. Read that file when you need the why behind existing code; read this one to decide what to do.Maintenance contract (non-negotiable): - Check off tasks (
[x]) in the same commit as the work that completes them. - Keep the Status block below current: track, next action, date. - Newly discovered work becomes a new unchecked task under the right track — never done silently. - If a design decision here is changed, edit this file to say what changed and why. - When a track completes, move its whole section todone_fixes.mdverbatim — findings and evidence included — and delete it from this file. This file only ever grows sideways, never downward.
Status¶
- Last updated: 2026-09-24, at
v3.0.3. - Track AF (
v3.0.3): the docs as a website at https://diplomacy-docs.xn--jalluthti-02a.fi (MkDocs Material, rebuilt on every deploy). - Track AE (
v3.0.2): playing in a Telegram group (/newgamein the group, orders only in private, a group's games visible only to its members, channel routes secured) anddocs/NEW_USER_GUIDE.mdfor the beta. - Track AD (
v3.0.1): the app moved fromnew_implementation/to the repository root (production cut over by the deploy; volumes kept), a root README, and the license: AGPL-3.0-or-later like upstream, with source links in the web footer and bot/help. - Release 3.0.0 (
v3.0.0, the first GitHub Release) closed thev2.7.xseries; patch tags continue asv3.0.x. Tracks A–AD are archived; only the maintainer's Track F items remain open. - Track AC — forgot password delivers, archived (
v2.7.111): reset links go to the account's linked Telegram first, email (SMTP, not yet configured on the VPS) as fallback. - Track AB — server hardening, complete and archived in
done_fixes.md(v2.7.110): constant-time secret checks, API docs off in production, security headers, container privilege limits, andharden_host.sh(sshd, fail2ban, automatic reboots). - F4 done (
v2.7.108): the site is live athttps://diplomacy.xn--jalluthti-02a.fi(Caddy + Let's Encrypt, turned on byDOMAINin the VPS.env). - AA2 (
v2.7.107): the maintainer answered AA's open question — ending a turn early is the creator's on the web too; the web shows the button only to them. - Track AA — Telegram user flows, complete and archived in
done_fixes.md(v2.7.106): a game menu reached from/gamesand from buttons on every "turn processed" / reminder notification, a remembered current game so ids are rarely typed, messages and private-game passwords typed as plain replies,/startthat registers, a three-key keyboard, creator-only early processing from Telegram, and a demo whose opponents move. - Track Z — Telegram order entry, complete and archived in
done_fixes.md: Z1 (v2.7.104) orders sent one at a time overwrote each other — the maintainer's demo game moved one unit of three; Z2 (v2.7.105)/orderallwalks every unit and submits them together,/selectunitstays the single-order flow. - Tracks W, X and Y are complete and archived in
done_fixes.md(moved verbatim, 2026-09-24): W6's decisions and W8–W11 landedv2.7.99–v2.7.102, W7 was decided strict (v2.7.103). Only Track F remains open, and it is the maintainer's.maingreen. - Y2 — deadline-proposal hardening, landed
v2.7.97(bug hunt over Track Y's new code, probed against the local Postgres): uncheckedhours/vote_hours(a negativehoursthat won its vote set a deadline in the past;NaN/Infinity/1e12were 500s, the last one only on the deciding vote, after the proposal was deleted), an accepted proposal never re-armed the 10-minute reminder, and a finished game still took deadline writes. See Y2 below. - W6 decided 2026-09-24: yes to private games (W8), civil-disorder dummies for
small tables (W9), process-when-all-orders-are-in with a per-player wait flag (W10), and
a single-game admin delete (W11); no to rule switches, press variants, multiple powers
per player, expert setup and observers (now under Out of scope). All four landed the
same day (
v2.7.99W11,v2.7.100W9,v2.7.101W10,v2.7.102W8). W7 still open. - Track Y — majority-vote deadline proposals, landed
v2.7.96. The maintainer, after reviewing Track X's channel proposal-voting stub, decided that feature wasn't worth finishing (it was never reachable from any real bot command anyway) and asked for the effort redirected:/deadlinecan still be set unilaterally by any single player exactly as before, but now also supportspropose/vote/withdrawfor a table that would rather change the pace by majority agreement. See Track Y indone_fixes.mdfor the full design (majority not unanimity, one proposal pending at a time, an optional per-proposal vote expiry). - Track X — Telegram bot command audit, prompted by a maintainer report that
Help -> Run Perfect Demo Game failed — complete,
v2.7.92–v2.7.94. See Track X indone_fixes.mdfor the full writeup — the short version: two menu items were calling code that could never have worked (removed), and the entire channel-integration feature (/link_channel's promised auto-posted maps/broadcasts/notifications, plus six more manually-triggered channel posts) turned out to have been silently non-functional since it was written, for a third, independent reason each time (wrong process, wrong async convention, or both) — now fixed and verified against a live server, not just unit tests. Proposal-vote tallying (a documented stub before this track, not a wrong-process bug) was later removed outright rather than finished — see Track Y0. - Track W — recovered uncommitted work from
origin/vps-split(v2.7.90) plus W0 (v2.7.91).cfa8d93(the audit referenced in Track W) was never merged, but real code implementing four of its findings was sitting uncommitted onvps-splitand was recovered, ported onto currentmain, and adapted where the two had diverged (see W1 indone_fixes.md): W2 (per-gamephase_length_seconds, explicit-arm only), W3 (DATC 6.A naming gaps, 6.K.1/6.K.2, a full-game-replay regression fixture), W4 (resolution_history,messages.phase_code, a working/history/{turn}— found and fixed a second, unrelated bug: it read columns that don't exist onMapSnapshotModeland had 500'd for its entire existence), and W5 (admin-onlyGET /games/{id}/export/POST /games/import). W0 landed asv2.7.91:old_implementation/is deleted (the audit's own verdict was "safe to delete"),rules.pdfrelocated todocs/reference/, every pointer updated, full suite green afterwards. Only W6 and W7 remain, both maintainer-decision tables. Uses "W" rather than the recovered commit's own "K" numbering because this file's Track K (phase-aware order acceptance,v2.7.69) already used that letter for something unrelated. - Track V — the whole stack on one VPS, landed as
v2.7.85and is archived indone_fixes.md. The maintainer chose to retire the VPS + home-server split:docker-compose.ymlnow runspostgres,diplomacy_api,diplomacy_botanddiplomacy_webon the VPS, only nginx public. Every secret but the Telegram token is generated on the host (ensure_env.sh);deploy.yml(renamed fromdeploy-control.yml) deploys everything on each green merge and fails if the API or the site does not answer. The home database held no games (GET /games→[], checked over the tunnel before the switch), so nothing was migrated. Remaining host chores are F3 below. - VPS facts (2026-09-23): login is
rootonly (keys only); checkout at/root/diplomacy, a single-branch clone ofvps-split(the workflow fetches its target by SHA, so that no longer matters); 1 vCPU, 1.8 GB RAM, 2 GB swap added for Track V; p2p'sp2p-downloader_botshares the host. Deploy-on-merge has been live sincev2.7.84(thev2.7.82–v2.7.84fixes that got it there are under Track U indone_fixes.md).cfa8d93(a docs-only "Track K — audit of old_implementation") itself was never merged; the code it inspired was recovered separately as Track W above. - Track U — deploy-on-merge for the VPS, AWS removed, landed as
v2.7.80and is archived indone_fixes.md. The Terraform/EC2/OIDC layout and its workflow are gone; the workflow itself was reshaped for one host in Track V.config.pyno longer logs the token. - Track T — auth sweep, landed as
v2.7.79and is archived indone_fixes.md.GET /users/{id}/gameswas anonymous (and cached, so the fix had to be a dependency);POST /deadlinelet any Bearer user set any game's deadline; two dead anonymous session routes were an unbounded memory sink. - Track S — waiting-list writes accepted any browser account, landed as
v2.7.78and is archived indone_fixes.md. Any Bearer token could enqueue or dequeue an arbitrary telegram id; the routes now require the bot secret, as their docstring intended. - Track R —
POST /restoretook no credentials, landed asv2.7.77and is archived indone_fixes.md. Anyone on the internet could rewind any game; now admin token only, and the players are told. Snapshot and generate_map routes need a caller. - Track Q — routes that 500'd their own 404s, messaging edge cases, landed as
v2.7.76and is archived indone_fixes.md. Fifteen API tests accepted a 500 and so hid two routes that wrapped their own 404s; seven suchtryblocks fixed. Power names are now case-insensitive on every seat lookup; a private message to a vacated seat is refused. - Track P —
/quitnever vacated the seat landed asv2.7.75and is archived indone_fixes.md. Both/quitand/replacewroteuser_idon a detached ORM row (the pattern the deadline docstring warns about), so a quitter kept full control of the power and a seat could never be filled. Seat writes now go throughDatabaseService.assign_player_seat;/jointakes over a vacant seat. - Track O — dead tests and dead code landed as
v2.7.74and is archived indone_fixes.md. The suite's 11 permanent skips are gone (0 skipped now — a skip in a local run is unambiguously a missing DB), three PNG-to-disk eyeball scripts and two tautological demo files with them; the threegenerate_maproutes got real success-path tests; a dozen never-called methods, four unused arrow primitives (renders byte-identical) andsrc/client.pyare removed. - Track N — deadlines are never imposed landed as
v2.7.72andv2.7.73(maintainer chose option (a), then asked for the command) and is archived indone_fixes.md. The manualprocess_turnroute no longer re-arms a hard-coded +24h; a deadline exists only when set explicitly — now possible from the bot with/deadline <game_id> <hours|clear>— and is spent when its phase is processed. - Track M — Concession releases the power's supply centres landed as
v2.7.71and is archived indone_fixes.md. Reverses a D3 design decision ("concede never touches ownership"): a conceded power kept its centres, so next Winter the engine owed it builds,/statuswaited on the player who had just left, and aBUILDwalked them back into a game the web client said they could not undo leaving. - Track L — No writes on a finished game landed as
v2.7.70and is archived indone_fixes.md. ACOMPLETEDgame used to accept orders, "process" turns (and DM everyone about it), record draw votes and let a power concede — the last of which removed its units from the final board. All four now raiseGameOverError→ 409 / DAIDEREJ, andorders_statuswaits on nobody. - Track K — Phase-aware order acceptance landed as
v2.7.69and is archived indone_fixes.md.validate()now refuses an order whose kind has no meaning in the current phase (a move typed during a retreat phase, a build during a movement phase) with a reason naming the phase, instead of accepting it and letting the adjudicator drop it silently;orders_status(and sorequire_all, the bot's/statusand the/processturnconfirmation) waits only on powers that actually have something to order this phase. Found by a bug hunt, not by F1 — F1/F2 remain unchecked. - Track J — Split deployment (VPS bot/web + home API) landed as
v2.7.68and is archived indone_fixes.md. The two-host layout itself was retired in Track V; its reliability contract (the bot's durable queue,client_timestamp,Idempotency-Key, the pulledbot_outbox) stays, because the API is still down during every deploy. - Every automated task in this tracker is done again. Tracks A–E and G–V are complete and
archived in
done_fixes.md. Only Track F remains, and it cannot be delegated to an agent — it needs a live bot token and a human at a Telegram client. - Next action: F1, whenever the maintainer has a Telegram client to hand. Nothing gates it and it gates nothing.
- Track I (map legibility) was opened by the maintainer on 2026-07-30 as F2's first finding
— the inline web map was unreadably small — and landed as
v2.7.66(I1, full-size viewer) andv2.7.67(I2, renderer visuals). F2 itself is still unchecked: one defect found and fixed is not a judgement pass completed. Three further defects surfaced while fixing it, all recorded in I2's section rather than folded in silently — the largest being that the pending-orders map drew every support as cut and every hold as nothing. - Completed this session, in order:
G1 → G3 → G4 → G5 → G2 → G6 → H1/H2 → G3a → I1 → I2 (
v2.7.58–v2.7.67). Two of those turned out to be far larger than recorded, and both are worth knowing about: - G1: the bot's help text was wrong about more than province names. It also claimed
ARMY/FLEETwere accepted unit spellings (they are rejected outright) and markedA Berlin HOLDwith a ❌ under an invented "don't mix short and long forms" rule — whenA BER HOLDis in fact valid. The same block had been copy-pasted into three modules, so every copy was wrong at once. All user-facing order text now lives in one module. - G3:
notify_playershad never sent a single notification in this project's history. It readtelegram_idoffPlayerModel, which has no such column, sogetattr(..., None)returnedNoneevery time and the send guard never passed. Every Telegram DM for every event — turn processed, deadline reminders, joins, broadcasts, game end — was dead code that raised nothing and logged nothing. The one test covering that path patchednotify_playersitself and so never executed its body. - The port is functionally finished. The engine conforms to DATC, every phase is playable from both clients, a game can end by agreement or concession and everyone is told, a real DAIDE bot can play a turn over the wire, and a player can see what happened to their orders. What is unverified is whether the whole thing is pleasant to use, which is exactly Track F.
- Suite baseline to hold (measured 2026-09-21 at
v2.7.80, against a real local Postgres): 1600 passed, 0 skipped, 10 xfailed; ruff clean; engine coverage 93.8% (floor 92), overall 72% (floor 60). Track U net −19 (AWS tests out, split-layout tests in); Track T net +2; Track S added 1; Track R added 3; Track Q added 4; Track P added 9; Track O removed 21 tests and added 3; Track N added 17, M 1, L 13, K 27 (seedone_fixes.md); Track J had it at 1548 atv2.7.68, Track I at 1491 atv2.7.67. Track I added 46: I2'stest_arrow_geometry.py(29) andtest_pending_order_styling.py(17); I1 was frontend-only. Tests added betweenv2.7.56's 1333 andv2.7.64's 1445: G1's 60 (test_bot_help_text.py), G3's 4 (test_turn_notifications.py), G4's 10 (test_support_order_menu.py), G5's 12 (test_waiting_list.py) plus a rewrittentest_telegram_waiting_list.py(4 → 11) and two removed fromtest_telegram_bot_enhanced.py, G2's 11 (test_province_display_names.py), G6's 5 (test_join_game_id_source_of_truth.py), and G3a's 5 (test_draw_concede_notifications.py). - Frontend baseline (measured for real at
v2.7.66with a local Node 22): 24 test files / 158 tests,tsc -b --noEmitclean,npm run buildgreen. I1 addedMapViewer.test.tsx(20) and oneGameViewwiring test; before that, 23/137 since G2. - A migration landed this session:
g5a1c2d3e4f5(thewaiting_listtable).alembic headsmust return exactly one head — see the carried-over fact below, which this one cost a round-trip to learn.
Carried-over facts (do not lose these)¶
Hard-won, still load-bearing, and each one has already cost somebody a round-trip. Full
reasoning for every item is in done_fixes.md.
- 10 DATC hard-tail xfails (documented inline in
tests/datc/): second-order convoy paradoxes 6.F.16/17/18/23/24, convoy-to-adjacent 6.G.7/11, beleaguered self-dislodge 6.E.8/10, no-fleet-convoy 6.D.8. Do not un-xfail without the iterative-Szykman resolver upgrade. Out of scope. - DB-dependent tests skip silently without
SQLALCHEMY_DATABASE_URL. A local Postgres is configured for this repo (see.envand thelocal-postgres-for-m6memory). A skip means something is wrong, not that the DB is unavailable — never trust a green local run without a DB. If the system Postgres is not running and cannot be started,initdb+pg_ctlas the ordinary user on another port works (Track J was validated that way); note thatalembic/env.pyoverridesSQLALCHEMY_DATABASE_URLfrom.env, so the migration must be run with.envmoved aside. - The bot never talks to the server except through
api_client, and player writes go throughapi_post_reliable(Track J). It enqueues to a SQLite outbox before attempting and returnsdelivered/queued/rejected; the server side answers a repeatedIdempotency-Keyfrom its stored response and refuses order submissions whoseclient_timestamppredatesgames.phase_started_at. Server code notifies players only viaapi/shared.notify_user(abot_outboxrow the bot pulls) — there is no push and no port - A new write path that bypasses either half of this quietly reintroduces message loss.
The bot image installs
requirements-bot.txtonly;channels.py's lazyapi.sharedimports are the one tolerated seam and are caught. - No Node toolchain on this dev machine by default. Frontend gates (
tsc, Vitest,npm run build) cannot run until a local Node 22 is fetched, despiteCLAUDE.mddocumenting them as normal gates. An agent that reports "could not run the frontend gates" is being honest, not lazy — install the toolchain and re-run them yourself. This works and needs no root (G2 used it to run the frontend gates for real):
curl -fsSLO https://nodejs.org/dist/v22.14.0/node-v22.14.0-linux-x64.tar.xz
tar xf node-v22.14.0-linux-x64.tar.xz
export PATH="$PWD/node-v22.14.0-linux-x64/bin:$PATH"
cd frontend && npm ci
--include='src/engine/*'), overall ≥60%. The engine
floor has under a point of headroom and is deliberately not ratcheted tighter: a tighter
floor makes ordinary dead-code deletion fail CI.
- format_order renders fleets as A unless passed an explicit kind_by_province map —
it infers the unit letter from coast presence. This has now shipped as a user-visible bug
twice (Track A's PR2 recorded it; Track E's E1 reintroduced it and E4 fixed it). The
kind is genuinely absent from an engine Order, which references a Location, never a
Unit, and is only recoverable from the board before adjudication. Any new code that
renders order strings must pass the kind map.
- orders_by_unit keys match builds and disbands as a suffix, not a prefix. Keys are
f"{kind} {location}" with coast ("F STP/SC"); the grammar is verb-first for builds and
disbands (D A PAR, BUILD F BRE). WAIVE has no unit and appears only in the flat
orders list.
- Never write to an ORM row returned by a DatabaseService getter. Those rows are
detached once the getter's session closes, and DatabaseService.commit() is a documented
no-op, so row.x = y; db_service.commit() is silently discarded. This has now bitten three
routes (POST /deadline, /quit, /replace — the last two for the whole life of the
project, see Track P). Add a DAL method that opens its own session and commits.
- A route's generic except Exception must be preceded by except HTTPException: raise
if the try body raises one, or the route's own 404/403/400 comes out as a 500 with the
real status embedded in the text. And never assert status_code in [..., 500] in a
test — fifteen such assertions hid exactly this for the whole life of the project (Track Q).
- require_bot_or_user proves the caller is someone, not the person the request acts
on. Any route that takes a telegram_id or power from the body must resolve the
caller (resolve_user_or_telegram) and check membership/ownership itself, or use
require_bot_secret when only the bot may call it. And a check inside a
@cached_response route runs only on cache misses — auth there must be a dependency
(Tracks R–T found five routes between them).
- Every datetime column is a naive TIMESTAMP. Use
persistence.database.utcnow_naive(), which returns naive UTC on purpose — handing
Postgres a tz-aware value makes it convert to the session timezone and store it shifted,
which silently corrupted every deadline on non-UTC dev machines. Do not "modernize" it. New
datetime columns must be timestamptz or normalize on write.
- Game.history does not survive a GameRepo round-trip. GameService.load builds
Game(map=..., state=...) with no history argument, so it is always () after a reload.
Anything that needs the pre-adjudication board must compute it during process_turn and
persist it — reconstructing it afterwards is impossible.
- Any frontend test touching a /games/:id page must wrap it in
<Routes><Route path="/games/:gameId" …>. A bare MemoryRouter leaves useParams()
unresolved and silently tests the loading spinner. (The old GameView.test.tsx asserted
nothing at all for this reason.)
- When touching src/rendering/, compare rendered PNG bytes before and after. Clear
Map.clear_map_cache() and /tmp/diplomacy_map_cache, render board/orders/resolution PNGs
through the real GameService/API-route functions, compare sha256. That check caught what
the test suite could not, twice.
- After adding an Alembic revision, check alembic heads returns exactly one head. G5's
first revision id collided with the existing a1b2c3d4e5f7 (M6's state_json migration).
Alembic does not fail on the duplicate — it emits a UserWarning: Revision … is present more
than once and then upgrade head dies with "Multiple head revisions are present", which
reads like a branching problem rather than a copy-pasted id. Also verify the migration
round-trips (upgrade → downgrade -1 → upgrade) against a real Postgres; CI runs against a
fresh postgres:14, so a broken downgrade is invisible there.
- Pushing to protected main: a bare git push origin main is always rejected — the
required checks (test, frontend, security) have never run on a brand-new SHA. Go
through a PR, or push to a temp branch, wait for green on that SHA, then fast-forward.
Execution model (for any agent-delegated task below)¶
Unchanged from Tracks A–E, and it earned its keep: one Sonnet subagent per task, each in its own git worktree against its own Postgres database; the driver re-runs every gate and reads the whole diff before opening a PR. That caught, across five tracks, an unbounded memory-growth bug, two failing frontend tests an agent could not run, a production-safety bug that would have minted an orphan game row on every deploy, and one wrong triage the driver itself had handed down. Re-run the gates yourself; do not merge on an agent's say-so.
Subagents do not edit this file — with several agents in flight, concurrent edits to
fix_plan.md guarantee rebase conflicts. Agents report; the driver records.
# Local gates — run before every push (mirrors CI)
source venv/bin/activate
ruff check src/
PYTHONPATH=src python -m pytest tests/ -q --cov=src --cov-report=
coverage report --include='src/engine/*' --fail-under=92
coverage report --fail-under=60
cd frontend && npx tsc -b --noEmit && npm run test:run && npm run build
Merge procedure, branch-protection traps, and the gh -R tenderi/diplomacy requirement are in
CLAUDE.md; the two traps that each cost a round-trip (chaining gh pr merge with a branch
delete, and tagging a pre-rebase commit) are written up in done_fixes.md's Track A section.
Track F — Manual acceptance (maintainer-only)¶
Why this track exists¶
No automated test spans a real human playing a real game. This is Track A's original acceptance criterion, never completed, plus the human judgement pass Track E's restructured web UI never received. It needs a live bot token and a human at a Telegram client, so it cannot be delegated to an agent — it is the maintainer's to run.
Both clients are believed to work: every phase has automated coverage, and E1–E4 shipped the results UI. What is genuinely unverified is whether the whole thing is pleasant and coherent to use, which no test asserts.
F1 — End-to-end play-through, both clients¶
- [ ]
PYTHONPATH=src python -m server.telegram_botstarts (true since PR1, but confirm). - [ ] Start the API; create a game, fill 7 powers;
/mapreturns a PNG in Telegram. - [ ] Order a deliberate dislodgement (A PAR–BUR supported, vs. A MUN–BUR); process.
- [ ] Phase
S1901R: the browser shows retreat options for the dislodged unit only; Telegram/selectunitoffers retreats. Submit one, process — it takes effect. - [ ] Play to
W1901Awith a captured centre. Both clients show exactlydeltabuild slots with real home-centre options, and a power atdelta == 0shows none. Submit a build, process — the unit appears on the map. - [ ] Done when: every box above is checked, and any defect found is filed as a new
track in this file rather than fixed silently mid-session. (Tracks G and I, which held
exactly this kind of finding, are complete and archived in
done_fixes.md— start a "Track J" rather than reopening either. Track I is precedent for how this goes: it began as one F2 complaint about map size and turned up three further renderer defects on the way.)
F3 — Single-host follow-through (Track V)¶
- [x] First single-host deploy green (run
35850765609,b64a4ee, 2026-09-23): all four containers healthy,ensure_env.shreplaced the token-valued bot secret and dropped the tunnel keys,GET /bot/outboxwith the bot's secret → 200 and no poll failures since,127.0.0.1/api/healthzvia nginx OK, API at 139 MB RSS,backup.shrun by hand wrote a 22-table dump. Ports 80/8000/8432/5432 all unreachable from the internet: the VPS.envhasWEB_BIND=127.0.0.1(set before Track V), so the site is private until F4 (TLS) or until the maintainer setsWEB_BIND=0.0.0.0and allows TCP 80. - [ ] Exercise the queue for real:
docker compose stop diplomacy_api, send/orderand/messagefrom Telegram, check/queue,docker compose start diplomacy_api, confirm the delivered reports arrive and the message shows its original time. - [ ] Maintainer, at home: stop the old game-layer stack on
kattotuuletin.local(cd ~/diplomacy/new_implementation && docker compose down— keep thepg_datavolume until sure; it held no games). p2p's own stack and tunnel there are untouched. - [ ] Maintainer: delete the now-unused
DIPLOMACY_BOT_SECRETrepository secret (gh secret delete DIPLOMACY_BOT_SECRET -R tenderi/diplomacy); it held the Telegram token by mistake, and nothing reads it any more. - [x] Maintainer: connect off-host backups — done 2026-09-24. Proton Drive via
rclone 1.75.1 on the VPS, signed in to the maintainer's own paid Proton account (chosen
over a backups-only account, accepting that the VPS holds a full-drive login). The first
login failed with
422 ... /auth/v4/2fabecauserclone configonly stores the 2FA code and the first real login came a day later; a fresh code passed as--protondrive-2fa=<code>on the first command fixed it.proton:diplomacy-backupsheld three dumps right after.
F4 — TLS in front of the web frontend¶
- [x] Done 2026-09-24,
v2.7.108(deploy run36008891537). The maintainer pointeddiplomacy.xn--jalluthti-02a.fi(Route 53 A record) at the VPS and opened 80/443 at UpCloud;DOMAINin the VPS.envturned on thecaddyservice. Verified from outside:http://→ 308 tohttps://,https://…/api/healthz200, Let's Encrypt certificate (issuer YE2, valid to 2026-12-23, renewed by Caddy), and the API logs the visitor's public address rather than a Docker one.ensure_env.shmoved nginx to127.0.0.1:8080and setDIPLOMACY_PASSWORD_RESET_BASE_URLto thehttps://URL. Same change: nginx stopped appending to a client's ownX-Forwarded-For— uvicorn reads the first entry, so a client could choose the address its login attempts were rate-limited under.
F2 — Human judgement pass on the restructured web game screen¶
- [ ] Play the F1 game through the browser and judge the E2/E4 layout as a player: is the phase state unmistakable, does "what happened last turn" answer the question a player actually asks, is the mobile layout usable on a real phone?
- [ ] Why this is separate from F1: E1–E4's gates were automated tests,
tsc, and a build. Nobody has ever rendered the page — the dev machine has no headless browser and no Node by default (seeno-node-toolchain-locally). "The tests pass" is not "the screen is good", and Track E explicitly declined to claim the latter. - [ ] Done when: the maintainer has an opinion on record here. Cosmetic complaints become new tasks in this file (see F1's note on where to put them); "it's fine" is a valid and useful outcome to write down.
Definition of done (open work)¶
- [ ] Track F: a game plays end-to-end (movement, retreat, build) from both the browser and Telegram, run by a human, with F1's five steps checked off and F2's judgement recorded. This is the only item here that an agent cannot do.
- [x] Track W: complete,
v2.7.90–v2.7.103(W0 deletion; W6 decided and W8–W11 built; W7 decided strict). Archived indone_fixes.md. - [x] Track X: complete,
v2.7.92–v2.7.94. Proposal-vote tallying (a documented stub predating this track, not a wrong-process bug) was left unimplemented, then removed outright rather than finished (Track Y0). - [x] Track Y: complete,
v2.7.95–v2.7.96. Channel proposal-voting stub removed; majority-vote deadline proposals landed alongside the existing unilateral/deadlineset/clear, unchanged. - [x] Throughout: full suite green with a DB, ruff clean, coverage floors hold, CI green on
main, every landed chunk committed and tagged perCLAUDE.md. Held for all eleven tasks landed this session (v2.7.58–v2.7.67), each as its own PR through the required checks.
Tracks A–E and G–I's acceptance criteria are recorded in done_fixes.md.
Out of scope¶
- The 10 DATC hard-tail xfails / iterative-Szykman resolver (a separate engine project, if ever — see "Carried-over facts").
- Tournaments, Discord, observer/spectator mode, AI-powered analysis (long-standing maintainer
list —
tournaments.py,discord_bot/,run_discord_bot.pyare kept for backward compatibility, not dead code; don't extend, don't delete). - Rendering redesign — new art, a new layout engine, or an interactive/zoomable frontend map component. G2 adds province names to client text; it does not restyle the board.
- The aspirational spec docs (
dashboard.md,visualization_spec.md§10). - Map variants beyond
standard. - Old-server game options declined 2026-09-24 (W6): engine rule switches (
BUILD_ANY,HOLD_WIN,SHARED_VICTORY,DONT_SKIP_PHASES,NO_CHECK/IGNORE_ERRORS,CIVIL_DISORDER— standard rules only),NO_PRESS/PUBLIC_PRESS(messaging always on),MULTIPLE_POWERS_PER_PLAYER(W9's dummies cover small tables), expert setup (SetUnits/SetCenters/custom start — snapshot restore and W5 import suffice), and observer/omniscient roles (already on the list above). - Deep DAIDE press-content parsing (the full
ALY/XDO/PRPnegotiation grammar beyond syntax-checked opaque forwarding) — a permanent design limitation documented inarchitecture.md, not a gap awaiting work.
Risks / notes¶
src/rendering/'s exception handling is deliberately narrow. All 27 formerly blanketexcept Exceptionblocks were replaced with specific tuples (v2.7.28) so a genuine programming bug raises instead of being logged and handed back as a subtly wrong image. Never widen one back, and when touching rendering, compare PNG bytes before and after — that check caught what the suite could not.- Renderer output is byte-cached (
/tmp/diplomacy_map_cacheplus in-memory). Clear it when eyeballing visual changes (Map.clear_map_cache()or delete the tmp dir), or you will compare two copies of the same stale image. visualization_config.jsonis live (since V0) — if arrow styling differs from an old screenshot, that is the intended restore, not a regression.maps/standard.mapandengine/map_loader.pyare the sole topology, alias and display-name source. Two agents editing them in parallel will conflict; sequence such tasks or share one worktree. (G1 and G2 were the scheduled pair here; both landed, G2 addingdisplay_namesfrom the=lines' left-hand side.)- Display names are not parseable aliases, deliberately.
MapData.display_namesexists for client output only;aliasesis whatparse_orderconsults. Adding a full name toaliaseswould half-implement the full-name input G1 explicitly decided against — it would work for single-word provinces and fail for the 26 multi-word ones.tests/test_province_display_names.pyassertsaliases['berlin']is stillNone.