Plan 50 NFR Registry - SekiroKenjii/Quizzivy GitHub Wiki

50 — Non-functional requirements registry

One place for every quality the system must have that is not a feature: how fast, how safe, how reachable, how it degrades, what it must never leak. The spec scatters these across §5, §6.5, §10, §11, §12, §13.5, §14 and §16; the plan adds more in 30-risks.md and 15-phase-5.md; and several only became visible while building. This file is where they are tracked from now on.

How to use it. Each requirement has an id, a measurable statement, where it came from, its status, and the evidence or the gap. A PR that satisfies or changes one edits the row in the same PR. New requirements are added as rows, not paragraphs. The tables are reviewed at every release/* cut; the summary at the end says how much is covered and what the next round is.

Status Meaning
✅ Met, with evidence in the repo (a test, a config, a CI job)
🟡 Partly met — the mechanism exists, the verification or a corner does not
⬜ Planned and specified (a T-5.x task or an issue), not started
❌ Not started and not yet planned
❓ Needs a decision from Thuong before it can be planned

A. Security

ID Requirement Source Status Evidence / gap
NFR-S01 Passwords are hashed with Argon2id; hashing concurrency is bounded so a login burst cannot exhaust the 512 MB machine §5.1, R-13 ✅ server/internal/modules/identity/domain, core/wiring/identity.go boundPasswordHashing, fly.toml note
NFR-S02 Access token lives in memory only (~15 min); refresh token is an httpOnly; Secure; SameSite=Lax; Path=/auth cookie, stored hashed, rotated on use, with reuse detection revoking the family §5.2, R-06 ✅ server/internal/auth, web/src/stores/auth.ts, single-flight refresh in lib/api/client.ts
NFR-S03 Google sign-in verifies the ID token (iss, aud, exp, JWKS) and rejects unverified emails; PKCE on the authorization request §5.1, §5.3, O-13 ✅ server/internal/auth/google*, web/src/features/auth
NFR-S04 A student on /admin/* gets a 403 page, never a redirect; mustChangePassword fences every route §5.4 ✅ web/src/app/guards
NFR-S05 Every unauthenticated operation is rate-limited, sized so a class of 40 behind one school address can sign in and join: per address 120/min · 600/h for login, Google sign-in, join preview and in-app join, and 120/min · 1,200/h for refresh and logout; per join code 200/h; login per address and email 10/min, per email 20/h. A keyed body is at most 8 KiB (413). The API refuses to start if a public route has no limiter §6.5, §18, T-R2.15 🟡 httpx/publicroutes.go AssertPublicRoutesLimited, core/router/ratelimits.go; ratelimit_contract_test.go pins every x-rate-limit block to the registry; classroom_limits_test.go (40 students from one address see no 429; the 11th login for one email is 429); login_spellings_test.go (an account written ten ways, as N <[email protected]> or "a"@x.co, spends one budget: the login buckets key on the lower-cased bare address the sign-in looks up); join_preview_test.go (the 121st preview in a minute, the 201st try on one code); join-classroom.live.spec.ts (thirty students join at once). T-5.2 still has to drive the other limits past their thresholds and prove the LRU evicts. Production collation (QC-E-6): not recorded. The login key folds case with Go's strings.ToLower and the lookup with lower(); they agree on libc and builtin collations and differ on ICU en-US and tr-TR, by at most two or three budgets for an account whose address holds those letters. The owner runs TestTheLimiterKeyFoldsNothingApartThatTheLookupFoldsTogether (-tags integration) on a Neon branch of production and writes the provider and the result here: provider _not recorded_, collation _not recorded_, run _not run_
NFR-S06 Public responses leak nothing: /join/preview returns only class and teacher name; join failures do not reveal which classes exist; login does not distinguish unknown user from wrong password §6.5, §9 🟡 Structural (see #27); the second leak review in T-5.2 is not done
NFR-S07 Join codes expire, carry max_uses, and are rotatable in two clicks; how they are stored is NFR-S18 §6.1, §13.3, R-02 ✅ server/internal/modules/classes, the class page's join-code panel
NFR-S08 The student attempt payload never carries isCorrect, sampleAnswer, acceptedAnswers, transcript or teacherNote §13.5, §14 E2E 9 ✅ Explicit column projections; web/tests/e2e/payload-leak.live.spec.ts
NFR-S09 No server secret reaches the SPA bundle; only VITE_* values that are public by design AGENTS.md, #8 ✅ web/tests/integration/bundle-secrets.test.ts, deploy preflight
NFR-S10 CORS is an exact-origin allowlist with credentials, never *; Vary: Origin §4.1 overview, R-07 ✅ httpx/cors.go + cors_test.go
NFR-S11 Every teacher intervention that changes a student's record (extend, reset, void, reopen, flag, note, close early) is audited with actor, reason and the previous value, in the same statement §8, D-04 ✅ OLD/NEW in RETURNING inside data-modifying CTEs; app.audit_log; classes.Update is the one write still unaudited (decision pending)
NFR-S12 The database roles are split: quizzivy_migrate owns, quizzivy_app has DML only; the audit log is append-only for the app role §13.5, #11 ✅ migrations/00001, internal/db grant tests
NFR-S13 Browser hardening headers on both hosts: HSTS, X-Content-Type-Options: nosniff, Referrer-Policy, frame-ancestors 'none', and a CSP matching PKCE, the API and private media origins good practice; not in spec 🟡 API middleware and Pages _headers implemented, and observed in production on 2026-10-04 (../setup/operations.md). Sign-in and media under the CSP await the owner's check (#78). core/router/tests/hardening_test.go.
NFR-S14 Request bodies are size-limited and the server has read/header/idle timeouts good practice ✅ 1 MiB cap before schema validation, streaming media exemption and security-header tests; existing server timeouts unchanged.
NFR-S15 Dependencies: none added without a stated reason; Sonar runs on every push §14 DoD, §18 ✅ CI Sonar job; PR template
NFR-S16 Every operation declares x-permission (a key, a pseudo-key or an any-of list) or is open, and httpx.RequirePermission enforces it on every request; the API refuses to start when an operation breaks that rule, names a key outside the catalogue or its path's tree, or app.permissions lacks a compiled key 70 §4.2 and D6, T-R2.4, T-R2.5 ✅ httpx.PermissionRequirements in router.New; CheckCatalogue in wiring.Build; core/router/tests/permissions_test.go with testdata/permissions.golden (97 operations); core/tests/catalogue_test.go; web/tests/units/contract/openapi-contract.test.ts
NFR-S17 Another owner's id is answered as a missing id: for every id an operation under /teacher/*, /app/* or /me/* takes, a second teacher or student gets the status and body a random id gets, never sees the first one's rows in a list, and leaves them unchanged; a scoped uuid that names no resource kind fails the build 70 §4.2, PR-1, T-R2.16 ✅ server/tests/isolation_*_test.go (tag e2e, CI Server job), driven by the contract's x-resource and x-resource-list; core/router/tests/resource_contract_test.go. No leak found
NFR-S18 Join codes are encrypted at rest: a new code is sealed with AES-256-GCM under JOIN_CODE_KEY, bound to its class and code ids, and found by an HMAC-SHA256 under a key the database never holds; rotating the key is documented and scripted 70 D5, §6.5, §13.3, T-R2.14a, T-R2.14b, T-R5.1 ✅ migrations/00078, classes/domain/joincode_keys.go, joincode_encryption_test.go, server/tests/join_code_key_flow_test.go; maintenance rekey-join-codes and ../setup/operations.md "Join-code key". R4 replaced the live codes issued before v0.8.0 (D5, T-R4.22); R5 removed their SHA-256 lookup, and migrations/00141's class_join_codes_live_encrypted refuses an unrevoked code without a ciphertext (T-R5.1), so a legacy hash a dump reverses names a code that redeems nothing

B. Privacy and data honesty

ID Requirement Source Status Evidence / gap
NFR-P01 Integrity monitoring records only this tab's focus, copy/paste and network events; no devtools heuristics, no camera, no keystrokes §10.1 ✅ web/src/features/integrity/useIntegrityMonitor
NFR-P02 Integrity is reported as evidence for a conversation, never as a verdict: no auto-zero, no "cheating detected", calm copy; the help text states the limits §10.4, §10.5, §12 ✅ G-05 timeline copy; timeline.* strings
NFR-P03 Media objects are private; every read is a short-lived signed URL, never a public bucket §11, docs/setup/r2.md ✅ R2 private bucket, media.SignedURL; #20 fixed cache headers
NFR-P04 Personal data is minimal: email, name, optional Google picture; no phone, no address, no birthdate §7 ✅ app.users columns
NFR-P05 Retention: how long attempt_events (≈50 rows per attempt), audit rows and disabled accounts are kept, and whether a student can be erased not in spec ✅ Approved O-23; spec v0.4; privileged dry-run/batched maintenance and migration 00029. ../setup/operations.md records the limits of structured-identity anonymization.
NFR-P06 Logs carry request id, route, status and latency — never a body, a token or an email good practice ✅ httpx/logging.go. Email (R5, T-R5.2): the dispatcher logs the outbox id, template, outcome, error code, attempt and recipient domain (platform/email/dispatch.go), the sink the file name and recipient domain, never an address, subject or link (sink_test.go, TestTheSinkWritesOneReadableFilePerMessageAndLogsOnlyTheRecipientsDomain); the daily housekeeping logs counts and a failure class only (core/jobs/housekeeping.go); user_import_flow_test.go captures the worker's log and finds no temporary password and no address in it

C. Reliability and data integrity

ID Requirement Source Status Evidence / gap
NFR-R01 The server holds the clock: deadline_at is set at start, the client only renders it; closing early never truncates a running attempt §9, O-05, R-11 ✅ attempts.Start, monitor skew correction, G-09 close-early copy
NFR-R02 Autosave is idempotent and ordered; a reload mid-test loses nothing; a second tab supersedes the first §9, §14 E2E 2/7, R-01 ✅ answers-persist.live.spec.ts, session-takeover.live.spec.ts
NFR-R03 Submit is idempotent; a race between auto-submit, timer expiry and a manual tap yields one submission §14 E2E 5, T-5.1 🟡 timer-expiry.live.spec.ts; auto_submit mode itself is T-5.1 (refused by the server until built)
NFR-R04 Integrity events survive tab close: (attempt_id, session_id, client_seq) uniqueness, ON CONFLICT DO NOTHING, beacon flush with an attempt-scoped token §10.6, R-05, R-12 ✅ 20-data-model.md D-01, attempts.beacon_token_hash
NFR-R05 An assignment pins a published version; editing the test never changes a paper a student has started §13.3, R-04 ✅ tests.TheVersionIsLockedOnceAnybodyHasStarted
NFR-R06 Every migration is reversible and CI proves it (up → down → up) on a clean PG18 §13.7, T-0.17 ✅ CI Server job; make migrate-redo
NFR-R07 Invariants live in the database, not only in code: status ↔ version, audio policy ↔ audio asset, one roster, disabled users leave every denominator §13, D-04/D-05, #55 ✅ CHECK constraints, composite FKs, internal/db construct tests
NFR-R08 Auto-grading is Unicode-normalised so NFD and NFC answers score the same #49 ✅ grading package tests
NFR-R09 A failed integrity flush never blocks answering or submitting §10.6 ✅ fire-and-forget buffer
NFR-R10 DB-backed Go tests cannot be answered by the build cache this project's own lesson ✅ make test-api runs -count=1, as CI does

D. Availability and operability

ID Requirement Source Status Evidence / gap
NFR-A01 The API is always warm: one machine minimum, never auto-stopped, /livez checked by the platform O-16, fly.toml ✅ min_machines_running = 1, auto_stop_machines = false, [[http_service.checks]]
NFR-A02 Nothing deploys from develop; a merge to main deploys only after CI is green, API before SPA, migrations in the release command with rollback on failure docs/setup/deploy.md ✅ deploy.yml workflow_run gate, preflight, post-deploy verification
NFR-A03 The deploy verifies itself: the machine is running and the API answers, or the job fails loudly with the crash log deploy.md ✅ "Verify the API answers", "Why it crashed" steps
NFR-A04 Database backups exist and a restore has been rehearsed: Neon history retention is set to a known window and a point-in-time restore has been done once into a branch good practice ❓ Recovery verifier and isolated-drill procedure are in ../setup/operations.md; actual Neon history window and production PITR remain unverified because project access is unavailable (#79)
NFR-A05 An outage is noticed by a person, not by a student: uptime check on /healthz and the SPA, with a notification good practice 🟡 Fly checks affect routing, not automatic restart. Repository monitor and owner setup: ../setup/operations.md; delivered notification still pending. PRODUCTION_MONITOR_ENABLED unset on 2026-10-04 (#81).
NFR-A06 Unexpected errors are collected with their request id so a student's "it broke" can be found §9 error boundary 🟡 The boundary shows a copyable id and the server logs it; no error tracker aggregates them. Same decision issue as A05
NFR-A07 Secrets are provisioned once and checked before use; a missing one fails the deploy with a name deploy.md ✅ "Check the token is present" steps
NFR-A08 Fitting the machine: 512 MB with Argon2 bounded; memory is raised only alongside the hash bound R-13 ✅ fly.toml comment
NFR-A09 Local development reproduces production: compose PG18 + MinIO, seed, make test-api; one documented loop AGENTS.md, 00-overview.md §9 ✅ docker-compose.yml, Makefile, seed/
NFR-A10 The platform's probe never keeps the database awake: Fly checks /livez, which runs no query, so Neon can suspend its compute when nobody uses the app; /healthz keeps the database probe for deploy verification and the monitor, and both are rate-limited per address #134 🟡 livez and ServiceRateLimits in core/router; fly.toml check path. The one machine is still the only routing target, so a database probe in the platform check changed no outcome except the bill. The maintenance gate reads its window snapshot, at most every 30 s, for any request whose path a route serves, under any method and before authentication; GET /public/status reads the same snapshot. So a request to a path no route serves cannot wake the compute, and a request to a real route path can, with or without a token, at most every 30 s. R5 adds one scheduled wake a day: retention-sweep.yml connects from GitHub Actions at 20:00 UTC (03:00 in Ho Chi Minh City), a dry run until RETENTION_SWEEP_ENABLED is set, for at most its 5-minute budget. The email dispatchers in the API and the worker arm a timer only while an outbox row is pending, so an empty outbox adds none, and the daily housekeeping rides the refresh-token prune's 24-hour tick. Still to measure, including whether route-path probes with no credential keep the compute awake: a night with the compute suspended apart from the sweep's wake, and a zero-traffic day well under 6 CU-hours in the Neon console, posted on #134; R5's release records the week after it (PR-7)

E. Performance and capacity

ID Requirement Source Status Evidence / gap
NFR-E01 Sized for one practice: ~50 students, a class sitting a test at once, a few thousand attempts a year §1.1 ✅ Design assumption throughout; in-process rate limiter, one API instance
NFR-E02 No N+1 on the monitor, grading, timeline or bank screens: a fixed number of statements per screen §13.8, #28 ✅ Monitor = 2 queries; bank list fixed (#28); ReferencesFor one query per page
NFR-E03 Polling is bounded: monitor every 15 s only while open and only in a visible tab §8 ✅ refetchIntervalInBackground: false, tests/units/attempts/monitor.test.tsx
NFR-E04 p95 latency at three years of data: monitor < 200 ms, attempt payload < 150 ms, timeline < 300 ms, autosave < 100 ms, dashboard < 200 ms T-5.5 (replaces §16's "no seq scans") ⬜ Volume seed, EXPLAIN capture and a nightly job do not exist yet
NFR-E05 Route-level splitting: a student's bundle carries no admin code, an anonymous visitor's neither; @dnd-kit only in the builder §2, T-5.4 🟡 tests/integration/router-chunks.test.ts runs a real build and asserts the entry chunk holds no admin, student or take-test tree, and that it does hold the system pages (T-R1.15) and the splash and app states (T-R1.16), which must render when a lazy chunk cannot load; the student and public graphs, and a size budget, are T-5.4
NFR-E06 Initial JS budgets on /login, /app, /app/attempts/:id, enforced in CI; take-test measured on a throttled mobile profile T-5.4 ⬜ No budget, no measurement
NFR-E07 Uploads are bounded: mp3/m4a only, size and duration limits asserted in one place §11.1, #33 ✅ media limits test
NFR-E08 List endpoints paginate uniformly and honour the contract's page size O-20, #42 ✅ TestEveryPageSizeMatchesItsContract

F. Accessibility

ID Requirement Source Status Evidence / gap
NFR-F01 Lighthouse accessibility ≥ 95 on the nine key routes; axe in CI failing on serious/critical §16, T-5.3 ⬜ Not measured; no axe run
NFR-F02 Every interactive element is a real element with a visible focus ring; icon-only buttons carry aria-label; decorative icons are aria-hidden §12 ✅ R1 (T-R1.6): --focus and a 2px :focus-visible ring on every control, tokens.test.ts holding --focus at 3:1 or more on --bg, --card, --sidebar and --muted in both themes. T-R1.17 audited every /app route, the engine included, and every /admin route except the import detail, import review and question-group editor pages (no seeded rows), at 1280: 1,184 of 1,184 Tab stops (the first 40 per page) showed a visible change on focus. Measured 2026-10-04 (#322) in Chromium on the built app, over the student console, sign-in and Join with the Join, Submit and Leave dialogs, at 360 and 1280: 130 Tab stops in each theme, and every settled ring is the 2px solid --focus outline, the 48 on Button, the segmented control and the switch included. The lowest contrast against what is behind the ring is 4.86:1 in light and 6.19:1 in dark, both on --muted. Those three controls reach it through a 150 ms transition from the resting outline colour, so a value read at the instant of focus is not the ring
NFR-F03 The whole attempt is completable by keyboard, including the audio player and fill-blank inputs; Esc always works, fullscreen never traps §10.2, §14 DoD 🟡 #66 fixed the token field and palette; student-interactions.spec.ts covers choice shortcuts, fill-blank editing and dialog isolation at three widths; complete audio/assistive-technology coverage remains T-5.3
NFR-F04 Motion is the deck's, plus our 2px hover lift on cards; under prefers-reduced-motion the marquee, live dot, breath, shimmer and indeterminate bar each have a static state, not a shorter one, and all other motion is cut to 0.01 ms by the global reduced-motion block (spec §12) §12 🟡 R1 (T-R1.4): web/src/index.css defines the deck's keyframes, and each deck class that runs one has a static reduced-motion state; the MarqueeText and LiveDot unit tests cover the pause and the static state. Motion that is not the deck's remains, as spec §12 lists it: the old consoles' skeleton pulse and settings entrance, the vendored primitives' enter and exit, animate-spin on busy states, and the test builder outline's drag-reorder slide
NFR-F05 Colour never carries meaning alone: correct/incorrect, flagged and status badges also carry text §12, F-07 ✅ StatusBadge words + colour; #67
NFR-F06 Side columns resize by keyboard as well as pointer: a focusable separator with a value F-13 ✅ components/shared/SideColumn.tsx, tests/units/shared/side-column.test.tsx

G. Usability and information architecture

ID Requirement Source Status Evidence / gap
NFR-U01 Every list and detail route has loading, empty and error states; empty is one sentence and one action §12, §14 DoD, F-08 🟡 The six shared blocks exist and every list uses them (Đợt 2 re-evaluation); the route-by-route audit and the network-failure-during-attempt copy are T-5.7
NFR-U02 Every mutation answers: a toast on success, the server's reason on failure, a confirm (with a reason where the audit needs one) before anything destructive #69, #70, F-09/F-10 ✅ ConfirmDialog, sonner toasts, InterventionDialog
NFR-U03 One route per thing; states change the bar, not the URL G-09, A-03 ✅ Assignment detail, test detail
NFR-U04 Every relationship is reachable from both ends: an assignment names its classes with a link, a class lists its assignments, and the list can arrive narrowed to a class this round (Thuong's two requirements), G-02/G-06/G-12 ✅ TargetsLine, ClassAssignmentsCard, ?classId= on the list and its facets
NFR-U05 The teacher never has to guess which link to bookmark: the dashboard, the sidebar counts and the grading queue are the daily entry points A-00, G-10 ✅ Dashboard queue, nav.grading count
NFR-U06 The deck is authoritative: every screen is built from docs/design/deck/, and a departure from it is a recorded decision docs/design/README.md, docs/design/gaps.md 🟡 Phase R (70-redesign-overview.md) rebuilds every screen to the new deck; each screen PR carries a deck comparison at five widths, light and dark
NFR-U07 Side columns keep one width per role (F-11), adjustable and remembered per browser (F-13) F-11, F-13 ✅ useColumnWidth, SideColumn
NFR-U08 Icon sizes follow one scale by position: badge or chip 12; table cell, 28px button, tab or text-xs line 14; default 16; student alert 18; top-bar toggle and card list item 20 §12, F-14 ✅ Measured on the running app 2026-09-05; Badge, TableCell and Button xs/icon-xs size their own icons; tests/units/ui/icon-scale.test.tsx
NFR-U09 Destructive actions are reversible where the domain allows: archive not delete, void not delete, close then reopen, undo on the toast §8, G-08, G-09 ✅ No hard delete of a class, test or attempt
NFR-U10 The admin is usable at 768 px with nothing clipped or unreachable; the panel becomes a sheet below 1024 §8, F-12 ✅ F-12 walk (#65); PageAside sheet

H. Internationalisation and time

ID Requirement Source Status Evidence / gap
NFR-I01 Vietnamese first: every string through t(), both locales, no key missing on either side, no empty string §14 DoD, §18 ✅ tests/units/i18n/parity.test.ts, no-hardcoded-strings.test.ts
NFR-I02 Server error messages are localised by Accept-Language, vi default overview §7 🟡 Every message: httpx/language.go (Text, TextFor), held for every transport by server/internal/core/tests/messages_test.go. The per-field sentences in details that the rules word, and the publish violations they word, are Vietnamese only (#284)
NFR-I03 Store UTC, render Asia/Ho_Chi_Minh everywhere, through one module #68 ✅ lib/i18n/datetime.ts, datetime.test.ts
NFR-I04 Search is accent-insensitive on both sides (pg_trgm, client fold) §13.8, D-11 ✅ Trigram index; lib/fold.ts
NFR-I05 A native review of every vi string, prioritising join, integrity and intro copy; layouts hold with the longest strings T-5.8 ⬜ Not done

I. Compatibility

ID Requirement Source Status Evidence / gap
NFR-C01 Audio plays on iOS Safari: one <audio> element, .play() synchronous in the click handler, plays counted server-side §11.3, §11.4, R-03 ✅ AudioPlayer, audio-plays.live.spec.ts
NFR-C02 Student and public routes usable at 360 px with no horizontal scroll; safe-area insets respected §9, §16, T-5.6 🟡 Chromium checks at 360/1024/1440px; physical iOS/Android validation remains T-5.6
NFR-C03 Admin: desktop and tablet, 768 px floor; not made phone-friendly §1.1, F-12 ✅ min-w-[768px] shell
NFR-C04 Fullscreen is a gesture, never trapped; the exit bar always offers a way to submit §10.2 ✅ Intro's Start, engine's exit bar
NFR-C05 Supported browsers are named (current Chrome, Safari, Firefox, Edge; iOS Safari 16+) and E2E runs on at least Chromium §14 🟡 Chromium in CI only; Safari/Firefox/Edge device validation remains pending

J. Maintainability and delivery

ID Requirement Source Status Evidence / gap
NFR-M01 The contract is the source of truth: Go and TS are generated and CI fails on drift; test fixtures are validated against it overview §3 ✅ make gen-check, contractJson in every MSW handler
NFR-M02 CI runs every check a merge needs: Contract, Web (lint, typecheck, format, unit, integration, build), E2E stubbed, E2E live against a real PG18, Server (vet, staticcheck, golangci-lint, gofmt, unit + integration + end-to-end tests), Sonar §14 ✅ .github/workflows/ci.yml, seven jobs including Deck
NFR-M03 Gitflow: features off develop, --no-ff merges, releases through release/* to main, back-merged overview §10 ✅ History
NFR-M04 Comments state contracts only: one paragraph on an exported identifier, none inside a function, none on unexported ones; descriptions in openapi.yaml are the generated docs AGENTS.md ✅ Swept 2026-09-06; held in review
NFR-M05 Migrations are sequential, one concern each, never edited once merged §13.7 ✅ migrations/00001–00029
NFR-M06 The imported deck cannot drift silently: its files are pinned and every local link resolves docs/design/README.md ✅ CI Deck job runs scripts/check-design-deck.mjs (manifest hashes, one template and one script per page, local references)
NFR-M07 Every finding becomes a self-contained issue; the queue is worked in rounds and closed with a comment saying what changed working agreement ✅ #1–#77 closed
NFR-M08 Sonar's rules fail locally and in CI before SonarCloud sees them: eslint-plugin-sonarjs in pnpm lint, golangci-lint with server/.golangci.yml in make lint; both at zero findings §14 DoD ✅ web/eslint.config.js, server/.golangci.yml, CI Web/Server jobs
NFR-M09 The backend is a modular monolith: one bounded context per module, four layers each, dependency direction enforced by a test, domain rules in managers, cross-module needs through ports docs/plan/60-backend-architecture.md ✅ server/internal/core/tests/architecture_test.go
NFR-M10 Go tests come in three tiers (unit, integration, end-to-end), every file in a <layer>/tests/ external package using only the public surface server/README.md ✅ make test-api, CI Server job steps
NFR-M11 The API serves its own reference (Scalar) from the contract it was built from IT review 2026-09-06 ✅ GET /docs, server/internal/platform/apidocs
NFR-M12 Every module's application layer is CQRS: one command or query type per use case with one handler behind Application{Commands, Queries}; the handler contracts, the database context and the repository base are defined once (shared/cqrs, platform/db) and reused docs/plan/60-backend-architecture.md ✅ server/internal/modules/*/application/{command,query}, server/internal/shared/cqrs, server/internal/platform/db/context.go

