Dx Music League Part 4 : Beta Week


Part 4 of a 5-part series. Part 3 ended with eleven real players in a real league. This part is about what they did to my roadmap.
There's a specific humility that arrives about 48 hours after real users touch your software. Not because everything breaks — the core loop held — but because real people reveal a category of problem no amount of self-testing finds: the difference between what the software does and what players assume it does. The week of July 6–9 was shaped almost entirely by them.
The first requests weren't features at all — they were questions the app couldn't answer. Has everyone submitted? Who are we waiting on?
So rounds grew participation panels: during submissions, "7 of 11 songs in" with names of who's done and who's pending; during voting, the same for ballots. The interesting part was deciding what not to show — the panels leak identities only, never which song someone submitted or how they voted. That privacy line is enforced server-side and covered by tests, because "the UI just doesn't display it" is not a privacy model.
Next request: "Can we submit two songs some rounds?" Songs-per-player became an owner setting, 1–5.
Storage-wise this broke an assumption baked into the database schema — one submission per player was enforced by the sort key SUB#<userId>. Multi-song rounds needed SUB#<userId>#<submissionId>, with legacy single-key rows still readable so existing leagues didn't break mid-round.
The subtle bug this feature almost shipped: when a player with a single slot re-submits (changes their pick), the natural implementation deletes the old submission and inserts a new one — with a new id. But ballots reference submission ids. Delete-and-insert would let a re-submit silently orphan votes already cast. The fix: replacement keeps the stable submission id. The song changes; the identity of "this player's pick" doesn't.
The best bug report of the beta: "my comments disappeared."
A player voted, wrote thoughtful per-song comments, then came back before the deadline to adjust one point allocation. The vote page — which loaded fresh and empty — submitted their adjusted points along with… empty comment fields, silently wiping everything they'd written. The ballot overwrite semantics were working exactly as designed; the page was the bug. It treated every visit as a first visit.
The fix added a GET my-ballot endpoint and made the vote page pre-fill everything — points, comments, anti-votes — from the ballot you've already cast, with the button switching from "Submit votes" to "Update votes." Editing is now actually editing.
I keep this one filed under: overwrite semantics are only safe if every writer reads first. The backend was never wrong; it just handed the frontend a loaded gun.
The most requested game change: negative votes. The league wanted a way to say this song, no — so rounds gained an anti-vote pool (0–2 per voter, owner-configurable, default off). Anti-votes are optional to spend, can't target your own song, subtract from a song's total, and the reveal now shows each song's +for / −against breakdown. Standings bank net points, so yes — a truly bold pick can send your season total negative.
Shipping it surfaced the sharpest edge in the codebase. The route layer doesn't pass request bodies through to handlers — it rebuilds each handler's input from an explicit field list, coercing types as it goes:
return leagues.updateLeagueSettings(deps, req.caller, {
votePoolSize: asInt(body.votePoolSize),
maxPointsPerSong: asInt(body.maxPointsPerSong),
// downvotePoolSize: ... ← forget this line and the field never arrives
});I added downvotePoolSize to the settings type, the handler, the validation, and the UI — and the save was a silent no-op, because the route still enumerated the old field list and dropped the new field on the floor. As a security posture, allow-listing input fields is genuinely good; unknown fields can never sneak into storage. But it makes every new field a two-place change, and the failure mode when you forget is silence. The fix commit fixed both the route and my mental checklist.
The last cluster of the week came from the social edges of the game — the stuff a solo tester literally cannot encounter:
Reading the week's commits back, a pattern: almost nothing was new invention. It was the existing model being made honest — pre-fill what the user already told you, keep ids stable under edits, keep points when people churn, show who we're waiting on, let the curious watch. The architecture didn't need to change; it needed to stop losing information it already had.
And one meta-lesson: every fix in this post shipped to production the same day it was reported, because the whole stack — mock frontend, memory-backed backend, real tests — still runs on a laptop with zero AWS. The seams from part 1 aren't a development convenience. They're what fast response to real users is made of.
Next, the finale: Part 5 — Adding a New Music Provider: YouTube Music — the seam that was designed for this exact moment, what's already proven, and the honest checklist of what's left.