EMBA Election System
A cohort-leader election system built in a day because a Google Form could guarantee neither one-person-one-vote nor a secret ballot - identity by a hash of student ID and phone number, secrecy by file layout, a count check before tallying. An adversarial audit 35 minutes before polls opened fixed four defects, and 35 of 37 members voted.
- Node.js
- Express
- No DB
- Election
- EMBA
- QA
Ballot - after entering only a student ID and phone number, pick one candidate. This is the test instance with a fake roll of five, and the candidate names are placeholders
Confirm before submit - one more screen stating that a submitted vote cannot be changed or cancelled
Vote complete - a ten-character confirmation code stored apart from the choice, so it reveals nothing about the vote; if it appears in the published list, the ballot was counted
Result page - winner, turnout, tally, and confirmation code lookup. Once published, editing the ballot file cannot change these numbers
Admin screen - electorate, votes, turnout, non-voters, majority / two-thirds / all thresholds, 15-second refresh. Candidate settings lock once ballots arrive
Tally - the verdict (majority win), the vote chart, and the roll count = ballot count = code count check. If any of the three disagree, no result is returned
Setup
- Problem
Cohort-leader elections had always been a Google Form. The announcement listed three problems. A Google account cannot guarantee one person one vote. The admin can see respondent and response side by side, so it is not secret. And the "close when all 38 have voted" condition could never be met because of a withdrawn student. An election is over the moment its process, not its result, is doubted. So the requirement was not features but guarantees. One person, once. Who voted for whom unknowable even to someone who can see the whole server. And published numbers that cannot be changed afterwards.
- Context
Built for the cohort 120 leader election (polls open 2026-09-17 14:00 to 09-18 23:59). The first commit was at 10:55 on the 17th and polls opened at 14:00 the same day. Of the 24 commits in between, the one at 13:25 is the result of an adversarial audit: six lenses, seven confirmed defects, four fixed and three refuted with reasons. Screen commits continued while voting was open. The EMBA app's member DB is the source of the electoral roll, but no vote data goes into that DB. It is mounted as a router on the card web (Express, pm2) and served on the vote subdomain with no new port and no ALB rule. One dependency (Express), no database, four JSON files, four plain HTML pages. Three admins (the lead developer and the 66th council's chair and vice-chair), all from cohorts 118 and 119 and so without a vote. After the election, on 30 September, the core was split into a multi-election and archive structure (two PRs), and the same frame ran three rounds of a cohort 120 slogan vote on the card web. 31 commits, solo.
- Users
The electorate is 37 members of cohort 120 (39 enrolled minus one test account and one withdrawal). They open a QR or link on their phone and enter only their student ID and phone number. There is no password: the app's average login count for cohort 120 is 1.2, so nobody would remember one. The three admins handle status, tallying, and publication; election setup, the roll, activation, and purging belong to the lead developer alone.
- Hypothesis
If identity is a hash of student ID and phone number, the server enforces one vote per person, and secrecy is guaranteed by file layout, turnout beats the Google Form and nobody disputes the result. That was the promise in the announcement, and the outcome was 94.6% turnout and zero recorded disputes.
Build
- What I did
- Identity - sha256(salt + student ID + phone) compared against roll hashes. The roll is extracted from the production DB by script and the salt lives in a separate file; both are banned from commits, with the reason written in .gitignore
- One person one vote - 409 once voted, session cookie deleted right after voting (shared PCs). Write order fixed as ledger, then ballots, then receipts
- Secret ballot - split into three files (roll with only a per-ID done flag, ballot box with only {"c":"c1"}, confirmation codes), the roll rewritten whole in ID order, and the box and codes cryptographically shuffled and atomically replaced after every vote. Not reconstructible even by someone who sees the server and the DB
- Tally and publication - opens only after close and returns 409 unless roll count = ballot count = code count. Publication happens once; repeat requests return the first snapshot. The roll hash is baked into the published
result.json to detect later changes - Confirmation code - a 10-character code lets a voter check on the result page that their ballot is in the count. The word "receipt" was renamed "confirmation code" on election day
- Admin screen - status refreshed every 15 seconds, non-voters (voted or not, nothing more), encouragement text to copy, candidates locked once ballots arrive, tally, publish, archive, purge
- Attempt limits - 8 per 10 minutes per student ID (strict), 60 per 10 minutes per IP (loose, for lecture-hall Wi-Fi and carrier NAT), counted only on failure
- A /test practice instance - a fake roll of five, a separate data directory, a red warning band. The reset route exists only on the practice instance
- 159 tests (node --test) - secrecy guarantees, role separation, isolation between elections, the create, roll, activate, archive, purge lifecycle, and the production publication snapshot as a migration regression baseline
- Product decisions
- No password - asking users who log in 1.2 times on average for a password would bury election day in support requests. Student ID plus phone number was judged a combination only the person knows
- Close as soon as everyone has voted - the Google Form's impossible "when all 38 are done" (because of a withdrawal) was solved by excluding them from the roll itself
- No organising body shown - because no such body exists. Authority comes from the admins list in the repo config, not the app's member level, and admins created from the screen are ignored by the core
- Vote requests are logged nowhere - the audit log records only committee actions (login, tally views, publication, config changes). Ops and progress logs are separated so that "which voter was this" never leaks
- No backups, deploys, or server access while polls are open - the diff between two snapshots of roll and box reconstructs individual choices. /api/maintenance reports safeToBackup
- Small-election gates - fewer than five voters cannot be activated, and two or fewer valid votes or a unanimous result require reconfirmation before publishing. The smaller the election, the easier secrecy breaks
- Purge after archive - roll, salt, applications, and ops logs are deleted; the box, codes, result, and audit log are kept. Purging is irreversible and owner-only
- QA considerations
- An adversarial audit 35 minutes before opening - of seven confirmed defects, four were fixed: an admin token placed in a voter cookie let a ballot through, a session left over after removal from the roll could still vote, knowing someone's student ID could lock them out for 10 minutes (the attempt limit ran before the match), and reordering candidates attached photos and applications to the wrong person. The three refuted ones are in the commit with reasons
- Which is less bad, a missing vote or an inflated one - the write order was chosen so that a crash mid-save yields a missing vote. A missing vote shows immediately as roll count > ballot count and can be recovered; an inflated one gives no way to know which ballot to remove
- Does secrecy hold against the server admin - three files, shuffled, and a roll rewritten whole instead of appended so no ordering information survives. Pinned by tests
- Do the numbers stay fixed after publication - editing the ballot file by hand leaves the public figures unchanged, pinned by a test. Not computing tallies before close is pinned too
- The remaining holes are in the README - someone attached to the server watching file changes live, per-person voting times inferable from the 15-second admin polling, and a ballot box the archive keeps forever. Not closed, and known
- There is a rehearsal instance, but no record that a dry run was done before the real election. No CI either; the 159 tests run locally
- Login failures, support requests, and whether early close triggered during voting are not recorded, because the data directory lives outside the repo
Outcome
- Metrics
- Electorate 37, voted 35, turnout 94.6%, abstentions 0, 35 public confirmation codes. Published 2026-09-18 (00:48 KST on the 19th) by the 66th council chair
- 31 commits (24 on 17 September, 7 on the 30th), single author. First commit 10:55, polls open 14:00
- 159 tests passing (local run on 2026-10-10), up from 100 to 147 on 30 September alone. No CI
- src 3,871 lines, test 4,334, public 4,331, README 1,039. The tests are longer than the source
- Zero recorded incidents, zero TODO or FIXME. Login failures and support requests not recorded
- Result / Learning
The election opened, closed, and was published on schedule. Turnout 94.6%, no recorded dispute, no recorded incident. The announcement and the result page record that all three things the Google Form could not do (one vote per person, secrecy, a workable close condition) were solved. What the project leaves behind is documentation more than numbers. The 1,039-line README states why the write order is what it is, why backups are banned during voting, and the three holes that were not closed. The core split out on 30 September into a multi-election structure was reused the same month for three rounds of the slogan vote.
- Retrospective
- The adversarial audit ran 35 minutes before polls opened. Four defects came out of it, so it should have run a day earlier. In a one-day project there was no "day earlier", and that is the real problem.
- Screen commits continued while voting was open. The logic was untouched, but it was not something the person who wrote "no deploys during voting" in the README should have done.
- A practice instance was built, but no record was kept of an actual dry run. Whether one happened is now unknowable, and that is the cost of not writing it down.
- Tech stack
- Node.js 18+
- Express (단일 의존성)
- JSON 파일 저장 (tmp + rename 원자적 쓰기)
- 순수 HTML 4장
- node --test
- pm2 (명함 웹에 라우터로 마운트)