K. Observability

ID Requirement Source Status Evidence / gap
NFR-O01 Structured JSON logs with a request id on every line; the id is returned in the error envelope and shown by the error boundary overview §7, §9 ✅ httpx/requestid.go, logging.go, ErrorBoundary.tsx
NFR-O02 Health is observable: /healthz answers only when the database does router.go ✅ healthz(deps.DB)
NFR-O03 A person is told when it breaks (see A05/A06) — 🟡 Repository uptime workflow prepared; owner notification setup and delivery test pending. ../setup/operations.md.
NFR-O04 The five dashboard counts and the sidebar counts are one round trip each, so the admin shell never fans out §8, D-19 ✅ GET /teacher/dashboard

Coverage

Category ✅ 🟡 ⬜ ❌ ❓ Total
A. Security 15 3 0 0 0 18
B. Privacy 6 0 0 0 0 6
C. Reliability 9 1 0 0 0 10
D. Availability 6 3 0 0 1 10
E. Performance 5 1 2 0 0 8
F. Accessibility 3 2 1 0 0 6
G. Usability 8 2 0 0 0 10
H. i18n 3 1 1 0 0 5
I. Compatibility 3 2 0 0 0 5
J. Maintainability 12 0 0 0 0 12
K. Observability 3 1 0 0 0 4
Total 73 16 4 0 1 94

