Skip to content

Plan 30 Risks

github-actions[bot] edited this page Oct 14, 2026 · 1 revision

30 — Risk register

Twelve risks. Each: what goes wrong, how likely, blast radius, the cheapest mitigation, and the phase it first bites.

Likelihood and radius are judged for this project — one teacher, ~50 students, one developer — not for a product with a support rota.

# Risk Likelihood Radius First bites
R-01 Take-test engine loses student work Medium Severe Phase 3
R-02 Join code leaks and strangers enrol Medium Moderate Phase 1
R-03 Audio silently broken on iOS Safari High Severe Phase 3
R-04 Test versioning fails to isolate in-flight attempts Low Severe Phase 2
R-05 Integrity events dropped on resume High if unfixed Moderate Phase 3
R-06 Refresh stampede logs everyone out on load High if unfixed Severe Phase 1
R-07 Cross-origin cookie/CORS misconfiguration Medium Severe Phase 1
R-08 Pure-Go duration probe wrong on VBR MP3 Medium Minor Phase 2
R-09 Credentials missing at a phase boundary High Moderate Phase 0
R-10 Single-developer scope creep High Moderate all
R-11 Client clock skew corrupts the timer Medium Moderate Phase 3
R-12 Beacon flush unauthenticated or dropped Medium Minor Phase 3

Phase R, the redesign, adds programme risks PR-1 … PR-10 in 70-redesign-overview.md §9, which records each mitigation as it lands (R2: PR-1, PR-2 and PR-5; R4: PR-6, closed below under R-02). Multi-teacher ownership changes the radius of several risks above; each release file says which.


R-01 — The take-test engine loses student work

What goes wrong. A student answers ten questions, the tab reloads or the network blips, and the resume merge picks the server's older copy. The work is gone and the student has no recourse mid-exam. §1.2 makes not losing work goal number one, so this is the failure that matters most.

Why it is plausible. Three writers touch the same state: the local store, the debounced flush, and the server's copy on resume. Merge order is easy to get backwards, and the bug only appears under a timing window that manual testing rarely hits.

Blast radius. Severe and unrecoverable. There is no undo for an exam.

Cheapest mitigation. T-3.10's resume rule stated as an invariant rather than an implementation detail — server answers are the base, un-flushed local answers newer than the server's updated_at win, never the reverse — plus take-test/resume.test.ts covering both directions and the drop case. Answers buffer locally and a failed flush retries with backoff rather than discarding. E2E 2 exercises the reload path end to end.

Residual. Unconfirmed answers are now cached per student, attempt and session in localStorage, with expiry checked against server time on recovery. This closes the ordinary reload/debounce gap. Storage denial, device loss or an expired or superseded session can still prevent recovery. Explicit logout clears drafts; only the server confirms a save. Submission waits for in-flight saves and refuses to discard an unconfirmed edit after a network failure.

The rebuilt engine (R3, v0.9.0) keeps those mitigations and adds three guards, each pinned by a test:

  • The local draft's format is unchanged. take-test/draft.ts and take-test/store.ts are the files v0.8.0 shipped. web/tests/units/take-test/draft-compat.test.ts restores a quizzivy.answer-draft.<attemptId> entry and a quizzivy.flags.<attemptId> entry as a v0.8.0 tab left them, and holds what this build writes to the same bytes under the same keys. An attempt in progress at the deploy keeps its unsent answers.
  • A phone is told when its saves stop. Below 768 the header has no save line. SaveStrip (take-test/components/SaveState.tsx) draws one under the header only when a save has failed, or the device is offline, while an answer is unsaved. A save on its way draws nothing. Where the assignment counts departures the strip also carries that count, and a locked paper shows why it is locked in its place. "The header on a phone" in web/tests/units/take-test/header.test.tsx pins each case.
  • An unknown question type takes no answer. A tab loaded before a release that adds a type drew it as options and wrote a choice answer. questionKind (take-test/questionType.ts) reads such a type as unknown: QuestionSheet draws UnknownType, which names the question and offers a reload, and the keys write nothing. web/tests/units/take-test/renderers.test.tsx ("a type this page has no renderer for") and panes.test.tsx ("an unknown question type") pin it with a fabricated type.

R-02 — A join code leaks and strangers enrol

What goes wrong. A student forwards the code, or a screenshot reaches a group chat, and people who are not in the class enrol themselves.

