Architecture
DOGFOOD is a single Django 5.2 project served by gunicorn, backed by one PostgreSQL 16 database.
There is no message broker, no Redis, and no separate worker process: caching and rate-limit
counters live in a database table (DatabaseCache), and static files are served in-process by
WhiteNoise (src/portal/settings.py, docker-compose.yml).
Request lifecycle
client
-> gunicorn (2 workers by default; DOGFOOD_WORKERS) docker/entrypoint.sh
-> Django WSGI src/portal/wsgi.py
-> MIDDLEWARE stack (src/portal/settings.py), incl. two project-specific classes
DemoAuthMiddleware src/portal/middleware.py
ContentSecurityPolicyMiddleware src/portal/middleware.py
-> URL routing src/portal/urls.py
-> view src/<app>/views.py
-> service layer (transaction.atomic; appends an audit event)
src/<app>/services.py
-> response (Content-Security-Policy: script-src 'self' set via setdefault)
Two project middlewares are registered (src/portal/middleware.py):
DemoAuthMiddleware— gated on theDOGFOOD_DEMOsetting. When enabled, aCookie: session=<token>is resolved against theDemoSessiontable; on a hit it setsrequest.userto the seeded user, setsrequest._dont_enforce_csrf_checks = True, and marksrequest.demo_shim = True. A no-op when the flag is off or the cookie is absent/unknown.ContentSecurityPolicyMiddleware— setsContent-Security-Policy: script-src 'self'on every response (viasetdefault, so a view may override it). The one view that does is the Swagger docs page (/api/v1/docs/), which setsscript-src 'self' 'unsafe-inline'scoped to that page for the UI's inline bootstrap<script>; the site-wide policy is unchanged (src/api/views.pyCspSwaggerView).
Writes go through a service layer, not the views. submissions.services.create_submission runs
auth → participant → deadline → validation and writes the Submission plus an audit event in one
transaction.atomic(). The single writer for scores is judging.services.record_ballot, which
updates the Ballot, appends an immutable BallotRevision, and chains a ballot.recorded audit
event — all atomically, so none can exist without the others.
The reachable HTTP surface
The complete routing table is src/portal/urls.py. The five flat, un-prefixed routes below are the
acceptance-checker contract and are kept byte-stable. Eleven prefixed includes contribute the
rest of the surface — /accounts/, /events/, /submissions/, /judging/, /normalize/,
/api/v1/, /voting/, /comments/, /records/, /bundles/, /webhooks/. The app-level
urls.py for gallery is currently empty (app_name + urlpatterns = []); its views are wired as
flat routes in portal/urls.py, as are embed's two. Three further paths are wired top-level by
contract: /.well-known/dogfood-signing-key is a well-known location (served by
records.views.signing_key, which is also reachable at /records/signing-key), and /embed.js /
/embed/<ext_id> are the entry points a third-party page loads. Finally, awards is included at the
root (path("", include("awards.urls"))) and is declared last, so its
events/<event_ext_id>/awards… patterns are reached only after every earlier pattern has failed to
match — it can shadow nothing. Every include and top-level path beyond the five flat routes is
declared after them, each needs a distinct leading segment, and none is linked from base.html,
so tools/replay.py stays 7/7.
The /submissions/ routes are participant self-service (edit / withdraw / "mine");
they are reachable by URL but intentionally not linked from base.html, so the gallery and nav the
checker renders stay byte-identical. The /events/invite/<ext_id> redeem route is the one events
route addressed to an invitee rather than the organizer; like /submissions/, it is reachable by
URL but not linked from base.html. Path placeholders below are the parameter names the URLconf
actually declares.
| Method(s) | Path | View | Access |
|---|---|---|---|
| GET | /healthz |
inline lambda -> ok |
public |
| GET | /projects |
gallery.views.projects |
public (checks 1–2) |
| GET, POST | /projects/new |
submissions.views.submit |
GET public form; POST needs an authenticated participant (check 3) |
| GET | /api/judge/scores |
judging.views.judge_scores |
judge only; own rows (?judge= mismatch -> 403; unauth -> 401) (checks 4–6) |
| GET | /api/export.csv |
judging.views.export_csv |
organizer only (check 7) |
| GET | /debug/whoami |
gallery.views.whoami |
DEMO-auth proof |
| — | /admin/ |
Django admin | staff |
| GET, POST | /accounts/login/ |
accounts.views.login_view |
public; real email + password login, IP-throttled |
| POST | /accounts/logout/ |
accounts.views.logout_view |
authenticated; POST-only by decorator |
| GET | /events/ |
events.views.dashboard |
authenticated; lists the events the caller organizes + the create form |
| POST | /events/new |
events.views.create_event |
authenticated; creating an event makes the caller its organizer |
| GET, POST | /events/invite/<ext_id> |
events.views.redeem |
authenticated invitee; GET confirms, POST redeems (Ed25519-verified, single-use, rate-limited) -> joins event |
| GET | /events/<ext_id> |
events.views.detail |
organizer of that event; tracks, teams, invites, state |
| POST | /events/<ext_id>/tracks/new, /teams/new |
events.views.create_track / create_team |
organizer of that event |
| POST | /events/<ext_id>/state |
events.views.set_state |
organizer of that event; moves the event lifecycle state |
| POST | /events/<ext_id>/invites/new |
events.views.create_invite |
organizer of that event; mints a signed, single-use judge/participant invite (atomic + audited) |
| GET, POST | /judging/score |
judging.views.score |
judge only; scores own assigned queue (append-only + audited); not a checker route |
| GET | /judging/<ext_id>/progress |
judging.views.progress |
organizer of that event; read-only coverage per judge + submission |
| GET | /judging/<ext_id>/assignments |
judging.views.assignments |
organizer of that event; assignment console |
| POST | /judging/<ext_id>/assignments/add, /remove |
judging.views.assign / unassign |
organizer of that event; add, or remove an unscored assignment (atomic + audited) |
| GET, POST | /judging/<ext_id>/auto-assign |
judging.views.auto_assign |
organizer of that event; GET previews a connectivity-aware assignment plan, POST applies it (rate-limited, atomic + audited) |
| GET, POST | /judging/<ext_id>/rubric |
judging.views.rubric |
organizer of that event; per-criterion weights (preview + next signed run) |
| GET | /submissions/mine |
submissions.views.mine |
authenticated participant; lists own submissions (all states) |
| GET, POST | /submissions/<ext_id>/edit |
submissions.views.edit |
owning team only; revise while accepting (else read-only); atomic + audited |
| POST | /submissions/<ext_id>/withdraw |
submissions.views.withdraw |
owning team only; soft-withdraw while accepting (state -> withdrawn, never a delete); atomic + audited |
| GET | /normalize/, /normalize/leaderboard.json |
normalize.views |
organizer (embargoed) |
| GET | /normalize/results, /normalize/results.json |
normalize.views |
public (frozen signed result) |
| GET | /normalize/results/explain/<ext_id> |
normalize.views.explain_rank |
authenticated owning team or organizer; a read-only plain-language reading of that one row of the published, signed result (never a recompute); every other case is a uniform 404; private, no-store + noindex |
| GET, POST | /normalize/results/publish |
normalize.views.results_publish |
organizer |
| GET | /normalize/diagnostics, /normalize/diagnostics.json |
normalize.views |
organizer; review diagnostics (leave-one-ballot-out residuals, leave-one-judge-out decision influence, coverage, duplicate-title clusters) — explicitly not fraud detection |
| GET | /normalize/pairwise |
normalize.views.pairwise |
organizer; live pairwise-sensitivity recompute (never signed/published) |
| GET | /api/v1/events/, /api/v1/events/<ext_id>/ |
api.views |
public, read-only; event metadata only (no memberships / PII) |
| GET | /api/v1/events/<ext_id>/{tracks,teams,submissions,results}/ |
api.views |
public, read-only; submissions are SUBMITTED-only; results are the frozen signed run or {"published": false} |
| GET | /api/v1/events/<ext_id>/certificate/ |
api.views |
public, read-only; verifiable certificate over the frozen signed run, or 404 when unpublished |
| GET | /api/v1/schema/, /api/v1/docs/ |
drf_spectacular / api.views.CspSwaggerView |
public; OpenAPI 3 schema + self-hosted Swagger UI |
| GET | /api/v1/me/ |
api.views.MeView |
authenticated (personal Bearer token); returns only the caller's own token metadata + event memberships (no PII) |
| GET, POST | /voting/<event_ext_id>/ballot |
voting.views.ballot |
depends on campaign mode: in authenticated mode anonymous -> 401 and a judge of that event -> 403; in the email modes the caller must first confirm an emailed link. POST casts/replaces a budgeted ballot (rate-limited; demo shim exempt) |
| POST | /voting/<event_ext_id>/join |
voting.views.join |
public; email modes only — submits an address to be sent a confirm link |
| GET, POST | /voting/confirm/<token> |
voting.views.confirm |
public; holder of the emailed token. GET confirms, POST creates the voter identity |
| GET | /voting/<event_ext_id>/results |
voting.views.results |
public, but only once an organizer has published and the window has closed; otherwise 404 (never 403), so an in-progress campaign discloses nothing |
| GET, POST | /voting/<event_ext_id>/manage |
voting.views.manage |
organizer of that event; campaign console |
| POST | /voting/<event_ext_id>/publish |
voting.views.publish |
organizer of that event; 409 while the window is still open (atomic + audited) |
| GET, POST | /comments/projects/<ext_id> |
comments.views.project_comments |
GET public JSON list of a project's visible comments (author is the display name, never the email); POST requires authentication (401 otherwise) and is rate-limited |
| POST | /comments/<ext_id>/moderate |
comments.views.moderate |
organizer of the comment's event; soft-hides one comment (401 anonymous, 403 non-organizer) |
| GET | /records/judge?event=<ext_id> |
records.views.judge_record |
authenticated judge of that event; own record only (?judge= mismatch -> 403) |
| GET | /records/participant?event=<ext_id> |
records.views.participant_record |
authenticated participant of that event; own record only |
| POST | /records/verify |
records.views.verify |
public; stateless {"valid": bool} check of an issued record against the deployment public key (CSRF-exempt; writes nothing) |
| GET | /records/signing-key, /.well-known/dogfood-signing-key |
records.views.signing_key |
public; the Ed25519 public key as PEM + X-Signing-Key-Fingerprint |
| GET | /embed.js |
embed.views.embed_js |
public; the dependency-free loader script |
| GET | /embed/<ext_id> |
embed.views.embed_gallery |
public, frame-able (xframe_options_exempt); one event's SUBMITTED-only projects |
| GET | /bundles/<event_ext_id>/export.json, /export.csv |
bundles.views.export_json / export_csv |
site administrator (is_superuser); 401 anonymous, 403 for an authenticated non-superuser — an event organizer role is not sufficient |
| POST | /bundles/import |
bundles.views.import_bundle |
site administrator; takes the target event from the signed body (CSRF-exempt; the is_superuser gate is the authority) |
| GET, POST | /webhooks/<event_ext_id>/endpoints |
webhooks.views.endpoints |
organizer of that event; GET lists, POST registers an endpoint (SSRF-screened, rate-limited) |
| POST | /webhooks/<event_ext_id>/endpoints/<endpoint_ext_id>/delete |
webhooks.views.delete_endpoint |
organizer of that event |
| POST | /webhooks/<event_ext_id>/deliver |
webhooks.views.deliver |
organizer of that event; signed outbound delivery, recorded |
| POST | /webhooks/<event_ext_id>/deliveries/<delivery_ext_id>/retry |
webhooks.views.retry |
organizer of that event |
| GET | /events/<event_ext_id>/awards |
awards.views.podium |
public podium, derived entirely from the frozen, signed normalization result (never a live recompute); before publication it renders a neutral "not yet published" state with no ranking. No per-judge score, judge identity, or PII |
| GET, POST | /events/<event_ext_id>/awards/manage |
awards.views.manage |
organizer of that event; lists prizes, POST creates one (atomic + audited) |
| POST | /events/<event_ext_id>/awards/prizes/<prize_ext_id>/assign, /clear, /remove |
awards.views.assign / clear / remove |
organizer of that event (atomic + audited) |
| GET | /events/<event_ext_id>/awards/topup |
awards.views.topup |
organizer of that event; a pre-finalization review-planning aid over the live, unsigned standings — see below. Cache-Control: private, no-store, Vary: Cookie |
Access control is event-scoped: roles come from the EventMembership table
(organizer / judge / participant), never from global user flags. judge_scores,
export_csv, and the score page all check the caller's EventMembership role; the normalize
views gate on an organizer membership. The one deliberate exception is bundles, whose three
whole-graph export/import endpoints gate on Django's is_superuser site-administrator flag — an
event organizer role is explicitly not sufficient there (bundles.views._site_admin_or_response).
Event resolution is not uniform, and the difference matters. The checker-facing, single-event
surfaces — gallery, submissions, judging's score / judge_scores / export_csv, and every
normalize view — resolve the current event as the single oldest row,
Event.objects.order_by("id").first() (_current_event in gallery, judging, submissions;
normalize.services.current_event). Every newer event-scoped surface instead takes the event's
ext_id from the URL path: the events organizer UI, the judging control room
(/judging/<ext_id>/...), voting, webhooks, bundles, embed, awards, and the nested
/api/v1/events/<ext_id>/… collections. records takes it from an ?event=<ext_id> query
parameter, and comments derives it from the addressed project or comment. The path-scoped
organizer views share one guard shape (_organizer_event_or_response / _organizer_or_response:
anonymous → login, unknown event → 404, non-organizer → 403), so the control room, the events UI,
voting, webhooks, and awards all gate identically.
Awards. The public podium at /events/<event_ext_id>/awards is derived entirely from the frozen,
signed normalization result — never a live recompute — and before publication it renders a neutral
"not yet published" state with no ranking. It shows only published-safe values (title, team name,
place, public q): no per-judge score, no judge identity, no PII.
/events/<event_ext_id>/awards/topup is the one awards route that reads the live, unsigned
standings (normalize.services.leaderboard). For each podium prize it takes the rank cutoff that
prize implies and lists the contenders sitting at or straddling it, with the reason each was
flagged: fewer recorded reviews than the coverage target, a bootstrap rank interval that spans the
cutoff, or a q-gap across the cutoff below a threshold. It is a pre-finalization
review-planning aid — it ranks nothing, assigns no score, is not the final ranking, and is not
fraud detection; awarding a prize stays a separate, explicit organizer action and the official
podium is always derived from the signed result. Its response carries
Cache-Control: private, no-store and Vary: Cookie. None of the awards routes is one of the
acceptance checker's five flat routes, none is linked from base.html, and the root include is
declared last, so none of them can shadow an earlier route.
The seventeen code units
Sixteen first-party apps plus the portal config package. INSTALLED_APPS also lists the
django.contrib.* apps and three third-party entries (rest_framework, drf_spectacular,
drf_spectacular_sidecar); those are dependencies, not code units.
| App | Responsibility | Models? | HTTP routes? |
|---|---|---|---|
portal |
settings, root URLconf, WSGI, the two middlewares, the rate-limit helper, bootstrap CLI | no | root URLconf |
accounts |
custom user (AppUser), demo-session shim, login/logout |
yes | /accounts/ |
audit |
append-only hash-chained log + Ed25519 checkpoints + offline verifier | yes | none |
events |
event / track / team / membership core graph + signed single-use invitations | yes | /events/ (organizer UI + invitee redeem) |
submissions |
submission create endpoint + service, plus participant self-service (edit / withdraw / "mine") | yes | submit (flat route) + /submissions/ |
judging |
assignments, recusals, ballots, revisions, rubric weights, the connectivity-aware assignment planner, scores read + CSV export + in-app scoring | yes | judge_scores, export_csv (flat), score + organizer control room (progress / assignments / auto-assign / rubric, all /judging/) |
normalize |
score-normalization engine, signed runs, publication, review diagnostics, duplicate-title clustering, pairwise sensitivity, explain-my-rank | yes | /normalize/ |
gallery |
public project listing + whoami |
no | flat routes (its own urls.py is empty) |
api |
read-only public REST API (/api/v1/) + OpenAPI 3 schema / Swagger docs + the single authenticated me/ endpoint |
no (no models, no migrations) | /api/v1/ |
apitokens |
personal Bearer tokens (sha256-hashed at rest, soft revoke) + the DRF authentication class | yes | none (it authenticates /api/v1/me/) |
voting |
community-voting campaigns: eligible voters, email-link confirmation, budgeted ballots, tallies, publication | yes | /voting/ |
comments |
per-project comments with organizer soft-hide moderation | yes | /comments/ |
records |
signed participation/judging records + a public verifier and public-key endpoint; computed on the fly | no (model-free) | /records/ + /.well-known/dogfood-signing-key |
embed |
dependency-free third-party embed: loader script + frame-able SUBMITTED-only gallery | no (model-free) | /embed.js, /embed/<ext_id> (wired top-level) |
bundles |
signed whole-event export (JSON + CSV) and signed import, site-administrator only; computed on the fly | no (model-free) | /bundles/ |
webhooks |
organizer-registered outbound endpoints (SSRF-screened), signed deliveries, recorded attempts + retry | yes | /webhooks/ |
awards |
prizes and the public podium derived from the frozen signed result, plus the organizer review-top-up planner | yes | events/<event_ext_id>/awards… (root include, declared last) |
Auth: DEMO shim vs real login
Real login (src/accounts/views.py) authenticates AppUser by email + password, is throttled
per client IP by DOGFOOD_RATE_LIMITS['login'] (default 10/m), and returns 429 + Retry-After
when exceeded. The throttle key is REMOTE_ADDR only — X-Forwarded-For is deliberately not
trusted (spoofable). LOGIN_URL is /accounts/login/; ?next is open-redirect-guarded with
url_has_allowed_host_and_scheme.
DEMO shim (DemoAuthMiddleware) is the path the acceptance checker uses: it never logs in, it
attaches Cookie: session=<token>. That path is CSRF-exempt and rate-limit-exempt (write
views skip throttling when request.demo_shim is set), so the checker's responses stay byte-stable.
It is gated entirely on DOGFOOD_DEMO (independent of DEBUG) and must be off in production.
Integrity spine
Three independent, offline-verifiable mechanisms. All hashing is SHA-256; all signatures are
Ed25519 (cryptography).
- Tamper-evident audit log.
AuditHeadis a single-row chain tip;AuditEventrows form an append-only hash chain (prev_hash->row_hash) with a contiguous per-instanceseq. The append service locks the head rowFOR UPDATEso sequence numbers never race (src/audit/service.py); the pure chain logic andverify_chainlive insrc/audit/hashchain.py. The head is seeded by a migration, not the request path. Beyondrecord_ballotandcreate_submission, the participant self-service writes (update_submission,withdraw_submission), the organizer control-room writes (assign_judge,unassign_judge,set_rubric_weights), and the invitation writes (create_invite,redeem_invite) co-commit their own chained events —submission.revised/submission.withdrawn/judge.assigned/judge.unassigned/rubric.reweighted/invite.created/invite.redeemed— in the sametransaction.atomic()as the write, so a revision or withdrawal, judging configuration, and invitation mint/redeem all ride the same tamper-evident trail as scores (a property of those service paths, not a repo-wide guarantee). The later feature services follow the same contract —voting(campaign.configured,vote.cast,vote.duplicate_refused,results.publish),comments(comment.posted,comment.hidden),webhooks(webhook.registered/.deleted/.delivered/.delivery_failed/.delivery_retried),bundles(bundle.exported,bundle.imported) andawards(prize.created,prize.winner_assigned,prize.winner_cleared,prize.removed) each co-commit their chained event inside the sametransaction.atomic()as their write. - Ed25519 signed checkpoints. A checkpoint signs the chain tip under a per-deployment key at
/state/audit_ed25519_key.pem(O_EXCL,0600) (src/audit/keys.py,receipts.py). Verify offline withpython -m audit.verify <bundle_dir>(src/audit/verify.py). - Signed, reproducible normalization run. A published run commits to
inputs_hashandresult_hashand is signed (src/normalize/signing.py,runs.py).python -m normalize.verifypins the public key by fingerprint, checks the signature, and re-runs the estimator from the pinned inputs, confirming the ranking canonicalizes to the sameresult_hash(src/normalize/verify.py). The combinedpython -m normalize.releaseruns both sub-verifiers and adds four cross-links binding the checkpoint, the run, its audit event, and the published ranking CSV (src/normalize/release.py).
Three further signed artifacts reuse the same /state key and the same single Ed25519
implementation (audit.receipts) under distinct domain tags: the single-use invitation
(dogfood.invite.v1, src/events/invite_signing.py), re-checkable with
manage.py invite_verify <ext_id>; the participation / judging record (dogfood.record.v1,
src/records/signing.py), re-checkable by anyone at POST /records/verify; and the portable
event bundle (dogfood.bundle.v1, src/bundles/signing.py). None of the three serves results
integrity — the invitation carries authorization (who may join in which role), the record attests
participation facts, the bundle binds exported bytes to the operator's key — so all three sit
outside the three-mechanism spine above; per-tag domain separation is what stops any of these
signatures from verifying in another's space. Outbound webhook deliveries are the one signature
that does not use this key: each endpoint is issued its own random secret at registration and
deliveries are HMAC-signed with it (src/webhooks/services.py), so a receiver verifies with a
shared secret rather than the pinned public key.
Honest scope: because the operator holds the private key, a PASS is decisive only if an independent party pinned the public key + fingerprint before judging. This is stated in the code and in Threat model.
Score normalization
Pure numpy, no scikit-learn (src/normalize/engine.py). Ballot composites are modelled as
y = q + b + e (per-submission quality q, per-judge severity b, noise e); ridge penalizes
only b; each connected component of the judge–submission graph is gauged to mean(b) = 0; the
ridge λ is chosen by cross-validation; rank uncertainty comes from a parametric bootstrap.
canonical_result freezes the output so hashes are stable, and ENGINE_VERSION = "ridge-additive-v1"
pins the schema. Every figure in Judging is emitted by manage.py normalize_report,
so the write-up cannot drift from the code.
Rate limiting
A fixed-window limiter over the shared DatabaseCache (src/portal/ratelimit.py); it is
fail-open (a cache outage never becomes an availability outage). settings.DOGFOOD_RATE_LIMITS
holds seven policies, and there are nine live ratelimit.hit( call sites across seven view
modules — submission_write is read from two places in submissions.views, and judging.views
carries both the ballot throttle and an eighth policy name that is not a settings key (below).
Every write throttle except login is skipped when request.demo_shim is set, so the acceptance
checker's responses stay byte-stable; login is keyed on REMOTE_ADDR and is never exempted.
| Policy | Default | Enforced today? | Where |
|---|---|---|---|
login |
10/m |
yes | src/accounts/views.py (login_view), keyed on client IP |
invite_redeem |
20/h |
yes (DEMO shim exempt) | src/events/views.py (redeem), keyed on user |
submission_write |
60/h |
yes (DEMO shim exempt) | src/submissions/views.py — submit, and _write_throttled for edit / withdraw; keyed on user |
ballot_write |
120/h |
yes (DEMO shim exempt) | src/judging/views.py (score), keyed on user |
vote_write |
20/m |
yes (DEMO shim exempt) | src/voting/views.py (ballot POST), keyed on campaign + voter |
comment_write |
60/h |
yes (DEMO shim exempt) | src/comments/views.py (project_comments POST), keyed on user |
webhook_write |
60/h |
yes (DEMO shim exempt) | src/webhooks/views.py (endpoints POST), keyed on event + user |
One further throttle has no settings key: judging.views.auto_assign reads
DOGFOOD_RATE_LIMITS.get("assignment_write", "60/h"), so the auto-assign POST is limited at the
inline default of 60/h (keyed on event + user, DEMO shim exempt) unless an
assignment_write key is added. The three call sites that use .get(..., default) — vote_write,
webhook_write, assignment_write — therefore run even if the key is absent; the other five read
the key directly.
Read-only public API (/api/v1/)
A versioned, read-only JSON API over the data that is already public, served by Django REST
Framework and documented by an OpenAPI 3 schema. It is GET-only by construction — the views are
ListAPIView / RetrieveAPIView / APIView.get, so there is no write path. Every endpoint is
AllowAny and unauthenticated except me/ (below), which requires a personal Bearer token and
returns only the caller's own identity. The single authentication class
(apitokens.authentication.BearerTokenAuthentication) is a no-op when no Authorization: Bearer
header is present, so the public endpoints stay anonymous and byte-identical
(src/portal/settings.py REST_FRAMEWORK). What it serves:
events/andevents/<ext_id>/— public event metadata only (memberships are a reverse relation and are never serialized);events/<ext_id>/tracks/,/teams/— track and team names, event-scoped (team members and their emails are never serialized);events/<ext_id>/submissions/— SUBMITTED submissions only; drafts and withdrawn projects are filtered out in the queryset;events/<ext_id>/results/— the official published ranking read verbatim from the frozen, signed normalization run (never a live recompute), or{"published": false}before an organizer publishes;events/<ext_id>/certificate/— a self-contained verifiable certificate over that same frozen, signed run (result hash, signer fingerprint, public key, Ed25519 signature, the exact signed fields, and the public rank/title/q), or404when the event has no published results. It introduces no new key or signature scheme —normalize.certificaterestates and re-checks the run thatnormalize.signingalready signed — and carries the same published-safe, no-PII fields as the results endpoint;schema/anddocs/— the OpenAPI 3 schema and a self-hosted (no-CDN) Swagger UI.me/— the only authenticated endpoint. With a personal Bearer token it returns the calling token's metadata (name, display prefix, last-used time) and the caller's own event memberships (operationalext_ids and roles) — never an email, display name, another user's data, a ballot, or a per-judge score. Tokens are minted withmanage.py mint_api_token; only a token's sha256 hash is stored (the raw string is shown once and never persisted) and revocation is a soft flag.
Each serializer declares its fields as an explicit allowlist (never fields = "__all__"), so no
per-judge score, ballot, judge identity, invitation, audit row, or user PII (email / display name) can
appear — a property tests/test_api_contract.py asserts without a database. Event-scoped collections
are nested under events/<ext_id>/, so scoping is structural rather than a queryset convention.
Every list is PageNumberPagination-bounded (PAGE_SIZE = 50) and the API is anon-throttled by a
fail-open throttle (src/api/throttling.py; default 240/min, DOGFOOD_RATE_API), so it never
becomes an unbounded amplifier or a hard availability dependency. Mounted after the five flat checker
routes and unlinked from base.html, so tools/replay.py stays 7/7 (src/api/*,
src/portal/urls.py).
Deployment topology
docker-compose.yml:
db—postgres:16-alpine, no published port (reachable only on the compose network),pg_isreadyhealthcheck.web— built fromDockerfile(python:3.12-slim,psycopg[binary], runs as uid 10001, collects static at build), depends ondbhealthy, publishes${DOGFOOD_PORT:-8000}:8000, mounts adogfood_statevolume at/state(secret key + audit key), healthchecksGET /healthz, and defaultsDOGFOOD_DEMO=1.
The image build is itself a gate: the Dockerfile runs manage.py check --fail-level WARNING and
manage.py makemigrations --check at build time, so a model that disagrees with its (hand-authored)
migration, or an unresolved import, fails the build. CI (.github/workflows/ci.yml) builds the
image, runs the containerized pytest tests/ suite against a real PostgreSQL service, and then
restarts the web container to exercise a second, idempotent boot. That suite includes a
network-free release-bundle regression (tests/test_verifier_golden.py), and it is active: the
golden bytes are committed at tests/goldens/golden_bundle.json (generated on a dev machine by
tools/make_golden_bundle.py, never regenerated in CI). Its four tests import only
normalize.verify — no Django DB, no sockets — unpack a fresh copy of the bundle into a temp dir,
and assert that the committed signed bytes pass every check, and that each of three one-byte
tampers is rejected with the failure isolated to the right check: a flipped input score breaks
inputs.json matches inputs_hash, a nudged published q breaks result.json matches result_hash,
and a corrupted hex char in the Ed25519 signature breaks run signature valid. The module-level
skip is a fixture-missing guard only; with the golden committed it does not fire.
Implemented vs planned
The line every reviewer should be able to trust. "Shipped" means reachable and tested; "planned" means the model/service may exist but no endpoint wires it yet.
| Capability | Status | Evidence |
|---|---|---|
Public project gallery (GET /projects) |
Shipped | src/gallery/views.py |
Submission create (POST /projects/new) |
Shipped | src/submissions/{views,services}.py |
Judge reads own scores (GET /api/judge/scores) |
Shipped | src/judging/views.py |
Organizer CSV export (GET /api/export.csv) |
Shipped | src/judging/{views,services}.py |
| Public frozen results + organizer leaderboard/diagnostics | Shipped | src/normalize/{views,urls}.py |
| Real login/logout + login throttle | Shipped | src/accounts/views.py |
DEMO auth shim (gated on DOGFOOD_DEMO) |
Shipped | src/portal/middleware.py |
| Hash-chained audit log + Ed25519 checkpoints + offline verifier | Shipped | src/audit/* |
Append-only ballot revisions (DB CHECK 1..5) |
Shipped | src/judging/models.py, migrations/0002 |
| Signed reproducible normalization run + release bundle + verifiers | Shipped | src/normalize/{signing,verify,release}.py |
| Submission-write / login rate limiting | Shipped | src/portal/ratelimit.py |
| In-app judge scoring (submit a ballot over HTTP) | Shipped | src/judging/{views,services}.py, GET/POST /judging/score |
| Judging-progress dashboard | Shipped — read-only assigned / scored / pending per judge and per submission | src/judging/{views,services}.py, templates/judging/progress.html |
| Judge-assignment management UI | Shipped — organizer console; add, or remove an unscored assignment (atomic + audited; a scored one is refused) | src/judging/{views,services,urls}.py, templates/judging/assignments.html |
| Event / team creation UI | Shipped — organizer creates events, tracks, and teams in-app (the dogfood_import seed still works) |
src/events/{views,services,urls}.py, templates/events/{dashboard,detail}.html |
| Rubric-weight editing | Shipped — organizer sets per-criterion weights (live preview + next signed run; never rewrites a published result) | src/judging/{views,services}.py, templates/judging/rubric.html |
| Submission edit / withdraw | Shipped — a team revises or soft-withdraws its own submission while the event is accepting writes (owner-gated, deadline-gated, atomic + audited); withdrawal flips state to withdrawn and hides it from the gallery but never deletes the row, so ballot/audit history survives |
src/submissions/{views,services,urls}.py, migrations/0002, templates/submissions/{mine,edit}.html, GET /submissions/mine, GET/POST /submissions/<id>/edit, POST /submissions/<id>/withdraw |
| Single-use invitations | Shipped — an organizer mints a signed, single-use invite; redeeming it is Ed25519-verified, single-use (DB-enforced under select_for_update), rate-limited, and atomically creates an EventMembership + invite.redeemed audit event. Roles are limited to judge/participant, so a link can never escalate to organizer |
src/events/{models,invite_signing,services,views,urls}.py, migrations/0002_invite, templates/events/{detail,redeem}.html, manage.py invite_verify; GET/POST /events/invite/<ext_id> |
| App models in Django admin | Shipped — all 30 models registered across 11 of the 12 admin.py modules (gallery has no models to register); append-only/signed tables (audit, ballots, revisions, runs, publications) are inspect-only, the signed Invite table is likewise inspect-only with adding disabled (delete-to-revoke an un-redeemed link is still allowed), and any row whose cascade would reach a scored assignment (the assignment itself, or a parent Event/EventMembership/Submission/Team/AppUser) refuses deletion so ballot history can't be destroyed through the admin UI (raw-DB access is the A8 operator boundary) |
src/*/admin.py, src/portal/admin_mixins.py |
Read-only public API (GET /api/v1/*) + OpenAPI 3 / Swagger |
Shipped — public + GET-only (the one authenticated endpoint, /api/v1/me/, is a separate row below); events, tracks, teams, SUBMITTED submissions, and the frozen signed results (or {"published": false}); explicit-allowlist serializers (no ballots / per-judge scores / judge identity / invites / audit chain / PII), page-bounded + fail-open throttled |
src/api/*, src/portal/urls.py, tests/test_api_contract.py |
Verifiable results certificate (GET /api/v1/events/<id>/certificate/ + manage.py certificate) |
Shipped — a self-contained attestation over the frozen, signed run (result hash, signer fingerprint, public key, Ed25519 signature, the signed fields, and the public rank/title/q); adds no new key or signature scheme (reuses normalize.signing) and no model/migration; refuses any non-published run; exposes no ballots / per-judge scores / judge identity / PII; the CLI self-verifies via the offline bundle verifier before it emits |
src/normalize/certificate.py, src/normalize/management/commands/certificate.py, src/api/*, tests/test_certificate.py, src/api/test_certificate.py |
Personal API tokens + authenticated /api/v1/me/ |
Shipped — a user mints a personal Bearer token (manage.py mint_api_token); only its sha256 hash is stored (raw shown once), and revocation is a soft flag. BearerTokenAuthentication authenticates only /api/v1/me/ (set on the view, not globally), which returns just the caller's own token metadata + event memberships — no email / display name / other users / ballots / scores. With no Authorization: Bearer header the public endpoints stay anonymous and byte-identical |
src/apitokens/*, src/apitokens/migrations/0001_initial.py, src/api/{views,urls}.py, src/portal/settings.py, src/api/tests.py, tests/test_apitokens.py |
Pairwise-sensitivity view (GET /normalize/pairwise) |
Shipped — organizer-only; the model-based P(one project outranks another) from the same bootstrap as the leaderboard's rank intervals, flagging every adjacent pair inside the pre-registered unresolved band; a live recompute, prominently labelled not signed, not published, not a merit score, not fraud detection; behind the same organizer gate as the leaderboard, and never rendered on the public results page | src/normalize/{services,views,urls}.py, templates/normalize/pairwise.html, src/normalize/tests.py |
Community voting (/voting/) |
Shipped — organizer-configured campaign (manage) with three identity modes (authenticated, email_link, email_gated), a budgeted per-voter ballot, join / confirm/<token> for the email modes, rate-limited casts, and tallies that become public only after an organizer publishes and the window closes (hidden tallies answer 404, not 403, so an in-progress campaign discloses nothing). In authenticated mode a judge of the event may not vote (403). No normalize module reads voting, so community votes are a separate tally that never feeds the signed run |
src/voting/*, src/voting/migrations/0001_initial.py, src/voting/{tests,test_pure}.py |
Project comments (/comments/) |
Shipped — GET /comments/projects/<ext_id> is a public JSON list of a project's visible comments (author is the display/short name, never the email); POST requires authentication and is rate-limited; POST /comments/<ext_id>/moderate lets an organizer of that event soft-hide a comment (a visibility flag, not a delete) |
src/comments/*, src/comments/migrations/0001_initial.py, src/comments/tests.py |
Signed participation / judging records (/records/) |
Shipped — a judge or participant fetches their own signed record for ?event=<ext_id> (anonymous 401, wrong role 403, another judge's record 403); POST /records/verify is a public, stateless {"valid": bool} re-check that writes nothing; the deployment's Ed25519 public key is served as PEM at /records/signing-key and /.well-known/dogfood-signing-key with the fingerprint in X-Signing-Key-Fingerprint. Model-free — records are computed from the live event graph |
src/records/*, src/records/{tests,test_signing}.py |
Embeddable gallery widget (/embed.js, /embed/<ext_id>) |
Shipped — a dependency-free loader script plus a frame-able (xframe_options_exempt) standalone page listing one event's SUBMITTED-only projects (withdrawn and draft rows excluded); unknown event → 404. Model-free |
src/embed/*, src/embed/tests.py, src/portal/urls.py |
Event bundle export + import (/bundles/) |
Shipped — export.json is a signed portable whole-event bundle and import reconstructs an event graph from one (400 on a bad signature or body, 409 if the event ext_id already exists, 201 on success); export.csv is an unsigned all-stages submissions CSV served private, no-store. All three gate on the site-administrator (is_superuser) flag: anonymous 401, authenticated non-superuser 403, and an event organizer role is explicitly not enough. Model-free — the export is computed on the fly from the live event graph |
src/bundles/*, src/bundles/{tests,test_signing}.py |
Outbound signed webhooks (/webhooks/) |
Shipped — organizer-of-that-event registers an endpoint (URL screened against SSRF targets before it is stored), triggers a signed delivery, and retries a recorded one; registration POSTs are rate-limited. Deliveries and attempts are persisted rather than fired and forgotten | src/webhooks/*, src/webhooks/migrations/0001_initial.py, src/webhooks/{tests,test_ssrf}.py |
| Judge recusal / conflict of interest | Shipped — a JudgeRecusal (judge, team) row is declared in the Django admin and expanded to that team's SUBMITTED submissions, then fed to the assignment planner as that judge's recusal set, so a recused judge is never planned onto that team's projects. It has no bespoke HTTP route of its own; the admin page is the UI |
src/judging/{models,recusal,services,admin}.py, src/judging/migrations/, tests/test_recusal.py |
Auto-assignment planner (GET/POST /judging/<ext_id>/auto-assign) |
Shipped — organizer-only; GET previews a connectivity-aware plan for a target reviews-per-project k, POST applies it (rate-limited, atomic + audited). The planner itself is pure and DB-free, so it unit-tests without a database |
src/judging/{assignment,services,views,urls}.py, src/templates/judging/auto_assign.html, tests/test_assignment.py |
Explain-my-rank (GET /normalize/results/explain/<ext_id>) |
Shipped — a read-only, plain-language reading of one row of the published, signed result; it never recomputes, so it cannot move a result_hash. Access is the owning team or an organizer; every other case is a uniform 404, and the response is private, no-store + noindex |
src/normalize/{explain,views,urls}.py, src/normalize/templates/normalize/explain.html, tests/test_explain.py |
| Duplicate-title diagnostic | Shipped — submissions in the same track that share the same normalized title (internal whitespace collapsed, stripped, casefolded) are grouped and surfaced on the organizer diagnostics panel as something for a human to look at. Display-only: it flags nothing as fraud, changes no score, and is never fed back into the leaderboard, the signed result, or any hash | src/normalize/{duplicates,services,views}.py, src/normalize/templates/normalize/diagnostics.html, tests/test_duplicates.py |
| Decisive-judge fragility | Shipped — leave-one-judge-out refits (drop all of one judge's ballots, refit, measure how far the decision moves) sit alongside the leave-one-ballot-out residuals in the same organizer diagnostics report. It measures how fragile the ranking is to any one judge — explicitly not whether a judge did anything wrong | src/normalize/diagnostics.py, src/normalize/services.py (diagnostics_report), src/normalize/templates/normalize/diagnostics.html, src/normalize/tests.py |
Prizes + public podium (GET /events/<event_ext_id>/awards) |
Shipped — an organizer creates prizes and may assign a winner explicitly (.../awards/manage, .../awards/prizes/<prize_ext_id>/assign|clear|remove, all organizer-only, atomic + audited). The public podium derives every place from the frozen, signed normalization result (never a live recompute) and renders a neutral "not yet published" state with no ranking until results are published; it exposes only title, team name, place and public q — no per-judge score, judge identity, or PII. Mounted as a root include declared last, off the checker's five flat routes and unlinked from base.html |
src/awards/*, src/awards/migrations/0001_initial.py, src/awards/tests.py, tests/test_podium.py |
Review top-up planner (GET /events/<event_ext_id>/awards/topup) |
Shipped — organizer-only; the one awards route that reads the live, unsigned standings. Per podium prize it lists contenders at or straddling that prize's rank cutoff with a reason each (fewer recorded reviews than the coverage target, a bootstrap rank interval spanning the cutoff, or a q-gap across the cutoff below a threshold). A pre-finalization review-planning aid: it ranks nothing, assigns no score, is not the final ranking, and is not fraud detection. Response is Cache-Control: private, no-store + Vary: Cookie. The planner is pure stdlib — no Django, no numpy — so it unit-tests without a database |
src/awards/{topup,services,views}.py, src/awards/templates/awards/topup.html, tests/test_topup.py |
| Offline release-bundle regression test | Shipped — the golden bytes are committed at tests/goldens/golden_bundle.json, so the test runs in CI: it re-verifies the committed signed bundle and asserts that each of three one-byte tampers (an input score, a published q, a signature hex char) is rejected with the failure isolated to the matching check |
tests/test_verifier_golden.py, tests/goldens/golden_bundle.json, tools/make_golden_bundle.py |
| Multi-event support | Partial by design — the checker-facing surfaces (gallery, submissions, judging's flat routes, all of normalize) still resolve one current event as the oldest row; the event-scoped surfaces (events UI, judging control room, voting, comments, records, embed, bundles, webhooks, awards, /api/v1/events/<ext_id>/…) address the event explicitly |
src/{gallery,judging,submissions}/views.py (_current_event), src/normalize/services.py (current_event) |