Knoxis
A church's secret ballot had to be provable by looking at the schema, beyond a promise made in a policy.
- django
- postgresql
- react
- typescript
- drf
The problem and constraints
Knoxis exists to run a specific, high-stakes ritual: a church electing presbyters and deacons by secret ballot, the way Presbyterian churches have done it for generations, with a paper ballot and multiple rounds of counting until every seat is filled by a real majority. The client wanted that ritual on a computer, and the one requirement that shaped everything else was that “secret” had to mean something stronger than “we promise not to look.”
That’s a sharper constraint than it sounds. A typical web form can log every request, keep every database row forever, and nobody outside the team ever notices. The cost of that carelessness is usually a slow query or a slightly bloated table. Here, the same carelessness (storing who voted next to what they voted for, even briefly, even “just for debugging”) is the one failure that would make the whole system worthless to a church that exists specifically because it doesn’t trust informal, undocumented processes to handle something this sensitive.
The second constraint was the shape of church governance itself, which doesn’t map onto a flat list of interchangeable “users.” A presbytery has multiple offices up for election at once, each with its own number of open seats; the rule for winning is a simple majority of votes cast, which routinely takes more than one round when nobody clears 50%; and the very last round can switch to “whoever got the most votes wins,” specifically to guarantee the process actually ends. None of that is exotic, but all of it needed to be represented faithfully: a “majority” calculation that’s off by one seat, or a system that can’t cleanly carry the same remaining candidates into a second round, would undermine the same trust the secrecy design was protecting.
The third constraint was scope discipline against real pressure to add things. The client was open to infrastructure like Redis if the system needed it (a real-time push notification, a background job queue, a distributed cache), and the honest answer, checked case by case, was that at the expected scale of a single congregation’s election, none of those needs were real yet. Adding Redis “to be safe” would have meant a fourth moving part in production, a bigger attack surface, and more to reason about for a system whose core promise is trustworthiness through simplicity; raw throughput was never the point.
The fourth constraint was that a CPF (the Brazilian national identifier used to check a voter against the church’s membership roll) is itself sensitive personal data under Brazilian law, and the system’s only real need for it is a yes-or-no match at the door. Anything beyond that minimum, including the temptation to just keep it around “in case it’s useful later,” was a liability with no corresponding feature to justify it.
The last constraint was multi-tenancy from day one: a platform where any number of congregations run their own elections, completely isolated from each other, without each one needing its own database, its own deployment, or its own migration history to keep in sync.
Architecture
A voter opens the ballot flow and identifies with a CPF. The backend normalizes it, validates the check digits, computes a keyed HMAC hash, and looks it up against the church’s voter roll for that specific election: a single indexed comparison, no different in speed from comparing plain text, except that the plaintext CPF is never the thing sitting in the database. A match issues a ballot_session: a short-lived token, handed back in an httpOnly cookie, that represents “this specific person is now allowed to cast this specific ballot” without yet saying anything about what they’ll choose.
Submitting the ballot is where the architecture’s real promise gets kept. In a single database transaction, the backend locks the ballot session row, confirms it hasn’t already been used and hasn’t expired, marks it used, and then writes to two separate tables: voter_attendance records that this voter participated in this round (it has a voter ID, because knowing turnout and preventing double-voting both require it), and votes records the actual choices, for each open office, with no voter ID, no CPF, no reference back to the ballot session at all. There is no foreign key a query could follow from one table to the other. The vote timestamp itself is truncated to the minute, which quietly reduces the value of trying to correlate “who was active at 7:42:03” with “what got recorded at 7:42:03,” an imperfect defense in a tiny election with long gaps between voters, but a real one at the scale this system actually runs at.
Every one of those tables, and every table in the system, carries an organization_id: the boundary that keeps one church’s election completely invisible to another. That boundary is enforced by a mixin that every API viewset in the system is required to inherit, which filters every read by the logged-in organization and silently injects the tenant on every write, ignoring anything a client might try to send instead, no separate database or Postgres row-level security policy involved. Because that guarantee lives in application code rather than the database engine itself, it’s backed by a second guarantee at the test level: a single regression test walks every registered viewset in the project and fails the build if any of them was written without the mixin.
While a ballot round is open, the organizer watches a live tally in a separate, admin-only view (never visible to voters), updated by polling the backend every three seconds rather than any push mechanism. An ETag built from the round’s ID, its status, and its current vote count means an unchanged tally costs the server almost nothing to answer: a 304 Not Modified and no query. When the round closes, the same aggregation query determines who cleared a real majority of the votes cast, and whoever remains short of a seat carries straight into the next round automatically, with the same candidates and the same open seats, until every position is filled, on the last round, by whoever received the most votes, exactly as the church’s own printed rules require.