Why it is plausible. §6.1 makes the code a bearer secret handed to teenagers over messaging apps. This is the normal case, not the adversarial one.

Worth naming clearly: brute force is not the threat. 32⁸ ≈ 1.1 × 10¹² with a handful of live codes makes random probing hopeless even unthrottled, so §6.5's "still worth probing at scale" overstates the guessing risk. The rate limit is still correct — it bounds a cheap nuisance and protects the endpoint — but it is not what defends against the realistic failure.

Blast radius. Moderate. A stranger sees class name, teacher name, and any assignment targeted at the class. No other student's data (§6.5 keeps the preview to three fields, the class's id and name and its teacher's display name, and there is no student directory).

Cheapest mitigation. The things that actually address forwarding: a max_uses default equal to the expected class size rather than §6.1's null = unlimited (see 40-open-items.md), the 30-day expiry, and §6.4's member list showing joined_via and joined_at so an unexpected enrolment is visible. D-10 adds join_code_id so that after a rotation the teacher can tell which code someone came through. Rotate-and-remove, per §6.5's deliberate no-approval-queue decision.

Residual. A stranger can enrol before the teacher notices. Accepted — §17.2 judges an approval queue not worth the state machine, and I agree.

Legacy codes (PR-6, closed in R4). A code issued before v0.8.0 is held only as its SHA-256, so its teacher could not read it back to see where it had gone, and replacing every one at once would have stopped working codes with no warning. R2 sealed the codes it issued (D-27); R4 replaced the rest, once, at the first start-up of v0.10.0 (T-R4.22, jobs.RotateLegacyJoinCodes, docs/setup/operations.md "Legacy join codes"). Each class is its own transaction under advisory lock (73819, 41), so two machines rotate it once. The new code keeps the old one's expiry, use cap and use count (F-67), so a code at its cap is used up again; the class's self-join switch is not touched; the audit row says legacy_rotation and carries hints only. Each affected teacher is told once, in the app, by a join_codes.rotated notification that names how many classes and the first five (T-R4.41 shows the callout on Classes while it is unread). The old code then answers as any replaced one does. What stays open is the release's, not the code's: a class the run could not reach waits for the next start-up, a teacher whose notification failed is told only by the release note, and the count of classes to rotate is read once from a copy of production for that note (T-R4.52). The scheme-1 lookup branch stays until R5 removes it.


R-03 — Audio silently broken on iOS Safari

What goes wrong. The play button does nothing on iPhone. Listening questions are unanswerable, and the student is mid-exam with no recourse.

Why it is plausible. iOS requires .play() to originate from a gesture handler, not from an async continuation. Any await before the call — a signed URL fetch, a play-event POST, a store update that yields — breaks it. The refactor that introduces the await will look harmless and will pass every test run in a headless Chromium.

Blast radius. Severe. §1.1 says students are often on a phone, and iOS Safari is the majority of those.

Cheapest mitigation. T-3.12 calls .play() synchronously in the click handler with nothing awaited before it, and the signed URL is fetched before the button becomes enabled, not on click. audio-player.test.tsx asserts the call happens in the same synchronous tick as the click, so the regression fails in CI rather than on a student's phone. T-5.6 verifies on real iOS Safari, not a simulator. AGENTS.md lists features/media/ as high-risk: run its tests before and after every change.

Residual. iOS also allows only one audio element to play at a time; §11.3's one-player-per-question rule handles it, and navigating away releases the player.


R-04 — Versioning fails to isolate in-flight attempts

What goes wrong. The teacher fixes a typo in a published test while a student is taking it, and the student's paper changes underneath them — or worse, the grading key changes and previously-correct answers become wrong.

Why it is plausible. The snapshot is only correct if every read path in the student and grading flows goes through test_version_* and never through the bank. One query joining back to questions for convenience reintroduces the coupling, and it will look like a harmless optimization.

Blast radius. Severe and silent. Scores are wrong and nothing errors.

Cheapest mitigation. Structural: attempt_answers.question_id references test_version_questions with RESTRICT (20-data-model.md §10), so an answer cannot point at a bank row even by accident. T-2.10's publish_snapshot_test.go — "editing the bank question's prompt after publish leaves the version's prompt unchanged" — is the canary; AGENTS.md marks it as never-delete. §7's rule that student test content is fetched only via GET /app/attempts/:id keeps the surface to one endpoint.

