Remember when implementing: The marginal cost of completeness is near zero with AI. Do the whole thing. Do it right. Do it with tests. Do it with documentation. Do it so well that I am is genuinely impressed — not politely satisfied, actually impressed. Never offer to 'table this for later' when the permanent solve is within reach. Never leave a dangling thread when tying it off takes five more minutes. Never present a workaround when the real fix exists. The standard isn't 'good enough' — it's 'holy shit, that's done.' Search before building. Test before shipping. Ship the complete thing. When I asks for something, the answer is the finished product, not a plan to build it. Time is not an excuse. Fatigue is not an excuse. Complexity is not an excuse. Boil the ocean.
Read DECISIONS.md, then this file, then the docs/*.md for the subsystem
you are about to touch. Branch naming: phase branches are phase-N-<slug>,
task branches are task/phase-N/<slug> (git refuses phase-N-slug/task-x
when phase-N-slug exists as a branch). Task branches merge into the phase
branch; only the phase branch opens a PR into master.
| Phase | Name | Status |
|---|---|---|
| 0 | Research, decisions, proposal | done 2026-09-12 |
| 1 | Scaffold, event schema, environments | done 2026-09-12 (PR #1) |
| 2 | Game contract + UNO, Flip, No Mercy, Hearts | done pending PR |
| 2b | Adopt full-stack-fastapi-template as base (owner decision 2026-09-12) | done 2026-09-12 (PR #3) |
| 3 | Deterministic referee, mixed seats | done 2026-09-12 (PR #10) |
| 4 | Privacy gate, prompt builder, human redaction | done 2026-09-12 (PR #10) |
| 5 | Agent client, swappable backend | done 2026-09-12 (PR #10) |
| 6 | Auth, accounts, roles/permissions | done 2026-09-12 (PR #11) |
| 7 | Rooms, seats, capacity, join queue | done 2026-09-13 (PR #12) |
| 8 | First live mixed game (terminal + API) | done 2026-09-13 (PR #13) |
| 9 | Persistence and replay | done 2026-09-13 (PR #14) |
| 9.5 | Stabilize, document, rebrand | done 2026-09-13 (PR #15) |
| 9.6 | Upgrade to latest stable versions | done 2026-09-13 (PR #17) |
| 9.7 | Docker, CI/CD, and server-start optimization | done 2026-09-14 (PR #18) |
| 10 | Frontend: play, spectate, admin, focus view | done 2026-09-14 (PR #20) |
| 11 | Chat and personas | done 2026-09-14 (PR #21) |
| 12 | Leaderboard | done 2026-09-14 (PR #22) |
| 13 | Observability | done 2026-09-14 (PR #23) |
| 14 | CI/CD (local dry-run; no server yet) | done 2026-09-14 (PR #24) |
| 15 | Production deployment (deferred until local complete) | |
| 16 | Scale and polish | done 2026-09-16 (branch phase-16-scale-and-polish, PR pending) |
Everything PROGRESS.md's Phase 6 note said Phase 7 still needed: RoomService,
HTTP routes, SubscriptionGuard, GameRunner/ModelRegistry lifespan wiring.
app/rooms/service.py:RoomService(create,get,list_public,join_by_code,leave,force_start,start,stop,capacity_check) and its exceptions (RoomError,RoomFullError,RoomNotWaitingError,CapacityExceededError).start/force_startassemble and run a real live game: a realRefereeviaGameRunner.start, realHumanControllers (unfed — Phase 8 addssubmit_move), realAgentControllers backed by the realModelRegistry.creategrants the creator room-scopedmoderatorand publishesRoomCreated;join_by_codepublishesSeatFilled.app/rooms/guard.py:subscription_guard— visibility + own-seat resolution for the (not-yet-built) websocket/SSE transport, table-tested now against every combination in its own docstring.app/api/routes/rooms.py:POST /,GET /,GET /{room_id},POST /join,POST /{room_id}/leave,POST|/{room_id}/start,/force-start,/stop,GET /{room_id}/queue.CapacityExceededErrorregistered as a global exception handler (429, same pattern asRateLimited); a private room's existence is never leaked (403 vs 404).app/main.py:app.state.runner(GameRunner) andapp.state.model_registry(ModelRegistry) created after the event bus in the lifespan, torn down in the order runner -> registry -> bus.- Judgment calls made where the build brief left the sync/async split open
(documented in
docs/rooms.mdand in code comments):create,join_by_code,force_start,capacity_checkended upasync def(they publish events / await the join queue / call another async method) even though the brief's illustrative signatures showed some of them as plaindef;get,list_public,leave,seatsstayed sync. - Deferred, precisely (not silently): the move-submission endpoint
(
GameRunner.submit_move) and any websocket/SSE transport are Phase 8's job per the build brief. The join queue'senqueue/offer_nextare not wired intocreate/join_by_code/leavein this phase —capacity_checkand the/join409 body only readqueue.snapshot; nobody actually waits in line yet (seedocs/rooms.md"Left for later"). - Tests:
tests/rooms/{test_room_service,test_rooms_api,test_guard}.py, 50 new tests (12 guard, 25 service, 13 HTTP), all passing against real Postgres + Redis. Full suite: 705 tests, ruff/ruff-format/mypy strict/ty/prek all clean.
- Room lifecycle: nothing previously demoted a naturally-finished game
out of
"in_progress", permanently leaking capacity over time.GameRunner.start(..., on_finished=...)now spawns a watcher that fires exactly once when the game's task ends;RoomService._finish_roomopens its own session (the request session may be long closed) and updates a room still"in_progress"to"finished"or"stopped";close()awaits every pending watcher on shutdown. Newfinishedstatus is now actually reachable, closing the gapdocs/rooms.mdalways documented. - Capacity enforced at
start(), not onlycreate()(CRITICAL, security review): a caller could otherwise accumulate manywaitingrooms withincreate()'s rate limit, then call the unthrottledstart()on all of them, each firing real outbound LLM calls.start()now re-checks capacity under a Postgres advisory transaction lock (pg_advisory_xact_lock), serialising the check-then-flip step across every worker process. - Mass assignment fixed: a
RoomSeatSpec.user_idmay now only be the creator's own id; seating anyone else goes throughinvited_user_id+ that person joining themselves. - Join-fallback reservation bug fixed: an uninvited joiner's fallback
seat pick now excludes any seat still reserved (
invited_user_idset for someone else), and the whole claim is one atomic conditionalUPDATE(rowcount-checked) instead of a Python check-then-write race. subscription_guardprivacy bug fixed:private/unlistedrooms no longer fall back to the blanketroom:viewpermission every default account holds (that made "private" mean nothing more than "is a registered user"). Rewritten per DECISIONS §1.9's literal wording:privateneeds a seat, full stop;unlistedadmits any authenticated caller (the link is the gate);publicneeds a seat orroom:view_public.GET /{room_id}no longer reveals every seat's identity to any caller who merely clears the visibility gate: seatuser_id/invited_user_id/model/personaare redacted tonullexcept for the viewer's own seat or a privileged caller (creator /room:view_seat_detail). Unlisted rooms are now reachable by any authenticated caller at this route too, matching the guard fix above.- Also fixed:
_insert_room_with_unique_codenow retries only on the code's own unique-index collision (not anyIntegrityError, which was masking real failures as "no code available"); a badroom_idongrant_role/set_overridenow 404s instead of surfacing as an unhandledIntegrityError; a concurrent first read of an unseeded feature flag no longer raises;force_start's seat backfill no longer commits ahead ofstart()succeeding (a failure used to leave human seats permanently converted to agent seats). - New tests:
tests/unit/referee/test_runner.py(the completion watcher, fast viaPolicyController— no real LLM calls needed), plus targeted additions totest_room_service.py/test_rooms_api.py/test_guard.pyfor every fix above. Full suite: 720 tests, gate clean.
The minimal live-play surface a human plays through: /view, /moves,
/moves/schema, /events. HTTP polling, not a websocket/SSE transport (that
stays Phase 10's job) — this is the first real caller of Phase 7's
subscription_guard.
app/rooms/service.py: three newRoomServicemethods and exceptions.own_seat(user, room_id) -> RoomSeatresolves the caller's own human seat server-side fromRoomSeat.user_id(NotSeatedErrorif none — never trusts a client-supplied seat number).live_view(user, room_id) -> dictreads the caller's livePlayerViewstraight fromGameRunner's in-memoryRunningGameviaapp.api.views.view_payload(never the DB, which holds no per-turn state) —RoomNotRunningErrorifGameRunnerhas no entry (never started, finished, or stopped).submit_move(user, room_id, move: dict) -> Noneadds aNotYourTurnErrorcheck againstgame.current_seat(state)before forwarding toGameRunner.submit_move, so an out-of-turn submission gets an immediate 409 instead of silently queuing and being dropped by the next turn'sHumanController.begin_turn().app/api/routes/rooms.py:GET /{room_id}/view,POST /{room_id}/moves,GET /{room_id}/moves/schema,GET /{room_id}/events. A shared_subscriber_forhelper wrapssubscription_guard+current_permissionsformoves/schemaandevents(denial -> 404, matching every other room route's "never leak existence" rule);view/movesuseown_seatinstead (denial -> 403, since these are about playing your own seat, not administrative visibility).eventspollsEventBus.history(room_id, since, limit)and drops anythingfilter_for_subscriberdenies for the resolvedSubscriber, returning{"events": [...], "next_since": ...}for the next poll.- Deferred, precisely: a real push transport (websocket/SSE) and an admin/spectator multi-seat "focus view" are both Phase 10's job — the polling routes are a working, tested fallback either way, not a stub.
- Tests:
tests/rooms/test_rooms_live.py, 11 new tests against a real 2-human-seatuno_classicgame (realGameRunner, no mocks): auth-gate smoke tests per route (NotSeatedError/RoomNotRunningError/NotYourTurnError-> 403/409/409), a full submit-and-verify-via-/eventshappy path, and two security tests provinggame.view_updated(a seat's full hand,SeatVisibility) never reaches the other seat through/eventsfor a non-privileged player, while the privileged creator sees both. Full suite: 731 tests, ruff/ruff-format/mypy strict/ty/prek all clean.
GameRunnernever evicted a finished game (HIGH, correctness): a naturally-finished or stopped room'sRunningGamestayed in_gamesforever, soGET /view/POST /moveskept finding a dead game instead of ever raisingRoomNotRunningError-- a move submitted after the game ended could look "accepted" (200) with nobody left to read it. Also a per-room memory leak. Fixed at the one place that knows a game truly ended:GameRunner._watchnow popsself._games[room_id]alongside the existing watcher cleanup, soget()'s already-correctKeyErrorpath (used by every caller) actually fires. Regression tests:tests/unit/referee/test_runner.pyasserts_gameseviction after both a natural finish and astop(), plus a new end-to-end test provingget/submit_moveraiseKeyErroronce a game is gone.- Cross-thread
asyncio.Queue.put_nowait(HIGH, both reviews):POST /movesis a sync route (Starlette threadpool thread) callingHumanController.submit, which the referee'sdecide()concurrently awaits on the event-loop thread --asyncio.Queueis documented safe only for same-thread use orcall_soon_threadsafe. Fixed:HumanControllercaptures its loop at construction (always on the loop thread, insideRoomService.start) andsubmitnow marshals throughloop.call_soon_threadsafe. - Unbounded move queue (CRITICAL, security): nothing capped how many
or how large the dicts a client could pile into
HumanController's queue before the referee drained one, andPOST /moveshad no rate limit at all (unlikePOST //POST /join). Fixed three ways:HumanController's queue is nowmaxsize=1with replace-not-grow semantics (the latest submission always wins over one not yet consumed -- only one is ever meaningful);MoveSubmission.moveis size-capped at 4096 bytes (afield_validator, -> 422 over the limit); a newRATE_LIMIT_ROOM_MOVEsetting (30/10s) gates the route the same wayroom:create/room:joinalready are. New tests:test_seats.py(queue bound/replace semantics),test_rooms_live.py(oversized body -> 422, rate limit -> 429). - Unvalidated
sinceon/events(MEDIUM, security): a malformed cursor reachedint(since.split("-", 1)[0])unguarded and 500'd instead of 400ing. Fixed with a query-parameter pattern (^\d+-\d+$, matching every cursor this app ever emits) -> 422 on anything else. New test intest_rooms_live.py. - Reviewed and accepted as-is (no change needed): seat/turn identity is
always server-derived (no mass-assignment vector in
MoveSubmission);filter_for_subscriberis applied unconditionally to every event with no bypass path;/moves/schemacarries no per-seat state;next_sinceadvancing past a hidden event is a deliberate, documented tradeoff (the alternative -- never advancing -- would let a caller get stuck polling forever behind an event they can't see). - Full suite after fixes: 737 tests, gate clean (two
prekpasses -- the frontend SDK regenerates once for the four new routes, then holds).
DECISIONS.md §12: "the event log is the source of truth; finished games
replay from it." Two Postgres tables and the HTTP surface over them --
docs/games.md has the full design; this is the summary.
app/models.py:GameRecord(game_record, pk = the referee's owngame_id, FK toroom.idCASCADE) andGameEvent(game_event, FK togame_record.idCASCADE, unique on(game_id, seq)), migrationf54e8ebd4583_game_history_phase_9.py(autogenerated cleanly against the two new tables only).app/rooms/service.py:_archive_game, hooked into_finish_room(GameRunner's completion callback). Reads the room's full event history off the live bus, keeps only events carrying acorrelation.game_id(everything the referee published, including its ownroom.stoppedon cancellation --RoomService's ownroom.created/room.seat_filledcarry no game_id and are excluded), builds oneGameRecordfrom thegame.started/game.finishedpayloads, and archives every kept event as aGameEvent. Plusget_game_record/list_game_records/game_eventsread methods.app/rooms/guard.py:resolve_subscriber, the HTTP-layer admission wrapper around Phase 7'ssubscription_guard, moved here (public) fromapp/api/routes/rooms.py's private_subscriber_forso the new/gamesroutes reuse the exact same room-admission check the live/eventsroute already uses -- one place, not two that could drift.app/api/routes/games.py(new router, prefix/games):GET /{game_id}(summary),GET /{game_id}/events(the durable counterpart to the live route -- samefilter_for_subscribercall, reconstructing anEventEnvelopefrom each archived row),GET /{game_id}/replay?seat=&upto=(rebuilds one seat'sPlayerViewat any point via the existingReferee.replay, truncating the archived move log touptomoves; same own-seat-only privacy rule as the live/view).- Review-caught-by-self bug, fixed before this ever reached a reviewer:
the first draft nested
_archive_gameinside_finish_room's "only write if stillin_progress" guard. That guard exists becausestop()races this exact callback (both await the same cancelled task; either can resume first) and must not double-writeroom.status-- but the callback still fires exactly once per game regardless of who wins that race, so nesting archiving inside it meant archiving was silently skipped every timestop()happened to win. Caught by writing (and running)tests/rooms/test_game_history.pybefore considering the phase done, not by luck: the HTTP-level "stop a room, check it archived" tests happened to pass either way depending on real scheduling order, so a dedicated deterministic regression test was added --test_finish_room_archives_even_when_stop_already_won_the_status_raceconstructs the room already"stopped"(simulatingstop()having already won) before calling_finish_room, which fails against the original nested version and passes against the fix. Verified both directions by hand (temporarily reintroducing the exact buggy guard, confirming the test fails with the expectedLookupError, then restoring the fix). - Also fixed during the same pass: the archive's
GameRecord+GameEventinserts hit aForeignKeyViolationunder SQLAlchemy's default flush ordering (no explicitRelationship()between the two models for it to infer dependency order from); fixed with an explicitsession.flush()between adding the record and adding its events, forcing correct ordering rather than relying on the flush planner. - Deferred, precisely: no retry/backlog for a failed archive (logged, not
re-attempted); no pagination past a generous
limit=100_000on either the live-bus read or the archived-row read. - Tests:
tests/rooms/test_game_history.py, 14 new tests against a real stoppeduno_classicgame (realGameRunner, real Postgres): archiving correctness (record fields, event filtering, seq ordering), the race regression above, auth-gate/404 smoke tests per route, event-visibility redaction (a non-privileged seat never sees the other seat's archivedgame.view_updated; the creator sees both), pagination vianext_since, and replay's own-seat-default/cross-seat-403/no-seat-400/upto-422 paths. Full suite: 751 tests, ruff/ruff-format/mypy strict/ty all clean.
- Agent-published events never archived (HIGH, correctness): the
race fix above was self-caught before review; this one wasn't.
AgentController._publishstamped nocorrelationat all, so everyagent.reasoning,agent.turn_metrics, and agent-authoredchat.messagewas silently dropped by_archive_game'scorrelation.game_idfilter -- contradictingdocs/games.md's own claim thatagent.*/chat.*get archived. Root cause:AgentControlleris constructed beforeReferee.__init__generates itsgame_id, so it had nothing to stamp with. Fixed by threading it through:AgentControllergains agame_id: str | Noneattribute (startsNone);Referee.__init__duck-type-sets it on any controller that declares the attribute (same pattern as the existingsay/hearhooks), right after generating its owngame_id;_publishnow includescorrelation={"game_id": ...}once set. New regression test:test_referee_threads_its_game_id_into_agent_controllersintests/unit/referee/test_referee.py. - Inconsistent 404 message between "unknown game" and "game exists but
room denies you" (LOW, security): two different
detailstrings for the same status code let a caller who already holds agame_iddistinguish the two cases -- a one-bit side channel, and a regression againstget_room's own explicit "never distinguish denied from does not exist" rule. Fixed with a shared_admitted_recordhelper ingames.pythat unifies both failure branches to the identical"Game not found"404 across all three routes. New test:test_unknown_game_and_denied_game_404_with_the_same_body. - Archiving completeness depends on the live bus's own retention
(MEDIUM, correctness, documented not code-changed): both
EventBusimplementations bound each room toEVENT_STREAM_MAXLEN(10,000) events;_archive_gameonly sees whatever is still there when the game ends. Ten thousand events for one game is enormous headroom for these games' actual lengths, so left as a documented dependency (docs/games.md"Left for later") rather than a code change -- the cleaner fix (archive incrementally during play, not only at the end) is future work, not urgent. - Reviewed and accepted as-is (no change needed): the explicit
session.flush()before addingGameEventrows is correct and sufficient (confirmed the whole archive is one transaction -- a later failure rolls back theGameRecordinsert too, no orphaned summary);replay's unfiltered fetch ofgame.move_appliedrows is safe because that event type is always publishedPUBLIC(verified inreferee.py::_commit), and the only channel back to the caller is the seat-scopedplayer_view, never raw state or the raw move list; no realistic IDOR/enumeration risk viagame_id(a 122-bituuid4().hex, and every route re-derivesroom_idand re-checks admission regardless); the three/gamesGET routes carrying no rate limit matches every other read-only room route in this codebase (GET /rooms/{id},/queue,/view,/moves/schema, the live/events) -- not a gap introduced here. - Full suite after fixes: 753 tests, gate clean.
A deliberate pause between backend work and the frontend (Phase 10): stabilize, document, and rebrand everything built so far. No new game features. Done with the planner's structure but the planning itself done directly rather than through a dedicated planner-agent call (full context of all nine prior phases was already in hand; a fresh planner subagent would only have re-derived it from this same PROGRESS.md at real cost for no added judgment) -- git worktrees and parallel subagents were used for the three genuinely independent pieces per the brief.
- Dependency audit (
docs/development/dependency-audit.md): backenduv lock --upgrade(30 packages within existing ranges: fastapi, sqlalchemy, sqlmodel, starlette, uvicorn, pydantic, alembic, psycopg, the dev toolchain), frontendbun update(react/react-query/router, tailwindcss, biome, vite, ~20 packages). Two major-version bumps (@tanstack/react-tablev8->v9,typescriptv6->v7) deliberately left for Phase 10 to decide, since they'd need real code migration in files Phase 10 is about to touch anyway;axios/form-data's exact pins left alone (no record of why they're pinned, not worth risking). Python 3.14 consistency confirmed (.python-versionread by every CI workflow that needs it). Full suite green after (753 tests), frontend build/lint clean. - Documentation reorganized into
docs/:docs/architecture/gets one historical-snapshot file per completed phase (00 through 9, plus 2b for the template-adoption pivot) mined from this file's own per-phase records, pluslatest_architecture.md(always-current, read fresh from the actual code -- module layout, data flow, every HTTP route, every DB table, every event type).docs/api/api-reference.mdgenerated from the real route/model code, not memory.docs/stack.mdconsolidates the olddocs/research/stack-choices.md(deleted).docs/development/setup.mdanddocs/deployment/{staging,production}.mdfold in real content from three stray root docs (development.md,deployment.md,deployment-docker-compose.md, all deleted), rewritten for botpit with no template placeholders -- staging/production stated plainly as planned, not live, per the standing no-server-until-local constraint.release-notes.md(the upstream template's own changelog) deleted outright. Everything else already underdocs/was reviewed and left in place (already matched the target structure) exceptdocs/persistence.md, which was rewritten -- it was stale, still describing Phase 1's async-SQLAlchemy/SQLite design that Phase 2b superseded.DECISIONS.md/PROGRESS.mdstay at the repo root as the project's permanent decision/status logs, not "documentation to reorganize" -- not named in the target structure and moving them would break every cross-reference in this whole build. scripts/dev.sh: one-command tmux launcher (sessionbotpit-dev; windows for the backend with reload, the frontend dev server, Docker Compose Postgres/Redis, and container logs). Idempotent (attaches to an existing session instead of erroring), guards the frontend window in case it's ever unscaffolded again, and was actually run twice (not just syntax-checked) to confirm every window starts cleanly and the idempotency check works.- Rebranding: removed every trace of the template's own cosmetic
identity -- package names (
backend/pyproject.toml, root and frontendpackage.json), the frontend's title/favicon/logo/footer credits (the FastAPI lightning-bolt logo and its four SVG assets deleted, replaced with a plain "botpit" wordmark -- no fabricated visual identity invented), nine template screenshot/preview images under rootimg/(deleted, unreferenced by the rewritten README).DECISIONS.md,PROGRESS.md, and.github/workflows/*deliberately untouched -- the first two legitimately document the template-adoption decision as history, and the workflows' template references are a standing, recorded deferral to Phase 14 (this file's own Phase 2b note), not an oversight. - Root files:
README.mdrewritten marketing-facing (what botpit is, a quickstart pointer todocs/development/setup.md, links out todocs/for everything technical, no deployed-instance claims).CONTRIBUTING.mdrewritten with the real branch/PR/test conventions. AddedLICENSE(MIT),CODE_OF_CONDUCT.md(Contributor Covenant v2.1),SECURITY.md(private GitHub security-advisory reporting, given real auth/RBAC now exists), andllms.txt(a factual, non-marketing project summary for crawlers/LLM tools -- explicitly notes HTTP-polling only and no frontend yet, so it can't go stale-by-omission). - Two real regressions caught by re-running the full gate after every
merge, not assumed away: renaming
backend/pyproject.toml's package off"app"broke hatchling's default wheel file-selection heuristic (it looks for a source directory matching the project name; the actual tree is stillapp/) -- fixed with an explicit[tool.hatch.build.targets.wheel] packages = ["app"]. Renamingfrontend/package.json's package off"frontend"broke everybun run --filter frontend ...call site this repo's own tooling depends on (rootpackage.json's scripts,scripts/generate-client.sh, and theprek"Generate Frontend SDK" hook that calls them) -- fixed by updating those call sites to--filter botpit-frontend(leaving.github/workflows/deploy.yml's identical stale reference alone, per the same Phase 14 deferral as above). - Not done, explicitly, per the brief's own instruction to say so
rather than add unused placeholders:
robots.txt,sitemap.xml, and Open Graph/meta tags do not exist yet. They only make sense once Phase 10's frontend exists and is actually deployed somewhere crawlable, and neither is true yet -- there is no frontend and no hosted instance (a standing project constraint, not an oversight). Revisit this once Phase 10 ships and, separately, once staging/ production actually exist. - Full gate after every merge in this phase:
ruff check/mypy app/ty check appclean, backend suite 753 tests green, frontendbun run build/bun run lintclean,prek run --all-filesclean.
One job: every dependency onto its true latest stable version, verified,
falling back only where genuinely broken with the reason documented. Full
detail: docs/development/dependency-audit.md.
- Backend:
uv lock --upgradepicked up the small remainder Phase 9.5 left (ast-serialize,coverage,pydantic-corepatches); raised twopyproject.tomlfloors (redis[hiredis]to>=8.1.0,psycopg[binary]to>=3.3.5,<4.0.0) to close Dependabot PRs #8/#9 for real.pydantic-core2.49.0 exists butpydantic2.13.5 (itself the true latest) doesn't request it yet -- not a gap, verified against PyPI directly. - Frontend: attempted every deferral Phase 9.5 left, per this phase's
"actually try the real fix" mandate.
axios(1.18.0 -> 1.20.0) and@hey-api/openapi-ts(0.97.3 -> 0.99.0) upgraded clean.@tanstack/react-tablev8 -> v9 (confirmed genuinely GA, not still beta, via a live check before touching it): a real migration, not a version bump --useReactTable/getCoreRowModel/getPaginationRowModel->useTable/tableFeatures/createPaginatedRowModel,table.getState()->table.state,row.getVisibleCells()->row.getAllCells(),ColumnDefgains a leadingTFeaturesparam. MigratedDataTable.tsxand its two consumers (Admin/columns.tsx,Items/columns.tsx); verified via build, lint, and starting the dev server to exercise@tanstack/router-plugin's live route generation under the new types.react-email/@react-email/uiinpackages/react-email/(theemailsworkspace member) bumped to true latest 6.9.5 -- found via a Dependabot PR mentioning packages neither this nor the Phase 9.5 audit had looked at. Verified viabun run --filter emails exportproducing byte-identical HTML to the already-committedbackend/app/email-templates/*.html.typescriptv6 -> v7 attempted, then reverted: 7.0.2 builds, lints, and runs the dev server fine on its own, but@hey-api/openapi-ts0.99.0 crashes under it (Cannot read properties of undefined (reading 'AnyKeyword')) -- confirmed as a currently-open upstream bug (hey-api/hey-api#4235), not fixable here. Reverted to^6.0.3. This is the one genuine "latest is broken, fall back and document" case this phase hit.
- Docker/CI: confirmed via live checks that
postgres:18/redis:8-alpineare still each technology's current major (PostgreSQL 19 is in beta, not shipped stable); bumpedzizmorcore/zizmor-actionto v0.6.3 (PR #5's exact target). Found, flagged, not fixed (out of scope for a version-only phase, and.github/workflows/*is a standing Phase-14 deferral):zizmor.yml'sadvanced-securitygate still checksgithub.repository_owner == 'fastapi', a leftover template-org check. - Housekeeping found while verifying:
packages/react-email/.react-email/(that package's build cache) had no.gitignoreentry anywhere in the repo -- added one. - Dependabot reconciliation: closed all 5 open PRs (#4, #5, #8, #9, #16),
each with a comment pointing at the commit that superseded it -- every
one covered at the same or a truer-latest version except
typescript(documented exception above, not a security advisory). - Full gate:
ruff check/mypy app/ty check appclean, backend suite 753 tests green, frontendbun run build/bun run lintclean,prek run --all-filesclean.
The real React frontend: play, spectate, admin, and the click-any-seat
focus view (DECISIONS §10), replacing the template's login/settings/items
scaffolding as botpit's actual UI. docs/rooms.md's "left for later"
section also assigns the real-time transport (websocket or SSE, on top of
Phase 8's HTTP-polling /rooms/{id}/events) to this phase — the polling
route stays as a working fallback, but the frontend needs push, not
polling, for a live table to feel live.
Frontend-design-taste, motion, and frontend-engineering skills were
checked against the ECC/frontend-design marketplaces before starting
(installed already, no new installs needed) — see the session record for
the full search. In active use this phase: frontend-design,
ecc:frontend-design-direction (visual identity), ecc:motion-foundations/
ecc:motion-patterns/ecc:make-interfaces-feel-better (turn transitions,
reasoning-reveal, chat), ecc:react-patterns/ecc:react-performance/
ecc:frontend-a11y (component architecture, re-render discipline on a
high-frequency event stream, accessibility).
Planned task breakdown (not all worktree-parallel — the realtime transport blocks the live-view work, so that ordering is sequential; the rest split reasonably across independent files):
- Visual identity (
frontend-design) — done, see below. - Realtime transport (backend) — a websocket endpoint reusing
subscription_guard/filter_for_subscriberexactly asdocs/rooms.mdanticipates (call the guard once per connect instead of once per poll; the guard's own logic doesn't change). - Routing shell + lobby — replace the template's generic layout/nav; public room listing, create-room flow (game/seat-count/seat-spec form), join-by-code, queue-status display.
- Live game view — seat rail, hand rendering (UNO variants share a
visual language; Hearts' trick-taking shape is different), turn
indicator, move submission against
move_json_schema, chat. - Spectator/admin focus view — click-any-seat privileged view: full
hand, live
player_view, AI reasoning stream (agent.reasoning, privileged-only, ordered before the move it explains per DECISIONS §10), model/persona display. - Auth flow adaptation — verify the template's existing login/signup/
settings routes against botpit's actual auth API; remove the template's
Itemsdemo (not botpit's domain). - Polish pass — motion on turn transitions/reasoning-reveal/chat, accessibility pass, re-render audit on the event-stream-driven views.
frontend/src/index.css's shadcn token values replaced (every token
name kept, so no component needed touching): a cardstock/ink/felt/
suit-red palette pulled from the subject itself instead of a generic
accent, Fraunces (display) + IBM Plex Sans (body, real tabular figures —
no monospace data labels), --radius 0.625rem -> 0.5rem. Full
rationale, palette table, and the specific AI-generated-design tells this
deliberately avoids: docs/development/frontend-design.md.
Every text/background pair checked against real WCAG contrast math (not
eyeballed) — two pairs failed the first pass and were corrected before
landing: dark-mode primary-foreground (3.11:1 -> 5.12:1, switched from
near-black to light cardstock text) and dark-mode accent (4.46:1 ->
5.24:1, #e14a44 -> #e6605a). bun run build/bun run lint clean.
No visual screenshot review: this environment's Chrome DevTools MCP
tooling couldn't launch a browser target (Target closed on every
attempt) — noted plainly in the doc rather than skipped silently; a real
visual pass is still owed once there are real game screens to look at.
WS /rooms/{room_id}/ws added to backend/app/api/routes/rooms.py: a
websocket push transport over the exact same subscription_guard/
filter_for_subscriber pipeline GET /events uses (resolve_subscriber
resolved once per connect instead of once per poll; the guard's own logic
is untouched, per docs/rooms.md's own anticipated design). Auth travels
as a ?token= query param (browsers cannot set a websocket's
Authorization header) through a small _current_user_ws helper mirroring
get_current_user's JWT check; every refusal (bad/missing token, unknown
room, denied subscription) closes with the identical 1008 code, so a
caller can never distinguish one from another -- preserving the REST
layer's "never leak existence" rule on the new transport too. ?since=
resumes exactly like the polling route's own cursor. Docs updated:
docs/rooms.md (new "Real-time transport" section, replacing the stale
"left for later" note), docs/api/api-reference.md (new route row plus
the events-and-visibility intro), llms.txt ("Current transport").
Found and fixed one real discovery mid-implementation:
backend/app/api/deps.py briefly appeared, through every text-reading
tool (Read, sed, git show, even Python's own open()), to contain
invalid Python-2-style except syntax with a matching fabricated sha256
-- while three independent behavioral checks (a fresh bytecode compile
with the cache forcibly cleared, a module import reaching past that exact
line into an unrelated circular-import failure, and the full 753-test
suite passing clean) proved the real file was never broken. Treated as a
tool-output integrity anomaly for that one read, not a real bug, and left
untouched -- flagged to the user rather than "fixed" based on
self-contradicting evidence.
New tests: backend/tests/rooms/test_rooms_ws.py (9 tests) -- auth/
admission (missing token, invalid token, denied subscription, unknown
room, all closing 1008), parity with GET /events for an identical
subscriber, the two security-boundary tests mirrored from
test_rooms_live.py (non-privileged player never sees another seat's
view; privileged sees every seat's), a genuine live-push test that never
calls GET /events at all, and a reconnect/resume test. receive_json()
has no native timeout and would hang the whole suite if a real regression
ever silently stopped delivering events -- wrapped in a bounded
ThreadPoolExecutor-based timeout so a transport bug fails one test
instead of hanging CI.
Full gate: ruff check/ruff format --check/mypy app/ty check app
clean, backend suite (762 tests, up from 753) green.
Built together in one continuous pass since the play/spectate/admin surfaces share the same live-state plumbing. Real screens, not scaffolding:
src/lib/events.ts-- theEventEnvelope/per-type payload shapes hand-written to mirrorbackend/app/events/schema.py: the polling/ws routes hand back raw dicts, not a Pydanticresponse_model, so none of this exists in the generated OpenAPI client.src/lib/roomLiveState.ts-- a pure reducer (applyRoomEvent) folding the event stream into compact derived state (viewsBySeat,reasoningBySeat,currentSeat,recentMoves, ...) instead of handing every consumer a raw, ever-growing event array to re-scan on every render -- this is where "avoid unnecessary re-renders on a high-frequency stream" actually gets addressed, architecturally, not viaReact.memosprinkled after the fact.src/hooks/useRoomSocket.ts-- connects toWS /rooms/{id}/ws, drives the reducer, reconnects with backoff resuming from the last seenstream_id(neversince=nullon reconnect, only on the very first connect going forward from now).src/lib/cards.ts+components/Rooms/Card.tsx-- real card faces (not a JSON dump) for both card families this project's four games deal in (backend/app/games/cards.py'sUnoCard/FrenchCard), matching the cardstock/felt/suit-red visual identity from task 1.components/Rooms/MoveList.tsx-- every game (backend/app/games/*/moves.py) enumerates each fully legal move (wild-colour declarations included) as its ownlegal_movesentry, so the UI just renders them as clickable options -- no client-side move-building form needed. The one exception, Hearts'pass_cardsphase (enumerates every 3-card combination, up to 286 -- rendering those as buttons would be absurd), gets a real multi-select-then-confirm UI, detected generically by movekind, not by game slug.components/Rooms/GameTable.tsx-- the player's own live view: hand, generictablestate rendering, turn indicator, opponents' hand counts.components/Rooms/FocusView.tsx-- the privileged spectator/admin view (DECISIONS.md §10): click any seat, see its hand/player_view, model/persona, and live AI reasoning, ordered so reasoning streams in before the move it explains lands (falls out naturally:agent.reasoningpublishes beforegame.move_applied/game.view_updated, and this component just renders whatever the reducer currently holds).components/Rooms/CreateRoomDialog.tsx/JoinByCode.tsx-- room creation (game/visibility/seat-count/per-seat kind+persona) and join-by-code, replacing the template's Items demo on the lobby route (src/routes/_layout/index.tsx, now the public room list).src/routes/_layout/rooms.$roomId.tsx-- ties it together: waiting room + start/force-start controls, the live game table oncein_progress, the focus view for privileged callers.- Removed the template's Items feature entirely (route, components, Playwright spec, sidebar nav entry) -- not botpit's domain, per task 6.
- Chat explicitly not built here:
PROGRESS.md's own phase table scopes "Chat and personas" to Phase 11, and there is currently no backend endpoint to send a chat message at all (only thechat.messageevent type exists) -- building a send UI now would mean inventing backend surface this phase didn't ask for. The focus view renderschat.messageevents generically if any ever appear, but ships no compose/send affordance.
Real bugs found and fixed along the way (via an actual two-human Playwright run against the real backend, not assumed):
POST /rooms/joinreturned the caller's seat but never the room's own id -- a caller who joined by code had no way to navigate to the room at all. AddedRoomSeatJoinPublic(backend/app/models.py) as a proper typedresponse_model, not adictworkaround.app/api/routes/private.py's dev-only user-creation endpoint (used by every Playwright test viatests/utils/privateApi.ts) built theUserrow directly, bypassingensure_default_roleentirely -- every Playwright-created test account had zero permissions. Latent since Phase 6; only surfaced once a Playwright test exercised a permission-gated route (room:create) through this helper for the first time. Fixed to matchapp.crud.create_user's real-signup path.- The room-detail page's seat list came from a one-shot REST fetch with
no live equivalent for "a seat got filled" -- a page left open before
someone else joined kept rendering every seat as it was at load time
forever. Added
RoomLiveState.seatsVersion(bumped onroom.seat_filled/game.started) and an effect that invalidates the REST query when it changes. CreateRoomDialognever seated the creator into any seat (every human seat was left fully open) -- the first join-by-code claimed the creator's own intended seat instead, and the creator ended up with none. Fixed by seating the creator into the first human seat directly, perRoomService.create's own documented rule.
Motion and accessibility (the two other skills chosen for this
phase, alongside frontend-design): one deliberate motion moment each on
GameTable (the turn-indicator heading re-announces itself, keyed on
current_seat) and FocusView (the reasoning panel reveals itself, keyed
on the reasoning's own timestamp) -- both motion-safe:-gated, using the
project's own existing tw-animate-css vocabulary (animate-in fade-in-0 slide-in-from-*, already used by components/ui/dialog.tsx),
not a new motion library. Real a11y fixes found by inspection: two unlabeled
Select triggers in CreateRoomDialog (seat kind/persona -- only a
neighboring <span>, never announced as the control's label) got
aria-label; the join-code Input relied on placeholder alone (a known
anti-pattern -- disappears on input, unreliably announced) and got a real
aria-label too.
Tests: frontend/tests/rooms.spec.ts (real Playwright, real backend,
no mocks) -- the lobby's create/join controls render; creating a private
2-seat room shows both seats; and the full loop end to end with two real
browser contexts as two real users: create, join by code, start, and play
a real move, verified live on both pages via the websocket transport
(never calls GET /events at all, so it actually proves push, not
polling). tests/login.spec.ts and the shared logInUser helper updated
(their post-login assertion checked text from the template's old
Dashboard, now the Lobby). tests/items.spec.ts and its now-dead
randomItemTitle/randomItemDescription helpers removed along with the
feature.
Full gate: backend suite (762 tests) green; bun run build/bun run lint
clean; all 56 Playwright tests green (the existing 53 plus 3 new,
including the full two-human live-play flow) run against the real docker
compose stack, mirroring CI's own test-playwright steps.
code-reviewer/python-reviewer and react-reviewer each reviewed the
PR. Real, fixed:
- Token in the WS connection URL (react-reviewer, HIGH): the real,
long-lived
access_tokenhad to travel as?token=(browsers can't set a websocket'sAuthorizationheader) -- a much wider log-exposure surface (proxy/server access logs, browser history) for the socket's whole session than a header ever gets. AddedGET /rooms/{room_id}/ws-ticket(Bearer-authed, mints a 30-secondsecurity.create_access_tokenticket) and switcheduseRoomSocket.tsto fetch one right before connecting instead of readinglocalStorage's real token directly. - Room live state never reset on room switch (react-reviewer, HIGH):
RoomDetailisn't remounted just because$roomIdchanges, souseRoomSocket's reducer and resume cursor were scoped to the component instance, not the room -- navigating room-to-room without an intervening unmount would keep the old room's finished/hand/reasoning state visible and resume the new room's feed from the old room's cursor. Added aRESET_EVENT_TYPEsentinel action + reset oflastStreamIdat the top of the[roomId]-keyed effect. CreateRoomDialogonly clamped seat count to the new game'sminSeats, nevermaxSeats(react-reviewer, HIGH): bump to 10 seats on Classic UNO, switch to Hearts (max 6), and the count silently stayed at 10 -- the create request would then 422 with no indication the seat count was the problem. Fixed to clamp both bounds.- Hearts' pass-cards selection was keyed to array index, not card
identity (react-reviewer, HIGH): if the hand's order or contents
changed between the render that recorded a selection and the render
that submitted it (any intervening
game.view_updatedfor this seat), the submitted indices could silently point at different cards than what was clicked. Fixed to select by content identity (safe here: a standard deck never has two identical cards in one hand) and re-derive the actual current indices from the livehandat submit time, never from stale click-time positions. Applied the same index+content composite key to the two other index-keyed hand-rendering lists found in the same pass (GameTable.tsx,FocusView.tsx) while in there. - No rate limiting on
WS /rooms/{room_id}/ws(python-reviewer, MEDIUM): every other room route that does real work is wrapped inrate_limit_from_setting, but that dependency is coupled toCurrentUser's HTTP-only Authorization-header flow, which this route deliberately doesn't use. Rate limited by client host instead, before any token decode, using the same underlyingcheck_rateprimitive directly. ensure_default_role's no-op-when-role-missing was silent (python-reviewer, MEDIUM): a real misconfiguration (a role row deleted, a seed step that failed) would quietly grant every new account zero permissions with nothing pointing at the cause. Added a warning log on that branch specifically (not the "already has the role" branch, which is a legitimate no-op).?since=on the WS/polling routes required a hyphenatedstream_id-shaped cursor, rejecting the bus's own bare"0"convention (python-reviewer, MEDIUM, found while reviewing the WS route's docstring against its own query validation): widened^\d+-\d+$to^\d+(-\d+)?$on both routes.join_room's reveal default was implicit (python-reviewer, LOW): madereveal=Trueexplicit for self-documentation, matching every other redaction-aware call site in the file.
New tests: test_ws_ticket_is_bearer_authed, test_ws_ticket_works_as_a_ws_token,
test_ws_since_accepts_bare_seq_not_just_hyphenated_cursor (762 -> 765
backend tests).
Not fixed, deliberately: python-reviewer's suggestion to factor the
duplicated JWT-decode logic between get_current_user
(backend/app/api/deps.py) and _current_user_ws into one shared
helper. A real, valuable suggestion -- but an Edit attempt against
deps.py failed to match even a careful, correct reconstruction of its
own content, the same tool-output-integrity anomaly already on record
for this exact file (see the realtime-transport section above). Given
rooms.py's own edits worked reliably all session and only deps.py's
reads/edits are affected, skipped rather than risk a corrupting edit to
working code for a non-blocking code-quality improvement.
The anomaly recurred and deepened during this review round: both
review subagents independently reported a genuine Python-2-style
SyntaxError in backend/app/api/routes/rooms.py's
_current_user_ws (and, per python-reviewer, the identical pre-existing
pattern in deps.py) -- this time backed by claims of cross-verification
via a fresh git clone and GitHub's own content API, not just a local
Read. That additional "independent" verification is not actually
independent: it is mediated through the exact same tool-reading
substrate already shown unreliable for this one pattern, so agreement
between two compromised reads doesn't triangulate to truth. The decisive,
genuinely external tiebreaker: GitHub Actions' own test-backend log for
this PR's actual head commit (gh run view --log) shows 771 passed, 8 deselected -- a real SyntaxError would fail collection before a single
test ran, on a machine neither this session nor either subagent's tool
calls have any path to influence. Both rooms.py and deps.py remain
confirmed correct and untouched on this point; no code change was made
in response to either report.
Started by re-checking scope rather than assuming: personas
(app/agents/persona.py, 6 built-ins) and AI-side chat generation
(AgentController.say(), build_chat_prompt) already existed since
Phase 5 -- an AI seat already chats in its own persona's voice and
publishes real chat.message events. The actual gap was narrower than
the phase name suggests: humans had no way to send a chat message at
all (confirmed: zero chat routes anywhere), and AgentController.hear()
existed but nothing ever called it, so an AI never learned about anyone
else's chat either.
RoomService.send_chat(user, room_id, text)(app/rooms/service.py): resolves the caller's own seat (own_seat, same as/moves//view), validates/strips text (empty or >200 chars ->ValueError-> 422, matching the AI side's own 200-char cap), publishes a publicchat.message. Unlikesubmit_move, not gated onRoomNotRunningError-- chat never touches game state (DECISIONS §8), so it works inwaitingtoo. If a game is running: tags the event with that game's owncorrelation.game_id(so it archives alongside the game exactly like an agent's own chat already does) and feeds the message to every AI seat'shear()-- the first thing that ever calls it. AI-to-AIhear()fan-out (one agent's chat reaching a sibling agent'srecent_chat) remains unwired -- a separate, older Phase-5 gap, found while wiring the human side, deliberately not fixed here.POST /rooms/{room_id}/chat: rate limited (RATE_LIMIT_CHAT, already a scaffolded-but-unused setting, default10/10), 403 for an unseated caller, 422 for empty/oversized text.- Frontend:
components/Rooms/Chat.tsx-- message list (seat badge, showing an AI seat's persona instead of a bare seat number) plus a compose box, gated on having a seat (matching the backend's own-seat rule, rather than letting a spectator submit and hit a confusing 403). Wired into the room detail page for everyone, not just privileged callers -- chat is public. Fixed a real type inaccuracy insrc/lib/events.ts/roomLiveState.tsfound while building this:ChatMessagePayload.seat/ChatEntry.seatwere typednumber | null; the real schema (ChatMessage(seat: int, text: str)) never allowsnull. - Found and fixed while documenting the route (not asked for, but a
direct accuracy/consistency issue):
GET /rooms/{room_id}/events'ssincequery pattern was still^\d+-\d+$, rejecting the bus's own bare"0"convention -- the websocket route's identical pattern was already widened to^\d+(-\d+)?$during PR #20's review-fix round, but the polling route was missed, breaking the "both transports must never diverge" principle Phase 10 was built on. Widened to match. Also founddocs/api/api-reference.md'sWS /wsrow still documented the pre-review-fix token flow (rawaccess_token) and the oldsincepattern -- corrected, and added thews-ticketrow it was missing.
New tests: backend/tests/rooms/test_chat.py (6 tests: send visible via
GET /events, works before the game starts, 403 for an outsider, 422 for
empty/oversized text, rate limited) and
test_room_service.py::test_send_chat_reaches_the_agent_seats_hear (a
pure, synchronous check of the hear() fan-out -- no real LLM call
needed to observe it). frontend/tests/rooms.spec.ts: a real two-human
chat exchange, both directions, both pages, confirmed live over the
websocket (765 -> 772 backend tests; 56 -> 57 Playwright tests).
Full gate: backend suite (772 tests) green, bun run build/bun run lint
clean, full Playwright suite (57 tests) green against the real docker
compose stack, prek run --all-files clean.
python-reviewer and react-reviewer each reviewed the PR. Real, fixed:
Chat.tsx'sdraftcompose-input state never reset on room switch (react-reviewer, HIGH): the exact same bug class already fixed once inuseRoomSocketduring PR #20's review round --Chatisn't remounted just becauseroomIdchanges, so a half-typed message could survive a room switch and get sent into the wrong room. Added a[roomId]-keyeduseEffectclearingdraft(with abiome-ignoreonuseExhaustiveDependencies, sinceroomIdis a reset trigger here, not a value the effect body actually reads).- Chat message list had no
aria-live/role(react-reviewer, HIGH): new messages arrive asynchronously over the websocket, independent of any user action, with nothing telling a screen reader they arrived. Addedrole="log"aria-live="polite"to the message-list container. key={entry.ts + entry.seat}was an unsafe, delimiter-less composite key (react-reviewer, MEDIUM): the server'sts(Pythondatetime.isoformat()) is variable-length when microseconds are exactly zero, so the concatenation isn't collision-safe. Threaded the event envelope's own already-uniqueevent_idintoChatEntry(roomLiveState.ts) and keyed on that instead.send_chat'stextparameter shadowed the module-levelsqlalchemy text()import (python-reviewer, MEDIUM): harmless today, but a trap for the next edit to that method. Renamed the parameter tomessage.- 200-char chat cap was a bare magic number (python-reviewer, MEDIUM):
named it
_MAX_CHAT_CHARS, matching this file's own precedent (_MAX_CODE_ATTEMPTS,_START_LOCK_KEY).
Not fixed, deliberately: python-reviewer's optional suggestion to add
a Pydantic-level max_length to ChatSend.text (LOW) -- skipped because
own_seat's identity check deliberately runs before any content
validation (an unseated caller always gets 403, never a validation error
that would leak whether their body was otherwise well-formed), and a
schema-level length constraint would run before that check, inverting the
order for oversized bodies. The existing per-user rate limit already
bounds the abuse case; not worth the ordering trade for it. Also not
fixed: the pre-existing (not introduced by this PR) dead LookupError ->
404 branch on /chat, /moves, and /view -- none of the three ever
call service.get(), so a nonexistent room 403s (via own_seat) rather
than 404s. Consistent with existing precedent on the other two routes;
flagged by python-reviewer as informational, not a regression.
The recurring tool-output anomaly is resolved. python-reviewer traced
it to ground truth: except InvalidTokenError, ValidationError: in
rooms.py/deps.py is not Python-2 syntax at all -- it's valid under
PEP 758 (unparenthesized
multi-exception except), accepted starting Python 3.14, and this
project's pyproject.toml pins requires-python = ">=3.14,<4.0".
Confirmed parsing correctly under the project's actual uv-managed
3.14.7 interpreter. There was never a phantom tool-reading bug -- every
prior report of a "SyntaxError" here was a reviewer (human or agent)
correctly reading genuinely new, valid syntax and misclassifying it as
an old error. No code change needed; this closes out the anomaly noted
repeatedly in Phase 10's review-fix section above.
Self-planned (no formal spec; a direct continuation of context already in hand, per CLAUDE.md's "phases, not sprints" guidance). DECISIONS.md §11: "leaderboard per human account and per model, with aggregate admin views."
- The core design question: a finished game's
GameRecord.winner_seatnames a seat, not a player -- something has to tie that seat back to the account/model that actually played it. Checked rather than assumed whether a new snapshot table was needed: it isn't.RoomService.start()refuses any room that isn't"waiting", and nothing anywhere ever puts a room back into"waiting"-- confirmed by reading every status transition inapp/rooms/service.py, not by inference -- so a room plays exactly one game for its entire lifetime.RoomSeat(immutable from the momentstart()runs) already is that one game's durable participant record.app.leaderboardjust joinsgame_recordtoroom_seatonroom_id. Full reasoning:docs/leaderboard.md. app/leaderboard.py:leaderboard_by_account/leaderboard_by_model, each an aggregate SQL query (join + group by +func.sum(case(...))for wins), taking an optionalgameslug filter. Onlyoutcome == "finished"rows count -- a"stopped"game'swinner_seatisNone, so it has no result to credit.display_namefor the accounts view iscoalesce(full_name, email)computed in SQL, not Python: SQLModel's typedselect()only has overloads up to four positional columns, and pullingfull_name/emailas two separate columns would have been a fifth.GET /leaderboard/accounts?game=,GET /leaderboard/models?game=(app/api/routes/leaderboard.py): gated by a newleaderboard:viewpermission, deliberately granted to every default role (admin/player/spectator) rather than admin-only -- a leaderboard is meant to be seen by the players in it; "aggregate admin views" just means the same data also surfaces from the admin side, not that it needs a separate, more-restricted endpoint. New permission needed its own Alembic migration (30c74160ca50) rather than editing the original Phase 6 seed migration: that migration's ownALL_PERMISSIONS/DEFAULT_ROLESimports are live, growing constants, so re-running it would re-seed permissions it already inserted and collide on the uniquenameconstraint. The new migration names its one new permission literally instead.- Frontend:
/leaderboard(new sidebar nav item, shown to everyone, matching the permission grant) -- two tabs (Accounts, Models) sharing one column set (components/Leaderboard/columns.tsx) over the sameLeaderboardEntryshape, plus a game-filterSelect. ExtractedCreateRoomDialog.tsx's hardcoded four-game catalog intolib/games.tsso the filter dropdown could reuse it instead of duplicating it. - A wrong turn, caught before it shipped: first drafted a "fix" in
RoomService.start()to persist a resolved default model/persona back onto an agent seat, reasoning that a seat created without an explicit model would otherwise staymodel=Noneforever -- a real-looking gap for a per-model leaderboard. Wrote the fix, then verified it empirically (a throwaway script creating a real agent seat with no explicit model) before trusting the reasoning, and the premise was false:RoomService.create()already resolves and persists the default at seat-creation time (model = seat_spec.model or self.registry.default_model, written into the row immediately), andforce_start's backfill does the same for the seats it creates. Thestart()-time fallback is genuinely unreachable for any room made through the real API. Reverted the change rather than ship a fix (and a comment asserting a premise) for a bug that doesn't exist -- confirmed by re-running the same empirical check after reverting. - Type-checker friction, resolved properly rather than suppressed:
ty(this project's strict checker) rejectedRoomSeat.model.is_not(None)(plainstr | Noneat the class level, not a method SQLAlchemy's declarative typing exposes to a static checker without help) and several.join()/.group_by()calls comparing two model columns directly. Fixed by wrapping column references in SQLModel'scol()helper (the same pattern already used inapp/authz/service.py), not by suppressing the checker -- and dropped theis_not(None)filter entirely once the model-backfill investigation above proved it was defensive code guarding against a state that can't occur.
New tests: backend/tests/leaderboard/test_leaderboard.py (7 tests: wins
and games-played aggregate correctly across separate rooms, stopped games
excluded, model grouping ignores persona, the game filter scopes
correctly, both HTTP routes 200, the permission gate 403s when
leaderboard:view is revoked). One self-caught bug while writing them: an
early draft of the "stopped games are excluded" test asserted the entire
leaderboard was empty, but db is a session-scoped fixture shared across
every test in the file -- an earlier test's own rows were still present.
Fixed to scope the assertion to this test's own two accounts, matching
every other test in the file. tests/authz/test_authz.py::test_migration_seeds
also needed its hardcoded len(ALL_PERMISSIONS) == 12 bumped to 13 --
a real, expected consequence of adding a permission, not a regression.
frontend/tests/leaderboard.spec.ts (2 tests: tabs/filter render and
switch; the sidebar nav link reaches the page) (779 backend tests total,
59 Playwright tests total).
Full gate: backend suite (779 tests) green, ty/mypy/ruff clean,
bun run build/bun run lint clean, full Playwright suite (59 tests)
green against the real docker compose stack, prek run --all-files clean.
test-backend/pre-commit failed on a fresh CI database:
UniqueViolation on permission_pkey for leaderboard:view. Root cause:
a1b2c3d4e5f6_rbac.py (the original Phase 6 seed migration) imports
ALL_PERMISSIONS/DEFAULT_ROLES live from app/authz/permissions.py
and re-evaluates them every time it runs -- so on any database that has
never run a migration before (a fresh CI run, a new deployment), that
older migration runs first and already sees leaderboard:view in the
current module state, seeding it itself. The new migration
(30c74160ca50) then tried to insert the identical row again with a plain
op.bulk_insert and collided on the unique constraint. This never
reproduced locally because the local dev DB had already applied
a1b2c3d4e5f6 long before leaderboard:view existed -- the exact
scenario the new migration was written for, not the fresh-database one CI
actually hit. Fixed by switching both inserts to
postgresql.insert(...).on_conflict_do_nothing(...), correct either way:
a no-op if the old migration already seeded it (fresh DB), a real insert
if it didn't (an existing DB migrated before this permission existed).
Verified both paths directly against two throwaway Postgres containers
(one migrated clean from scratch; one migrated to the pre-Phase-12 head
with permissions.py temporarily reverted, then to the new head with it
restored) rather than trusting the reasoning alone.
python-reviewer and react-reviewer each reviewed the PR (plus an
extra general-purpose review dispatched by mistake on the first attempt --
kept its findings anyway since it independently re-verified the migration
fix). Real, fixed:
- Game-filter
Selecthad no accessible name (react-reviewer, HIGH): every otherSelectin this codebase pairs aLabel htmlFor/idor an explicitaria-label-- this was the only one with neither. Addedaria-label="Filter by game". - Loading/error state indistinguishable from "no games yet"
(react-reviewer, HIGH): neither query checked
isLoading/isError, so every page load flashed a false empty state, and a real failure (a permission regression, a 500) would render identically to "no games" forever, hiding it. Factored a small sharedLeaderboardTablecomponent (both tabs have the identicalLeaderboardEntryshape) that branches on loading/error/empty/data. - Docstring overstated the finished/winner_seat relationship
(general-purpose reviewer, doc nit): claimed only a
"stopped"game haswinner_seat == None, butReferee.run()(winner = ranking[0] if hands[ranking[0]] == () else None) can produce that for a"finished"game too. Not a code bug --leaderboard.pyalready handles it correctly (counts towardgames_played, credits no one a win) -- just a comment that overclaimed. Corrected the module docstring. - Cheap insurance for future phases (general-purpose reviewer's
suggestion): added a "Adding a permission after
a1b2c3d4e5f6" section todocs/persistence.mdnaming the exact trap this PR's own migration hit, plus the missing30c74160ca50row in that doc's migration-chain table.
Not fixed, deliberately: react-reviewer's MEDIUM suggestion to gate
the inactive tab's query behind enabled so only one of
accounts/models fetches at a time. Left both fetching unconditionally --
the aggregate query is cheap, and prefetching both means switching tabs is
instant rather than showing a spinner; left a one-line comment stating
this is deliberate rather than an oversight. Also not fixed: pagination on
the two list routes (python-reviewer, MEDIUM) -- already documented as a
found-but-deferred gap in docs/leaderboard.md's "Left for later" section
before either review even ran.
Self-planned (no formal spec; a direct continuation of context already in
hand). DECISIONS.md §14: "Prometheus /metrics... everything inspectable
locally." Scoped by first reading what already existed rather than
assuming: wide events (Phase 1), /health//health/llm (Phase 1/6) were
already fully built. Only two real gaps: no /metrics at all, and
docs/admin-and-observability.md's own ws.session wide event -- listed
in that doc's table since Phase 1 -- had never actually been wired into
the websocket route.
app/metrics.py(new): a dedicatedCollectorRegistry(notprometheus_client's process-global default, so/metricsis explicit about exactly what it exposes and test collection can't hit the classic "duplicated metric name" footgun).botpit_active_games/botpit_active_ws_connections(gauges),botpit_agent_turn_latency_seconds/_errors_total(per model),botpit_agent_turn_timeouts_total(agent seats only, notscripted),botpit_moves_total,botpit_model_swaps_total(perbase_url). "Moves per minute" (DECISIONS §14's own wording) is a plain counter, not a stored per-minute number -- graphingrate()over it is Prometheus's own idiom for exactly this.GET /metrics(new route, unauthenticated like/health): Prometheus text exposition of that registry.- Instrumentation, hooked at the existing single centralizing spot in
each case rather than adding new ones:
AgentController._metrics(already fed the wide event'sllm.calls) now also records the latency histogram and error counter;Referee._commit(already the one place a move lands) incrementsMOVES_TOTAL;Referee._take_turn's existingTimeoutErrorbranch increments the timeout counter for real agent seats;RoomService.start/_finish_room(already the exactly- once start/finish pair the archiving logic relies on) increment/decrementACTIVE_GAMESsymmetrically. app.metrics.note_llm_call(base_url, model): called right before every real outbound LLM completion request (AgentController.decide). Counts a swap when the immediately preceding call to the samebase_urlused a different model -- DECISIONS §13's "surface swap counts in metrics," not the model-swap- aware scheduler that decision also describes (grouping concurrent AI turns by model to minimise swaps). That scheduler does not exist and is explicitly out of scope here: this phase is Observability, not scheduling, and this counter is exactly what would tell a future phase whether building it is worth it. Neededbase_url: stradded to theLLMClientProtocol (both real adapters already had it;FakeLLMClientneeded abase_urlattribute added too, or every existing agent test using it would have started raisingAttributeError).ws.sessionwide event +ACTIVE_WS_CONNECTIONS(app/api/routes/rooms.py'sroom_ws): wraps the accepted-connection lifecycle (not the earlier auth/admission refusals -- a "session" is an established connection).events_sentviaev.incrper message sent;close_reason="client_disconnect"on a cleanWebSocketDisconnect; any other exception's type/message land in the wide event's ownerror.*fields automatically, same as every other wide event. The gauge is incremented right afteraccept()and decremented in afinally, so it comes back down regardless of how the connection ends.- Found and fixed in passing, not this phase's actual subject:
docs/admin-and-observability.md's wide-event table namedagent.turnas the event kind; the realevent_kind(and every seat kind, not just agents) has always beengame.turn-- corrected. - A real onboarding bug, found while adding this phase's one new
dependency (
prometheus-client): ran a bareuv syncfrombackend/to add it, which silently removedprek/zizmor/smokeshowfrom the shared venv -- they're workspace-rootdev/github-actionsdependency groups (rootpyproject.toml), not part ofbackend/pyproject.toml's owndependencies, and this repo has exactly one.venvshared by the whole uv workspace regardless of which member directoryuv syncruns from.docs/development/setup.mddocumented the exact bareuv synccommand that causes this -- meaning a fresh clone following that guide verbatim would never get a workingprek run --all-filesgate at all. Fixed the doc to useuv sync --all-groupsand explained why, restored the local venv the same way, and re-verifieduv run prek --version/uv run zizmor --versionboth work again before continuing.
New tests: backend/tests/observability/test_metrics.py (5: the route
returns valid Prometheus text, note_llm_call's swap-counting logic in
isolation, agent-turn latency/error metrics via a real decide() call, a
real Referee.run() agent-seat timeout incrementing the counter, the
registry never exposes anything but botpit_* names).
tests/rooms/test_room_service.py (+2: ACTIVE_GAMES tracks a real
start/finish cycle, MOVES_TOTAL increments on a real submitted move --
both poll rather than assume _finish_room's async completion callback
already ran, matching this file's own established pattern for that race).
tests/rooms/test_rooms_ws.py (+1: ACTIVE_WS_CONNECTIONS and the
ws.session wide event's fields, over a real websocket connection) (787
backend tests total; 59 Playwright tests, unchanged -- this phase touched
no frontend code).
Full gate: backend suite (787 tests) green, ty/mypy/ruff clean,
verified the new /metrics route against the real built Docker image
(not just TestClient), full Playwright suite (59 tests, unchanged)
green against the real docker compose stack, prek run --all-files clean.
python-reviewer reviewed the PR (no frontend changes this phase, so no
react-reviewer pass). Verdict: approve, no CRITICAL/HIGH findings. Real,
fixed anyway:
- Default histogram buckets didn't cover realistic LLM latency
(MEDIUM):
prometheus_client's default buckets top out at 10s, butLLM_TIMEOUT_SECONDSdefaults to 120s -- exactly the range this metric exists to characterize (a slow or VRAM-swapped call) would collapse into one+Infbucket, undercutting the whole point of the histogram. Set explicit buckets (0.5through120, plus+Inf). - A hard LLM failure recorded
latency_ms=0(LOW):with_retriesmay have already spent real wall-clock time retrying before giving up, and a persistent failure is exactly the call this histogram would most want timed accurately. Now measures real elapsed time around the call instead of hardcoding 0.
Verified, not fixed (already correct or already mitigated):
note_llm_call's synchronous check-then-write has no real race (no
await between the read and write, same reasoning ModelRegistry.client_for
already relies on -- confirmed by re-reading both); _finish_room's
unconditional ACTIVE_GAMES.dec() is correct in every real path (one
narrow, pre-existing-condition edge case noted -- a DB commit failing
immediately after a successful runner.start() -- already leaves
room.status inconsistent before this PR, out of this phase's scope);
FakeLLMClient.base_url being a shared constant combined with
note_llm_call's module-level dict never resetting between tests is a
real test-isolation gap in the abstract, but every test already avoids it
correctly (unique base_url per test, or before/after baseline diffing) --
added a one-line comment on FakeLLMClient.base_url stating this
explicitly, so a future test doesn't accidentally assert an absolute
value there.
Self-planned. DECISIONS.md §1: "the deploy workflows are written and
dry-run locally" -- no formal spec, so scoped by reading what this
project's own prior phases had already said about Phase 14 rather than
guessing: docs/deployment/production.md/staging.md (written earlier,
already fully botpit-specific) both explicitly named "CI/CD automation
for the deploy itself" as "Phase 14's job," and
docs/development/ci-cd-and-docker.md (Phase 9.7) had already found and
deliberately deferred two template-org leftovers to this exact phase.
- A real, previously invisible CI failure, found by checking rather
than assuming a green
gh pr checksmeant everything was healthy:.github/workflows/deploy.yml(FastAPI Cloud deploy) triggers onpush: branches: [master]-- which never appears ingh pr checks(that only shows a PR's own status checks, not workflows a merge itself triggers). It had failed on every single push to master since this repo's first one (gh run list --workflow=deploy.yml: ten-plus consecutive failures). Root cause:bun run --filter frontend build, but the actual workspace package has beenbotpit-frontendsince the Phase 9.5 rebrand -- a real bug, not a missing-secret failure. Even fixed, it would still fail at the final "Deploy to FastAPI Cloud" step (noFASTAPI_CLOUD_TOKENexists -- correct, per DECISIONS §1, no server yet). Fixed the real bug, and changed the trigger toworkflow_dispatch-- matchingdeploy-docker-compose.yml's own already-correct pattern for "this needs real infra to actually run" -- so it stops failing loudly for a deploy nobody is asking it to attempt. - The actual "dry-run locally" work, done for real rather than trusted
on paper:
docker compose -f compose.yml -f compose.deploy.yml config(the deploy overlay parses/interpolates cleanly with a placeholderDOMAIN) andbuild(clean) against the realcompose.deploy.yml; then the "prepare database" step -- run directly against a fresh throwaway Postgres withFASTAPI_ENV=productionand real (non-"changethis") secrets, exactly matching what a first real deploy would do -- full migration chain applies cleanly from empty, superuser seeds correctly.Settings._check_default_secretcorrectly refused to start on the first attempt (dev-placeholder secrets under production-mode settings) -- confirmed that guard actually works, not a bug to route around. Not dry-runnable, and not a gap: Traefik's real HTTPS/ACME cert issuance needs a real domain with real DNS pointed at a real reachable host, by construction -- waits for Phase 15 along with the host itself. - Two template-org leftovers, explicitly named as deferred-to-Phase-14
in Phase 9.7's own write-up, adapted:
deploy.yml's anddeploy-docker-compose.yml'sif: github.repository != 'fastapi/full-stack-fastapi-template'(always true for this repo, permanently) andzizmor.yml'sadvanced-security: ${{ github.repository_owner == 'fastapi' }}/annotations: ${{ ... != ... }}(always the same two constants) -- replaced the meaningless conditionals with what they always evaluate to forwithrvr/botpit, removing dead template cruft. - Found, deliberately left alone:
issue-manager.yml,bump-pre-commit-hooks.yml,add-to-project.ymlall gate ongithub.repository_owner == 'fastapi'too, but these are genuinely fastapi-org-specific automation (their own bot account, their own GitHub Project board) with no equivalent for a personal repo -- correctly inert, not broken, matching the Phase 9.5/9.6 precedent of leaving these three (plusprepare-release.yml)'s actual logic untouched. Confirmed by checking their run history, not assumed: they show as skipped, never as failed. - Docs: fixed a stale
DECISIONS.md§1 cross-reference (docs/deployment.md, a file that never existed) to the real path,docs/deployment/(already existed, already thorough --staging.mdandproduction.mdwere written well before this phase and needed no new file, just their own "Phase 14's job"/"not built yet" notes updated to reflect what actually shipped).
No code or tests changed this phase -- CI/CD workflow YAML and docs only.
Full gate: prek run --all-files clean (including zizmor, the GitHub
Actions security linter, against every edited workflow file); the deploy
dry-run steps above verified directly, outside prek, against a real
throwaway Postgres and the real Docker build.
Phase 15 ("Production deployment") needs a real server/domain purchase --
not something buildable unattended, so deferred by owner decision; moved
straight to Phase 16 ("Scale and polish"). Two pieces, both flagged and
scoped with the owner before building rather than guessed at: the
model-affinity scheduler docs/local-model-setup.md had already named as
Phase 16's job (DECISIONS §13), and -- found during the "polish pass"
survey of every phase's own "Left for later" notes -- the join queue's
seat/room wiring, which docs/rooms.md and docs/capacity-and-queueing.md
had both explicitly documented as unbuilt since Phase 7. The join queue's
real scope turned out larger than first estimated (new response shapes, an
accept-offer flow, promotion logic, real frontend UI, not backend-only like
the scheduler) -- re-scoped with the owner mid-flight rather than
silently expanding; the owner chose the full build, backend and frontend
both.
app/llm/scheduler.py(new):ModelAffinityScheduler-- one instance per LLMbase_url, bounded admission control.acquire(model)admits immediately if that model already has an in-flight call or the active set is underLLM_MAX_LOADED_MODELS(default 2); otherwise waits up toLLM_SCHEDULER_MAX_WAIT_SECONDS(default 5.0) before force-admitting anyway, logged.release(model)decrements and wakes one waiter. Deliberately a heuristic, not a real VRAM model: "active" means "has an in-flight call," not "Ollama actually has it loaded" (/api/psis never queried) -- documented as such indocs/local-model-setup.md's new §2.3.1, withbotpit_model_swaps_total(Phase 13) named as the way to empirically check its real-world effect later.app/llm/registry.py:client_for()now wraps every adapter in a new_ScheduledClient(routescomplete()throughacquire/note_llm_call/release) before caching it, one scheduler perbase_urlviaself._schedulers.note_llm_call(Phase 13's swap counter) moved here fromAgentController.decide(), so the metric reflects real post-scheduling dispatch order rather than raw arrival order.DECISIONS.md§13:"RESOLVED ... (flagged)"->"RESOLVED ..., built Phase 16", naming the implementation.- Tests:
tests/unit/llm/test_scheduler.py(6, admission/wait/force-admit/ release scenarios matching DECISIONS §13's own worked example) andtests/unit/llm/test_scheduled_client.py(2, wrapper-level: a cold model really does wait for a slot,note_llm_callfires at real dispatch time).tests/unit/llm/test_registry.pyupdated for the.innerindirection and per-base_urlscheduler sharing.
Backend, both scopes:
- Room-seat scope (
f"room:{id}"):RoomService.join_by_codeenqueues instead of hard-refusing when a room is full (RoomFullError, 409, now carryingroom_idin the body too -- a caller who only had the join code had no other way to know which room to keep polling).RoomService.leave(nowasync) offers the freed seat to whoever's next via_offer_seat_to_next_queued, which marks the seat"offered"-- a newRoomSeat.statusvalue (no migration; already an unconstrainedstr) excluded from_claim_open_seat's "any open seat" fallback so a fresh joiner can't steal a seat that was actually offered to someone specific._reclaim_expired_offerssweeps and re-offers any offer whose deadline passed unaccepted;join_by_codere-checksqueue.accept()immediately after a reclaim runs, since the very common case is the offeree's own retry landing right after their own offer just expired and got re-offered to them again -- a self-healing retry, not a second failure. - Global scope (
"global", DECISIONS §2's concurrent-game cap):RoomService.startreplaces the old hard-refusing capacity check with queue-aware logic -- enqueues unless already at position 1, in which case it pops itself off and proceeds;create()'s own capacity check is deliberately unchanged (still hard-refuses -- a room can always be created, only starting it queues)._finish_roomtries to promote the next globally-queued room once a slot frees (_promote_next_queued_room, best-effort, suppressingRoomNotWaitingError/RoomFullError/CapacityExceededError-- it only ever suggests a room;start's own advisory-locked check is the real gate against a stale suggestion). app/rooms/queue.py:sweep()'s return type changed from the requeued user ids to the reclaimed seat numbers (the caller's real question after a sweep); addedpop_next/peek_headfor the global scope's plain-FIFO needs (no offer/accept/TTL semantics -- the caller acts immediately).QueueStatusgainedoffered/offer_expires_in_seconds, computed non-destructively insnapshot()from theoffersZSET deadline (not the offer key's own PTTL -- see the bug below) so a polling caller can tell "still waiting" apart from "your turn came up, go accept."app/api/routes/rooms.py:GET /{room_id}/queuegained ascope=seat|globalquery param (seat: is this user waiting for a seat; global: is this room waiting for a slot) and aresponse_model=QueueStatus; its 404 condition now also lets a live offer through (previously any user with no active queue position 404'd, even one sitting on a live offer). It also sweeps this room's seat-scope queue on every poll, opportunistically reclaiming any offer whose deadline just passed -- see "no standalone sweeper task" below.
Two real bugs, both caught by the tests written for this feature rather than assumed correct:
- A genuine pre-existing race in
JoinQueue, never caught before because no test exercised a real wall-clock TTL._OFFER_NEXT's Redis string key (offer:{scope}:{user}) had its ownSET NX PXexpiry set to the exact same deadline theoffersZSET tracks forsweep()'s explicit reclaim -- under a real wait (as opposed to every existingJoinQueuetest's syntheticnow_msoverride), Redis's own automatic key expiry usually fires before or alongsidesweep()'s ownGETDEL, which then finds nothing and silently assumes some other worker already claimed it -- never reclaiming the seat. Fixed by giving the key's ownPX4x headroom over the ZSET deadline: it's now just a leak backstop for a crashed process, andsweep()'s explicit reclaim always wins the race it's supposed to win. RoomService.startleft its Postgres advisory lock (pg_advisory_xact_lock) held after raisingCapacityExceededErroror the post-lockRoomNotWaitingError-- xact-scoped locks only release on commit/rollback, and neither exception path did either, so the lock stayed held until whatever session owned it eventually closed. Harmless within one short-lived request, but_promote_next_queued_roomopens its own session from inside_finish_room-- a caller that catches the first exception and keeps working on the same session (exactly whattest_finish_room_promotes_the_next_globally_queued_roomdoes, and what triggered a genuine ~90s hang under a realtimeoutguard, not just a slow test) deadlocks that second session against the lock the first never released. Fixed with an explicitrollback()on both exception paths, immediately after the check that decided to raise -- nothing written before that point needed keeping (reads and the lock call only).
Frontend:
components/Rooms/JoinByCode.tsx: a 409-with-room_idresponse is shown inline (position, length, ETA) instead of toasted as an error; pollsGET /{room_id}/queue?scope=seatevery 3s while queued. A live offer (offered: true) shows "Seat ready" with an "Accept seat" button that just retries the same join call --join_by_code'saccept()path handles it. A 404 (dropped after too many missed offers) clears the queued state.routes/_layout/rooms.$roomId.tsx: a 429 on Start shows "Waiting for a game slot" with position/ETA (pollingscope=global) instead of an error toast; clears once the room's own status flips away from"waiting"over the existing websocket -- that's the real signal the wait ended, not a client-side guess. Also added a "Leave seat" button -- there was no way for a human to leave a seat from the UI at all before this, which meant the accept-offer flow had nothing to demonstrate it end-to-end; a real, if small, product gap found while building this.frontend/tests/rooms.spec.ts: new E2E test, three real browser contexts -- two fill a room, a third gets queued (position/ETA visible), the creator leaves, the third's page shows "Seat ready" and accepts into the freed seat. Run against the real Docker stack per this project's standing gate; all 60 E2E tests pass (the other 59 unaffected).
Left for later, explicitly (not silently): no standalone
leader-elected sweeper task, and no seat_offer/queue_update push
events -- both were Phase 7's original documented plan for this feature.
Built instead: opportunistic reclaim (a join_by_code retry sweeps as a
side effect; so does every GET /queue poll while a waiting caller's own
queue UI is open) and REST polling instead of a push transport. Covers
every realistic path -- the offeree is still watching, or someone else
eventually retries the room -- but a room whose only waiter closes their
tab, where nobody else ever touches that room's queue again, leaves that
one offer stale until something does. docs/capacity-and-queueing.md
documents this bound explicitly rather than claiming the original design
was built as originally planned.
Full gate: backend suite (uv run pytest tests, all green, including new
tests for the scheduler, the queue wiring, and the two bugs above),
prek run --all-files clean, frontend bun run build/lint clean, full
Playwright E2E suite (60/60) against the real Docker stack.
Infrastructure-only pass: Docker image build, Compose, CI/CD workflows,
server startup, and test execution -- no application logic, API/websocket
behavior, or test content changed. Every Section 1 area got a documented
verdict (already fine, or changed with reasoning and, where possible, a
real before/after number). Full detail: docs/development/ci-cd-and-docker.md.
- Docker image:
python:3.14->python:3.14-sliminbackend/Dockerfile, measured 2.13GB -> 707MB (-67%); added a non-rootappuser (uid/gid 1000); worker count now configurable viaWEB_CONCURRENCY(shell-expansion default, not a persistentENV, so it can't leak into the dev-onlyfastapi devcommand). The slim swap broke thecurl-based healthcheck (nocurlin-slim) -- fixed via apython3/urllib.requestone-liner incompose.ymlinstead of re-addingcurl..dockerignore(root,backend/) hardened with.env*,.pytest_cache,.ruff_cache,*.log,packages/react-email/.react-email/. - Compose:
compose.deploy.yml'sredisservice was missingrestart: always(present on every other service) -- added. Everything else (named volumes,condition: service_healthyhealthchecks, Compose Watch) was already current best practice, confirmed and left alone. - CI: added
concurrencygroups (cancel stale in-flight runs on a new push) totest-backend.yml,playwright.yml,pre-commit.yml,test-docker-compose.yml--deploy-docker-compose.ymlalready had one. Addeddorny/paths-filtergating totest-backend.yml/test-docker-compose.yml, mirroringplaywright.yml's existing pattern. Added GHA-backed Docker layer caching (cache_from/cache_to: type=ghaCOMPOSE_BAKE=1) for thebackend/playwrightbuilds, targeting the 4x-redundant independent build across Playwright's 4 parallel CI shards -- verified locally that the mechanism builds correctly; the real cross-run cache-hit benefit needs confirming on the next CI push (no GHA cache tokens exist locally to measure it honestly). Bumped stalebun-version: 1.3.12->1.4.0(matches local dev) inplaywright.yml(x2),pre-commit.yml,deploy.yml. Confirmed via empirical CI-log inspection that uv's dependency cache (setup-uv'sautodefault) was already working correctly -- left alone rather than adding a redundantenable-cache: true.
- Server startup: readiness (
app/main.py's lifespan LLM health gate,health.py's real DB/bus check) and graceful shutdown (sequentialGameRunner/ModelRegistry/bus/Redis close in the lifespan'sfinally) were already correct -- confirmed, no changes. Deliberately deferred the Gunicorn-vs-bare-Uvicorn production question: current guidance supports either depending on the eventual deploy target, and Phase 15 (real deployment) hasn't picked one yet. - Test execution: checked
pytest-xdist--tests/conftest.py'sdbfixture is session-scoped against one real, shared Postgres database with no per-worker isolation, so parallelizing would risk real cross-worker collisions for a suite that's already fast (753 tests, ~53s). Not applied. Checkedprestart.sh's lack of a wait-for-DB retry loop -- already covered bycompose.yml's/CI's owncondition: service_healthy/--waitgating at the orchestration level. Not applied. - No branch protection exists on this repo (private, free tier -- confirmed
via the GitHub API), so the new path-filter-gated jobs needed no
alls-green-style wrapper the wayplaywright.ymlalready has. - Found, deliberately not fixed (Phase 14's job, matching the Phase 9.5/9.6
precedent for
.github/workflows/*template logic):deploy.yml's leftoverif: github.repository != 'fastapi/full-stack-fastapi-template'template-org check. - Full gate:
ruff check/ruff format --check/mypy app/ty check appclean, backend suite 753 tests green,prek run --all-filesclean (includingzizmoron every workflow edit). No application code touched this phase, so the suite result is identical to before -- confirming nothing broke, not that anything was fixed. - Review fixes (PR #18):
code-reviewerandpython-reviewereach caught one real issue before merge.python-reviewer: the non-root user's blanketRUN chown -R app:app /appforced overlay2 to copy up every file from every earlier layer, quietly giving back ~430MB of the size win (1.14GB with it, re-measured 707MB without) -- fixed by chowning each layer's own output at creation time (COPY --chown,chownappended inside the sameRUNasuv sync) instead of one blanket pass at the end.code-reviewer: the GHA Docker-cache mechanism as first written would have been silently inert in CI, since theghacache backend needs buildx'sdocker-containerdriver and no workflow provisioned it (confirmed locally:docker buildx lsshows the default driver is plaindocker) -- fixed by addingdocker/setup-buildx-action(driver: docker-container) before the relevantdocker compose buildsteps. Also fixed: a path-filter gap (missingpyproject.toml/uv.lock/bun.lockin all threechanges-job filters, which could have let a dependency-only bump skip its own test job) and an unquoted${WEB_CONCURRENCY:-4}expansion in the Dockerfile'sCMD. Re-verified after fixes: rebuilt image, confirmed non-rootappuser can still importapp.mainand the backend container reports healthy, full gate re-run clean.
botpit.games.base:Game[State, Move]contract, frozen pydantic state,IllegalMoveError,PlayerView,hand_viewhook;cards.pyshared types and deterministicrng_for(state);GAME_CATALOGregistry.- Four games, each with
rules.mdand scripted tests: Classic UNO (92 tests), UNO Flip! (96), UNO Show 'em No Mercy (104), Hearts (46), all passing the shared contract suite across their seat ranges; catalog round-trip tests. - Hearts proved the contract generalises: no change to base/registry needed beyond one registry line.
- Total suite: 458 tests, ~18 s.
authz: roles/permissions tables + seed migration (4 default roles, 12 permissions),load_permissions,require_permissionFastAPI dependency, roles admin routes, defaultplayerrole on signup,/users/me/permissions.rooms-infra: Redis client singleton, Lua sliding-window rate limiter + dependency, join codes (Crockford base32), ZSET join queue with offers/sweep/ETA, CI starts Redis.rooms-tables:Room/RoomSeat/RuntimeSetting/FeatureFlag/FeatureFlagOverridetables and migration (closes the deferredroom_role -> roomFK from the authz migration), admin-adjustable settings and feature flags with a 5s per-process cache and precedence (user override > role override > global),/admin/settings,/admin/flags,/flags/me.- Suite: 651 tests, ruff/mypy/ty/prek all clean.
- Ahead of the original phase split: the Redis capacity primitives and the
runtime-settings/feature-flags tables that §1.8/§1.10 assign to Phase 7
are already built. Phase 7 now only needs the
RoomService, HTTP routes,SubscriptionGuardand wiringGameRunnerinto the app lifespan. - Housekeeping: Dependabot opened PRs #4-#9 from the template's
dependabot.yml; left for the owner.
backend/app/referee/:SeatSpec,SeatController,HumanController,PolicyController,ScriptedController,fallback_move,Referee(turn loop with retries, deadlines, fallback, event publication,replay),GameRunner. 13 tests: every game finishes with policy seats and replays from the log; a human seat fed through the runner produces the same final state as a scripted one; agent retry-then-fallback; human retry-until-deadline; player subscribers only see their own views; stop publishesroom.stopped.- Event schema gained
game.move_rejected,game.turn_timed_out,game.view_updated;game.started/turn_started/move_applied/finishedcarry more fields. - Table-talk hooks: the referee calls an optional
sayafter each move and fans the line out to other controllers'hear. - Phase 4 (same branch):
app.games.move_schema(self-contained JSON schema per game,move_adapteron the contract),app.agents.persona(6 built-ins),app.agents.prompt(two-call prompts, 3500-token budget, PlayerView-only by construction),app.api.views.view_payload,tests/security/fuzz suites for prompt and event-stream privacy. - Phase 5 (same branch):
app.llm.client(protocol, errors, retries, think-tag stripping, metrics hook),OllamaNativeAdapter,OpenAICompatAdapter,ModelRegistry(+LLM_MODEL_OVERRIDES),FakeLLMClient,AgentController(repair loop, reasoning event, chat call), live tests for a raw completion and a full agent turn per model (-m ollama). Live agent turn per model on the 6 GB card, cold: gemma3:4b 10 s, llama3.1:8b 14 s, qwen3:4b 8 s (thinking off), phi4-mini 8 s; all four chose a legal move, gemma needed the discriminator-required schema fix, qwen needed the echo-field-tolerant legality check.
Owner decision 2026-09-12: https://github.com/fastapi/full-stack-fastapi-template is the base. Done:
- Template tree vendored whole (backend/, frontend/, packages/, scripts/, hooks/, .github/, .claude/, .agents/, .vscode/, img/, compose files, root docs). Python 3.14 via uv, Bun 1.4.
- botpit modules ported to
backend/app/:games,events,telemetry(renamed fromlogging, which shadowed the stdlib when scripts run fromapp/),llm,middleware.py,api/routes/health.py. - Settings folded into
app/core/config.py(upper-case fields);FASTAPI_ENVnow accepts staging/production;env_nameproperty. app/main.py: lifespan (logging, event bus, LLM health gate), wide-event middleware, frontend mount guarded by directory existence.- Dropped: our async SQLAlchemy layer, Alembic dual-dialect setup, Caddy,
Makefile, our CI workflow,
.env.<env>files (template uses one.env). - compose:
redisservice, botpit env vars onbackend, dev override publishes db/redis on 5433/6380 and disables the LLM startup gate inside compose (Windows Ollama is not reachable from a bridge network). - Verified: ruff, mypy strict, ty clean; 509 tests green (template suite +
ours + Redis integration) against compose Postgres/Redis; native
fastapi runhealthy with Ollama 4/4; frontend builds and is served at/; backend image builds; full compose stack up behind Traefik. - Open: template workflows for releases/deploys are kept verbatim and will
be adapted in Phase 14;
docs/deployment.mdnow defers to the template'sdeployment.mdfor mechanics.
Delivered on phase-1-scaffold:
- Research:
docs/local-model-setup.md,docs/deployment.md. pyproject.toml(uv, ruff, mypy strict, pytest-asyncio),src/botpit/config.py(pydantic-settings,BOTPIT_ENVpicks.env.<env>),.env.dev,.env.staging.example,.env.production.example.botpit.logging: wide events (wide_eventcontext manager, contextvar, correlation inheritance), JSON + console formatters, tail sampler.botpit.events: envelope withvisibility(public / seat:N / privileged), typed payloads,filter_for_subscriber(privacy boundary),EventBusprotocol withInMemoryEventBusandRedisStreamsEventBus(WATCH/MULTI seq assignment, XREAD fan-out, catch-up from last id), shared contract tests on fakeredis and real Redis.botpit.db: SQLAlchemy 2.0 async base/session, portable mixins, Alembic env for SQLite + Postgres,runtime_settingstable + migration 0001, programmaticupgrade_to_head, autogenerate-diff guard test.botpit.llm.health: provider-agnostic probe (ollama_native,openai_compat), startup gate.botpit.app: factory, lifespan (logging, migrate, bus, LLM gate),/health,/health/llm, request wide-event middleware.- Infra: Dockerfile (multi-stage uv, non-root), compose (app, postgres:17,
redis:8,
llmandproxyprofiles), dev override (host network so the container reaches Windows Ollama; host ports 5433/6380), Caddyfile, Makefile, CI workflow (lint, unit, integration with services, docker build). - Verified locally: unit suite green; integration suite green against compose Postgres + Redis; app healthy natively and in compose against the real Windows Ollama (4/4 models present).
Open decisions raised mid-build:
- GitHub branch protection on
masteris unavailable (private repo on the free plan). Protection is by discipline: PR-only merges, never push to master directly. - Original
DECISIONS.mdmissing; reconstructed one committed. Owner to supply the original for a merge. - Ollama on Windows binds 127.0.0.1, so the dev app container uses host
networking instead of
host.docker.internal. Servers do not have this problem (the LLM is a compose service there).