Counts include all twelve maintainability requirements. Implemented mechanisms remain partial where deployment, notification delivery or device evidence is still required.

The next rounds

Đợt 4 — before release/* to main. Everything that changes what a stranger on the internet can do, or what is lost if the database is:

  1. NFR-S13 verify sign-in and media under the deployed CSP: the owner's check (#78). The headers themselves were observed in production on 2026-10-04.
  2. NFR-A04 record Neon's retention and rehearse one restore into a branch (issue).
  3. NFR-S05/S06 T-5.2 — drive every public limit past its threshold, re-run the leak review.
  4. NFR-A05/A06/O03 — activate the prepared availability monitor and prove owner notification delivery; error aggregation remains deferred.

Đợt 5 — Phase 5 proper, after the first release. T-5.3 accessibility (NFR-F01–F03), T-5.4 bundle budgets (E05/E06), T-5.7 state audit (U01), T-5.8 language review (I05), T-5.6 360 px on a real phone (C02), T-5.1 auto_submit (R03), and NFR-C05 writing the browser list down.

Đợt 6 — once there is a year of data. T-5.5 the volume seed and p95 budgets (E04), the first authorized NFR-P05 maintenance run under approved O-23, the audit-log screen the plan already lists under "not in v1".

Standing. NFR-A04 needs Neon access and confirmation of the actual restore window. O-23 resolved the retention policy; maintenance remains an explicit privileged operation. Each remaining gap has an issue or Phase 5 task.


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

⚠️ **GitHub.com Fallback** ⚠️