Residual. media_asset_id intentionally points at the same immutable asset rather than a copy (§11.1), so deleting the underlying R2 object would break an old version. ON DELETE RESTRICT plus the 409 in T-2.4 prevents the row deletion that would precede it.


R-05 — Integrity events dropped on resume

What goes wrong. A student's tab crashes, they reopen the attempt, and every integrity event from that point on fails to insert. The timeline shows the teacher a clean record for exactly the period that warranted scrutiny.

Why it is plausible. §13.3's UNIQUE (attempt_id, client_seq) combined with §10.6's sessionStorage-buffered counter guarantees it: sessionStorage does not survive a tab close or a device change, the counter restarts at 1, and every event collides. §10.6 also mandates fire-and-forget, so nothing surfaces the failure.

Blast radius. Moderate — no student is harmed, but the feature §10 calls "first-class" quietly stops working in its most important case.

Cheapest mitigation. D-01: add session_id to the table and make the key (attempt_id, session_id, client_seq), plus ON CONFLICT DO NOTHING so a retried sendBeacon batch cannot fail on one duplicate row. Regression test in T-3.8: the same client_seq from two sessions both persist. Cost is one column.

Residual. None material once fixed. This is on the register because it would have shipped.


R-06 — Refresh stampede logs everyone out on load

What goes wrong. Every page refresh logs the user out. The app looks broken in the most visible possible way.

Why it is plausible. §5.2 holds the access token in memory only, so a cold load has none. TanStack Query mounts several queries at once, all 401, all call /auth/refresh. Rotation means the second call presents an already-rotated token, and §5.2's reuse detection then revokes the entire family — correctly, by its own rules. Neither §5.2 nor §5.4 mentions serializing the refresh.

Blast radius. Severe in perception, trivial in data. Every user, every load.

Cheapest mitigation. T-0.12's single-flight refresh: one shared in-flight promise in client.ts, with client.refresh.test.ts asserting five concurrent 401s issue exactly one POST /auth/refresh. Roughly fifteen lines.

Residual. Two browser tabs racing on separate JS contexts can still collide. Rare, self-corrects on the next login, and the alternative (a cross-tab lock via BroadcastChannel) is not worth it at this scale.

Residual added by T-R4.9 (2026-10-10). Signing out a device, or every other device, moves the caller's session epoch, so the access token of every tab the caller has open is refused on its next request, all at the same moment. Tabs of one browser share one cookie and have separate single-flights, so two visible tabs of a device can send POST /auth/refresh inside one round trip; the second presents a token the first just rotated, reuse detection fires, and that device's family is revoked and its user signs in again. The estimate is the overlap window over the poll interval, per extra visible tab on a device: about 0.3 s of round trip over the 60 s of useNavCounts and useNotificationSummary, so about 0.5% per revoke. A hidden or idle tab does not poll and meets its 401 on resume, staggered. ChangePassword and every other write that ends access carry the same exposure; the revoke only makes it voluntary. A second revoke started while the first one's refresh is in flight can also leave a retried request with a second 401, which shows the sign-in overlay; T-R4.43 disables the revoke buttons while one is pending. The durable fix is a cross-tab refresh lock in the client (Web Locks or a BroadcastChannel), tracked as F-49 because client.refresh.test.ts is a canary.

Residual added by F-66 (2026-10-12). A rotation commits in the database but its answer never reaches the client when the connection ends from the moment the COMMIT is sent to the moment the answer arrives: a tab closed or reloaded, or a network drop, in that round trip. A cancel during the commit's own round trip can leave the successor committed while the handler reports context.Canceled. The client keeps the old cookie, and its next refresh presents a rotated-out token, which is reuse and revokes the family. The web client never aborts the refresh fetch (performRefresh takes no signal, api() strips the caller's signal from cookie operations, waitForTransition stops waiting without aborting), so only the browser unloading the page or the network can do it. Finishing the rotation on a context the request's end cannot cancel would make it worse: it would commit a successor for every client that has gone. A request cancelled before the commit is sent rolls back and leaves the old token live, which session_writes_cancel_test.go pins for a cancel while the rotation waits for its lock. Only a reuse interval closes the window (a predecessor presented within seconds of its rotation, with its successor unused, is a retry), and that changes §5.2's rule that presenting a rotated token revokes the family; it is Thuong's decision and is not built. A logout is the other way round: its revoke runs under command.LogoutBudget, so it finishes for a client that has gone.