Architecture
Decisions
DECISION 01/04 · Secret ballot
A promise not to look is not a guarantee. It's broken by a curious admin, a bug, or a subpoena. Splitting the tables makes the guarantee visible in the schema itself: there is no join that recovers who voted for whom
Cost acceptedResidual temporal correlation is still possible in a very small ballot round with a long gap between votes, mitigated by truncating vote timestamps to the minute, but not eliminated. Documented as an accepted limitation of this threat model rather than solved with heavier cryptography the scope doesn't call for
DECISION 02/04 · Storing a CPF
The only real need for a CPF after import is matching what a voter types against the roll: a keyed hash does that in a single indexed lookup without ever holding the plaintext value
Cost acceptedThe original CPF is gone for good once hashed. There is no recovery path, and rotating the hash key invalidates matching for every existing list, requiring a re-import. Accepted because no feature in the system ever needs the CPF back in clear form
DECISION 03/04 · Multi-tenant isolation
One database and one migration path is operationally simple for the expected scale (hundreds of church tenants rather than thousands), with centralized backup and no per-tenant migration multiplication
Cost acceptedCross-tenant leakage becomes an application bug: the database itself has no structural guard against it. The mixin has to be inherited correctly every time, which is why a consolidated regression test walks every registered viewset and fails if one of them doesn't inherit from it
DECISION 04/04 · Live vote count
At the expected scale (a few hundred votes, a few dozen simultaneous organizers per church), three-second polling is imperceptible to a human, and it keeps the whole backend on a plain WSGI process with no extra moving part (no Channels, no async workers, no message broker)
Cost acceptedThere's no true push: the fastest an organizer finds out a ballot round has closed is however long is left on the current three-second cycle. Accepted because nothing in this domain needs sub-second updates
Invariants
The votes table never has a column that identifies the voter
Guaranteed by
the Vote model declares no voter or ballot_session field, and a dedicated schema-introspection test walks information_schema.columns and fails on any column outside the allowed setA voter can never vote twice in the same ballot round
Guaranteed by
a unique constraint on voter_attendance(round_id, voter_id)A request for another church's election data returns 404, never 200
Guaranteed by
a mandatory viewset mixin filters every queryset by the logged-in organization, plus a consolidated regression test that iterates every registered viewset and confirms it inherits from the mixinA CPF is never stored or logged in plaintext
Guaranteed by
the only persisted value is an HMAC-SHA256 hash keyed by a server-side secret; a mandatory masking helper is the only sanctioned way a CPF can appear near a log lineA migration can run twice in a row without changing anything the second time
Guaranteed by
verified in CI: apply all migrations, apply them again, assert the second run reports nothing left to apply
What broke
- Symptom
- An internal security review found that the ballot_session cookie (the short-lived token linking a voter's identification to their still-unsubmitted ballot) wasn't marked Secure in production, meaning a browser would have been willing to send it over a plain, unencrypted connection if one existed
- Root cause
- The cookie was configured with the same flags in every environment. Local development necessarily runs over plain HTTP, so the Secure flag had been left off entirely rather than made conditional, and that same unconditional setting carried straight into the production configuration untouched
- Fix
- Made the Secure flag conditional on the deployment environment (on whenever DEBUG is off), in the same review pass that also removed an undocumented endpoint exposing raw election lookups by ID and added a missing validation check on the tie-resolution flow
- Prevention
- All three fixes shipped from a single, deliberate audit pass rather than three separate incidents. The review checklist that caught them is now a standing item before any release that touches authentication or session handling
Results
Full stack
Backend
- Django 5 + Django REST Framework
- Python 3.12
- Argon2id (password hashing)
Frontend
- React 18 + Vite
- TypeScript
Data
- PostgreSQL 16
Infra
- Docker Compose (Nginx + Gunicorn + Postgres)
Interested in a project like this? Get in touch.
Get in touch