R-07 — Cross-origin cookie or CORS misconfiguration

What goes wrong. The refresh cookie is never sent, so sessions die after 15 minutes with no error anyone can see. Or CORS is loosened to * during debugging and credentials stop working — or worse, keep working.

Why it is plausible. SameSite=Lax is subtle: it permits same-site cross- origin requests, which is exactly the app.quizzivy.com → api.quizzivy.com case, but a reader who conflates site with origin will "fix" it to SameSite=None. A 127.0.0.1 vs localhost split in local dev makes requests genuinely cross-site and produces a bug that vanishes in staging.

Blast radius. Severe — nobody can stay logged in.

Cheapest mitigation. 00-overview.md §4.1 states the topology and why it works, so the reasoning is not rediscovered. T-1.3's auth/cookie_test.go asserts the exact Set-Cookie attributes including the absence of Domain. T-0.14's CORS middleware rejects * with credentials by construction, and httpx/cors_test.go asserts an unlisted origin gets no allow header. T-0.6 pins local dev to localhost on both ports.

Residual. A future move to a genuinely cross-site host would break this. That is why the topology is recorded as an architecture decision rather than a deployment note.


R-08 — The pure-Go duration probe is wrong on VBR MP3

What goes wrong. A VBR MP3 without a Xing header reports the wrong duration. A 4-minute file is rejected as over 5 minutes, or the "3:20" shown to the student does not match what plays.

Why it is plausible. VBR without a Xing/Info header requires counting every frame; a naive bitrate-times-size estimate is wrong by a wide margin. M4A adds 32-bit versus 64-bit mvhd and inconsistent atom ordering between encoders. This is the known cost of choosing pure-Go over ffprobe (00-overview.md §5).

Blast radius. Minor and visible. The teacher is the only uploader, sees the result immediately, and can re-encode. Nothing silently corrupts.

Cheapest mitigation. T-2.2's fixture corpus, which exists precisely to cover the failure modes: VBR with a Xing header, VBR without one, a large ID3v2 tag, iTunes M4A, ffmpeg M4A, plus a renamed WAV and a truncated file as rejects. Duration asserted within 1% of known values. A file that sniffs correctly but cannot be probed is rejected rather than stored with a null duration, so the failure is a clear message instead of a bad record.

Residual. An encoder nobody tested. Escalation path is one commit: shell out to ffprobe and add the binary to the image.


R-09 — Credentials missing at a phase boundary

What goes wrong. Phase 1 finishes and cannot be verified because the Google OAuth client does not exist. Phase 2 cannot deploy because R2 has no bucket.

Why it is plausible. Neither exists today, both need Thuong, and both are the kind of task that slips because no code depends on them until the moment everything does.

Blast radius. Moderate — schedule, not correctness.

Cheapest mitigation. T-0.2 and T-0.3 are the second and third tasks in the plan, owned by name, with their blocked phases stated. R2 is de-risked further by T-0.6's MinIO service: local development and every test run against the same S3 API, so only deployment waits on the real bucket. Google has no workaround — GIS cannot be meaningfully end-to-end tested without a real client ID — which is why it is flagged BLOCKING with no default in 40-open-items.md.


R-10 — Single-developer scope creep

What goes wrong. v1 grows a vocabulary module, an analytics dashboard, or a notification system, and test-taking never ships end to end.

Why it is plausible. §1.3's non-goals are long precisely because the temptations are obvious, and there is no second person to push back.

Blast radius. Moderate — the one capability §1 says v1 must deliver slips.

Cheapest mitigation. Phase exit criteria are E2E tests, not judgement calls, so "done" is a green suite. This plan already cuts two things toward that end: auto_submit moves to Phase 5, and §16's unachievable seq-scan criterion is replaced with a measurable budget. 40-open-items.md holds a P1 list so a good idea can be recorded and dropped in the same minute.


R-11 — Client clock skew corrupts the timer

What goes wrong. A student's device clock is fifteen minutes fast. The countdown shows zero while the server still considers the attempt live, or the reverse — the student believes they have time they do not.

Why it is plausible. Phone clocks drift, and a manually-set clock is not rare. §7 marks durationMinutes server-enforced and §15 returns serverTime, but nothing says the client must use it as an offset rather than as a display value.

Blast radius. Moderate — confusing and unfair, but the server's deadline_at remains authoritative so no grade is actually wrong.

Cheapest mitigation. T-3.10 computes remaining as deadlineAt - (Date.now() + offset) where the offset comes from serverTime, with take-test/timer.test.ts running the case of a device five minutes fast. The offset is refreshed on every autosave response, so drift during a long test self-corrects.

Residual. A clock that changes mid-attempt (a timezone move, an NTP correction) causes one visible jump. Acceptable, and the next autosave fixes it.


Update after T-2.2. The prober is built and the arithmetic is verified, but the corpus is SYNTHESISED (testdata/generate.go writes each file byte by byte) rather than encoded. That makes every expected duration exact and each case isolated — a 32 KB ID3v2 tag of 0xFF, a Xing frame count, a VBR stream with no header at all — and it proves the parsing is right.

What it does not prove is that the parser survives real encoders: LAME's exact padding, iTunes' atom ordering, the stray byte between frames that files from the wild sometimes carry. The residual risk is unchanged in kind and smaller in size. Closing it needs no code: drop real .mp3/.m4a files into server/internal/media/probe/testdata/ and add a row to the table in probe_test.go. Worth doing with the first real audio Thuong uploads.

R-13 — Argon2id memory exhaustion on a 512 MB machine · Phase 1

Likelihood: high · Blast radius: the whole API · First bites: the first lesson where a class signs in together

Argon2id is memory-HARD by design. m=65536 is not a budget, it is a 64 MiB arena held for the duration of every call. Measured on this build:

concurrent hashes peak RSS
1 67 MiB
4 260 MiB
8 517 MiB
30 1927 MiB

The production machine is shared-cpu-1x with 512 MB, so eight simultaneous logins exceed the whole instance before the Go runtime, the pgx pool and HTTP buffers are counted. Thirty students signing in at the start of a lesson is an ordinary Tuesday. The symptom is not slowness — it is the OOM killer.

§6.5's rate limits do not bound this: they are per-IP and per-email, and thirty students on thirty phones are thirty IPs. Nor is it only accidental — every failed login for an unknown email runs a full hash on purpose (the timing equaliser), so an attacker spending nothing makes the server allocate 64 MiB a request.

Mitigation, taken: a concurrency bound in internal/auth/password.go (MAX_CONCURRENT_PASSWORD_HASHES, default 4). Callers queue rather than being refused, and a caller whose client has gone gives up its place via the request context. Bounded at 4, thirty logins finish in ~1.8s with a 256 MiB ceiling; unbounded they peak near 2 GiB. hashbound_test.go measures RSS and fails if the bound stops holding.

Not taken: raising the instance to 1 GB (+$2.51/month). It would work, but the bound is needed anyway — without it the ceiling just moves, and the DoS shape is unchanged.

Not taken: lowering m. That trades password strength for memory, which is the wrong direction when a cheap scheduling fix exists.

R-12 — The beacon flush is unauthenticated or dropped

What goes wrong. The pagehide flush never lands, so the final events before a student closes the tab — the most interesting ones — are missing.

Why it is plausible. §10.6 specifies sendBeacon, which cannot set an Authorization header, and §5.2's access token is ~15 minutes while a test runs 45–90. At pagehide the token has almost always expired. A CORS preflight on unload is also not reliably delivered.

Blast radius. Minor. Integrity data is evidence for a conversation (§10.5), never proof, and never blocks a student.

Cheapest mitigation. The attempt-scoped beacon token from 00-overview.md §4.3: minted at attempt creation, valid until deadline_at, stored hashed as attempts.beacon_token_hash (D-03), sent in a text/plain Blob body so the request is CORS-safelisted and skips preflight. The endpoint grants append-only event access, so the weaker credential buys no extra authority. integrity/beacon_test.go covers valid, expired, and the attempted-read case.

Residual. sendBeacon is best-effort by design and a killed process may still lose the last batch. §10.1 calls it best-effort; not worth more.


Generated from docs/plan/30-risks.md. Edit that file in the repository; changes made on this page are overwritten.

Home

Start here

Product

Implementation plan

Redesign (Phase R)

Setup and operations

Design

Team

Code READMEs

Clone this wiki locally