Study/audit-results/FINDINGS.json

1024 lines
79 KiB
JSON

{
"audit": "Study Desk comprehensive read-only audit",
"generatedAt": "2026-08-06T00:00:00Z",
"method": "10 specialist subagent audits (DB, quiz, flashcards/SRS, auth/sharing, imports, groups, frontend, tests, ops, adversarial) + parent verification (baseline commands, migration chain on temp DB, drift reproduction, code-path re-tracing). Read-only; no tracked files modified.",
"outOfScope": "Arcade feature internals (may be removed) except where they affect the main app, shared dependencies, database integrity, build, or deployment.",
"findings": [
{
"id": "FIN-01",
"sourceIds": ["DBAUD-01", "GRP-01"],
"title": "Schema/migration drift: MaterialGroup table and Deck/QuizSet/ShareLink.groupId columns were never migrated — every fresh deployment's database is incompatible with the app",
"severity": "Critical",
"confidence": "Confirmed",
"files": ["prisma/schema.prisma:33-34,61-62,128,133,196-208", "prisma/migrations/20260628011409_init/migration.sql:10-19,32-40,92-101", "src/generated/prisma/models/Deck.ts:44,54,64,201", "src/app/api/material-groups/route.ts:24,43,49", "package.json:6", "docker-entrypoint.sh:3"],
"functions": ["createDeckFromImport", "createQuizSetFromImport", "toggleShareLink", "isContentSharedViaGroup", "MaterialGroup CRUD routes", "predev", "docker-entrypoint.sh"],
"scenario": "Fresh clone → npm run dev (predev runs prisma migrate deploy) or docker-compose with empty volume → open any class page. GET /api/decks/list fails with 'no such column: Deck.groupId'; GET /api/material-groups fails with 'no such table: MaterialGroup'. Verified: temp DB built from the 4 migrations has no MaterialGroup table and no groupId columns; Prisma-shaped SELECTs fail with the exact errors above.",
"expected": "migrate deploy produces a DB matching schema.prisma (groups feature present).",
"actual": "The migration chain (the only DB-init path in dev and Docker) produces a DB missing the entire groups feature; even Deck/QuizSet/ShareLink reads fail because the generated client selects groupId.",
"rootCause": "Commit 7af0935 changed schema.prisma (+31 lines) and updated the committed dev.db binary (a prisma db push artifact) but created no migration. The developer's db-push-synced local DB masks the drift; AGENTS.md rule 'change schema, then create a new migration' was violated.",
"impact": "On any fresh install/deploy (new machine, new container volume, CI), the core app is non-functional: library pages fail, group/import/reorder/share operations error. No data loss on migration-tracked DBs; a db-push DB without _prisma_migrations would make migrate deploy fail with 'table already exists'.",
"evidence": "git show 7af0935 --stat (dev.db 139264→155648 bytes, schema +31, no migration); git log --all -- prisma/migrations (only 4 migration commits); audit-results/tmp/inspect-db.cjs + reproduce-drift.cjs runs (no such column/table errors); grep MaterialGroup|groupId across migrations = 0 hits.",
"testCoverage": "None. Only arcade + spacedRepetition tests exist; no migration/drift/integration test.",
"fixDirection": "Create a new migration (prisma migrate dev --name add_material_groups) adding MaterialGroup table, groupId columns with FKs (SET NULL for Deck/QuizSet, CASCADE for ShareLink), and the ShareLink_groupId_key unique index; verify with prisma migrate diff --from-migrations --to-schema-datamodel (empty diff); add CI drift gate. Do NOT edit applied migrations.",
"fixComplexity": "M",
"regressionTest": "CI: migrate deploy on fresh temp DB → prisma migrate diff empty; smoke query prisma.deck.findFirst() + prisma.materialGroup.count().",
"needsRuntimeConfirmation": false
},
{
"id": "FIN-02",
"sourceIds": ["AUTH-01", "OPS-03"],
"title": "Hardcoded fallback SESSION_SECRET enables full session-cookie forgery and authentication bypass when the env var is unset (the shipped docker-compose default)",
"severity": "Critical",
"confidence": "High-confidence inference",
"files": ["src/lib/auth.ts:14-22", "src/proxy.ts:6-11,31-43", "docker-compose.yml:10", "src/services/authService.ts:61-67,128"],
"functions": ["getSession", "createSession", "isAuthenticated", "proxy()", "login", "completePasswordReset"],
"scenario": "docker compose up without SESSION_SECRET in .env (no .env.example exists, README silent) → compose interpolates an empty string → both lib/auth.ts and proxy.ts fall back to the public constant 'dev-session-secret-change-in-production-must-be-32-chars'. Anyone with the source seals {isAuthenticated:true, sessionGeneration:1} with that password (iron-session seal) and sets cookie study-app-session=<seal>; the proxy decrypts it successfully and grants full access to every protected route and API.",
"expected": "Missing production secret should fail hard at startup.",
"actual": "Silent fallback to a publicly-known secret; sessionGeneration defaults to '1' (only password reset bumps it), so a forged generation:1 cookie is valid in the default state.",
"rootCause": "process.env.SESSION_SECRET || <public constant> in two places with no startup validation; compose passes possibly-empty variable; no documented secret provisioning.",
"impact": "Complete loss of confidentiality, integrity, availability of the entire single-user dataset (decks, cards, quizzes incl. rationales, attempts, progress, settings) on any deployment that omits the variable — read, modify, or wipe everything.",
"evidence": "auth.ts:15 and proxy.ts:8-9 use the identical fallback; compose:10 passes ${SESSION_SECRET}; grep shows no other consumption or validation; iron-session seals with the password (verified in node_modules/iron-session).",
"testCoverage": "None (no auth tests).",
"fixDirection": "Refuse to boot in production when SESSION_SECRET is unset or equals the known fallback (throw at module load); compose: SESSION_SECRET: ${SESSION_SECRET:?must be set}; ship .env.example; document openssl rand -hex 32.",
"fixComplexity": "S",
"regressionTest": "Unit: sessionOptions/proxy reject missing/known secret under NODE_ENV=production; integration: cookie sealed with fallback rejected when a real secret is set.",
"needsRuntimeConfirmation": true
},
{
"id": "FIN-03",
"sourceIds": ["QUIZ-01", "ADV-01"],
"title": "In-viewer 'Retake Missed' is persisted as a full attempt with a wrong, lower score and pollutes SEQUENTIAL progress",
"severity": "High",
"confidence": "Confirmed",
"files": ["src/components/quizzes/QuizViewer.tsx:137,219-288,295-310", "src/app/api/quizzes/[id]/attempt/route.ts:33-43", "src/app/(protected)/[classSlug]/quizzes/[quizId]/page.tsx:213-223"],
"functions": ["QuizViewer.handleFinish", "QuizViewer.onRetake", "saveProgress", "POST /api/quizzes/[id]/attempt"],
"scenario": "Finish a 10-question quiz missing 4; click 'Retake Missed (4)'; answer the 4 questions; Finish. The retake resets local state but never sets the retakeIds prop; handleFinish computes isPartialRetake=!!retakeIds=false; the server scores ALL questions (6 unanswered → 0) and persists e.g. 3/10 instead of 3/4. saveProgress also runs during the retake (guard reads the prop), writing retake-only answers into SEQUENTIAL progress.",
"expected": "Partial retake scored only over submitted questions (domain invariant), maxScore = submitted count, no progress pollution.",
"actual": "Full-attempt scoring with wrong low score; abandoned retakes leave progress pointing at the retake subset.",
"rootCause": "Retake mode is tracked via a prop (set only by the page-level AttemptHistory flow), but the results-screen retake keeps state local and never lifts it; all three retake-dependent behaviors (saveProgress guard, isPartialRetake, progress DELETE) read the prop.",
"impact": "Wrong persisted scores and history entries for the most common retake path; percentage drops and compounds on further retakes; resume can show only the retake subset.",
"evidence": "QuizViewer.tsx:295-310 has no retakeIds setter (verified by reading); attempt/route.ts:40-43 filters only when isPartialRetake===true (verified); server cannot detect partial answers itself (adversarial review confirmed).",
"testCoverage": "None.",
"fixDirection": "Track retake mode in component state initialized from the prop (useState(!!retakeIds)), set it in onRetake; use it in saveProgress/isPartialRetake/DELETE; defense-in-depth: server validates isPartialRetake consistency with answer-set size.",
"fixComplexity": "M",
"regressionTest": "Component/integration test: full quiz → onRetake(missedIds) → answer → finish → assert POST body isPartialRetake===true and maxScore equals submitted question count.",
"needsRuntimeConfirmation": true
},
{
"id": "FIN-04",
"sourceIds": ["CARD-01", "IMPT-01", "QUIZ-06"],
"title": "Stale card ids in saved progress are never filtered on resume (filterAndClampOrder is dead code) — deleting a card mid-session bricks the flashcard study session",
"severity": "High",
"confidence": "Confirmed",
"files": ["src/lib/shuffle.ts:27-35", "src/components/flashcards/FlashcardViewer.tsx:42-44,73,174-207", "src/app/(protected)/[classSlug]/flashcards/[deckId]/page.tsx:157-172", "src/services/cardService.ts:33-38"],
"functions": ["filterAndClampOrder", "generateOrder", "FlashcardViewer (resume init)", "deleteCard", "GET /api/decks/[id]"],
"scenario": "Study a deck to card N; open Manage; delete the current or any upcoming card; return to Study/reload. order still contains the deleted id; currentCard is undefined; the card area renders nothing, gradeCard early-returns, no skip UI exists → permanently stuck until Restart (which wipes progress). Quiz resume has the same latent gap (currently unreachable since question editing doesn't exist).",
"expected": "Resume filters deleted ids and clamps the index — the cardService.ts:34-35 comment claims this.",
"actual": "filterAndClampOrder has zero call sites (grep-verified); resume copies orderJson verbatim.",
"rootCause": "The cleanup helper was implemented but never wired into the viewer's resume path; viewer initializes order once from props and never reconciles with the live cards list.",
"impact": "Session becomes unusable after any card deletion affecting the current/upcoming position; user must restart, losing in-progress results. No data corruption.",
"evidence": "grep filterAndClampOrder → definition + comment only; FlashcardViewer.tsx:42-44 parses raw orderJson (verified); gradeCard guard at :176.",
"testCoverage": "None.",
"fixDirection": "Call filterAndClampOrder on resume with (orderJson, existing card ids, currentIndex); also filter redoMissed/restartFullSet and results; add skip affordance as defense.",
"fixComplexity": "S",
"regressionTest": "Unit test filterAndClampOrder; component test: resume with stale id at ≤ currentIndex → filtered order, clamped index, card renders.",
"needsRuntimeConfirmation": true
},
{
"id": "FIN-05",
"sourceIds": ["OPS-01"],
"title": "Port mismatch: container listens on 3726 but docker-compose publishes 3000:3000 — production deployment is unreachable",
"severity": "High",
"confidence": "Confirmed",
"files": ["Dockerfile:19,34", "docker-compose.yml:6-7", ".next/standalone/server.js:8"],
"functions": ["Next standalone startServer (PORT env)", "compose port publishing"],
"scenario": "docker compose -f docker-compose.yml up on a server → entrypoint exec node server.js → listens on 0.0.0.0:3726 (ENV PORT=3726) → compose publishes host 3000 → container 3000 where nothing listens → curl http://server:3000 → connection refused. Dev path works only because the override runs npm run dev (PORT unset → 3000).",
"expected": "Published port reaches the app.",
"actual": "No listener on container port 3000; AGENTS.md already flags the mismatch ('verify the mapping') but the repo ships broken.",
"rootCause": "Dockerfile PORT=3726 chosen for the image; compose port mapping never updated; EXPOSE is metadata only.",
"impact": "Production deployment totally unreachable via the documented path — complete service outage.",
"evidence": "server.js:8 (parseInt(process.env.PORT,10)||3000); Dockerfile:19; compose:6-7 (verified by reading).",
"testCoverage": "None (CI builds but never runs the image).",
"fixDirection": "Map '3726:3726' in compose (or drop ENV PORT); add healthcheck on the real port; document in README.",
"fixComplexity": "S",
"regressionTest": "CI smoke: run image with compose mapping, curl the published port, expect HTTP 200.",
"needsRuntimeConfirmation": false
},
{
"id": "FIN-06",
"sourceIds": ["OPS-06"],
"title": "No .dockerignore: building on a Windows host injects win32 native modules (better-sqlite3, argon2) into the Linux image",
"severity": "High",
"confidence": "High-confidence inference",
"files": ["Dockerfile:10-11,25", "(missing .dockerignore)", "next.config.ts:3-6"],
"functions": ["docker build context", "COPY . ."],
"scenario": "On this Windows machine: docker build . → context includes local node_modules (win32 better_sqlite3.node PE32+ DLL verified) and .next → builder's COPY . . overwrites the Linux deps from the deps stage → next build traces win32 natives into .next/standalone → the Linux runner image contains Windows binaries → ERR_DLOPEN_FAILED at runtime or build failure. CI (Linux) masks it entirely.",
"expected": "Linux-built natives in the Linux image.",
"actual": "Build-host platform leaks into the image when building from Windows.",
"rootCause": "No .dockerignore; COPY . . after COPY --from=deps node_modules.",
"impact": "Locally built production image is broken or fails to build; context bloat (node_modules, .next, .git).",
"evidence": "Dockerfile COPY order (verified); local node_modules contains win32 binaries (adversarial review verified PE32+ via file); no .dockerignore (glob).",
"testCoverage": "None.",
"fixDirection": "Add .dockerignore (node_modules, .next, .git, .env*, data, audit-results, *.db*).",
"fixComplexity": "S",
"regressionTest": "CI assertion: container's better_sqlite3.node is ELF, not PE32+.",
"needsRuntimeConfirmation": true
},
{
"id": "FIN-07",
"sourceIds": ["OPS-08"],
"title": "docker-compose.override.yml auto-merges on plain 'docker compose up' — the production command silently runs dev mode",
"severity": "High",
"confidence": "Confirmed",
"files": ["docker-compose.override.yml:3-12", "docker-compose.yml:2-14"],
"functions": ["Compose override auto-merge", "npm run dev"],
"scenario": "Operator on the server runs the conventional 'docker compose up -d' with the repo present → Compose v2 auto-merges docker-compose.override.yml → build target deps, command npm run dev, NODE_ENV=development, bind mount → production runs dev server with hot reload and dev semantics. Running with -f docker-compose.yml instead hits FIN-05 (port mismatch). Every compose invocation is misconfigured.",
"expected": "One documented production command.",
"actual": "Two broken paths: dev-mode-in-production (silent) or unreachable (port mismatch).",
"rootCause": "Dev override committed under the conventional auto-merge filename with no -f documentation.",
"impact": "Unpredictable runtime behavior in production; dev-mode performance/logging; production bugs masked.",
"evidence": "Override file contents (verified); standard Compose v2 auto-merge semantics; no README instructions (stock create-next-app).",
"testCoverage": "None.",
"fixDirection": "Rename to docker-compose.dev.yml and document COMPOSE_FILE/-f usage; document the exact production command.",
"fixComplexity": "S",
"regressionTest": "docker compose config for the documented production invocation must show production settings.",
"needsRuntimeConfirmation": false
},
{
"id": "FIN-08",
"sourceIds": ["AUTH-04", "ADV-09"],
"title": "First-login provisioning takeover: whoever logs in first becomes admin; ADMIN_PASSWORD_HASH env is dead config",
"severity": "Medium",
"confidence": "High-confidence inference",
"files": ["src/services/authService.ts:91-108", "src/app/api/auth/setup-status/route.ts:6-17", "docker-compose.yml:11", "src/lib/validation/authSchemas.ts"],
"functions": ["login", "GET /api/auth/setup-status"],
"scenario": "App reachable before the owner's first login: attacker polls the public /api/auth/setup-status until setupRequired:true, then POSTs any 8+ char password → becomes admin; owner's first login later fails. ADMIN_PASSWORD_HASH passed in compose is never read by any code (grep-verified).",
"expected": "Password seeded from env or setup bound to the deployment; env var consumed.",
"actual": "Provisioning is 'whoever logs in first'; the intended seeding env var is ignored.",
"impact": "Full account takeover if the instance is exposed pre-setup; misleading deployment config.",
"evidence": "authService.ts:97-101 first-login provisioning; setup-status public; grep ADMIN_PASSWORD_HASH → compose only.",
"testCoverage": "None.",
"fixDirection": "Consume ADMIN_PASSWORD_HASH at startup (seed Setting + mark setupRequired false) or require a one-time setup token; document firewall-until-setup.",
"fixComplexity": "S-M",
"regressionTest": "Boot with ADMIN_PASSWORD_HASH set → setup-status false, only that password works.",
"needsRuntimeConfirmation": true
},
{
"id": "FIN-09",
"sourceIds": ["AUTH-02"],
"title": "Proxy auth bypass for any path containing a dot — latent authorization gap in every API route",
"severity": "Medium",
"confidence": "High-confidence inference",
"files": ["src/proxy.ts:22-28,48-49"],
"functions": ["proxy()"],
"scenario": "GET /api/decks/<anything>.json → proxy sees '.' → passes through without session check → the [id] segment routes to the handler. Today it 404s (UUID lookups miss); any future route with non-UUID keys (numeric ids, slugs) becomes unauthenticated instantly.",
"expected": "Every protected path requires a valid session.",
"actual": "pathname.includes('.') bypasses the check entirely (asset convenience rule written too broadly).",
"rootCause": "Static-asset rule not scoped to asset paths or an allowlist.",
"impact": "Latent authorization bypass primitive; inconsistent 404-vs-redirect UX masking the gap.",
"evidence": "proxy.ts:25 (verified by reading); compiled middleware contains the same logic (.next artifacts, adversarial review).",
"testCoverage": "None.",
"fixDirection": "Replace includes('.') with an explicit allowlist (favicon, known assets) or exclude /api/* from the bypass.",
"fixComplexity": "S",
"regressionTest": "Unauthenticated requests with dot-suffixed ids (.json, .rsc, %2E) → redirect/401, not handler execution.",
"needsRuntimeConfirmation": true
},
{
"id": "FIN-10",
"sourceIds": ["AUTH-03"],
"title": "Password-reset endpoints abuseable: token overwrite DoS, spoofable x-forwarded-for rate limits, unthrottled complete",
"severity": "Medium",
"confidence": "High-confidence inference",
"files": ["src/app/api/auth/password-reset/request/route.ts:5-24", "src/app/api/auth/password-reset/verify/route.ts:6-25", "src/app/api/auth/password-reset/complete/route.ts:5-24", "src/services/authService.ts:132-154", "src/lib/rateLimiter.ts:10-36"],
"functions": ["requestPasswordReset", "verifyPasswordResetToken", "completePasswordReset", "checkRateLimit"],
"scenario": "Owner requests reset → token T valid 15 min. Attacker POSTs request repeatedly with rotating x-forwarded-for headers → each call upserts the token record, killing T; log flooded with token lines; verify is rate-limited per spoofable IP; complete has no rate limit at all.",
"expected": "Token issuance throttled globally, outstanding token preserved or invalidated with delay.",
"actual": "Per-IP in-memory limits keyed on a client-controlled header; token overwrite per request; complete unlimited.",
"impact": "Account-recovery DoS (matters once FIN-02 is fixed); log flooding; CPU/DB load.",
"evidence": "authService.ts:146-150 upsert; routes' header trust (verified).",
"testCoverage": "None.",
"fixDirection": "DB-backed global rate limits; refuse minting while one is outstanding; rate-limit complete; document trusted-proxy requirement.",
"fixComplexity": "M",
"regressionTest": "Spoofed-IP parallel requests → only N tokens; outstanding token survives; complete 429 past threshold.",
"needsRuntimeConfirmation": true
},
{
"id": "FIN-11",
"sourceIds": ["QUIZ-02"],
"title": "Attempt route stores unvalidated answersJson; duplicate option ids inflate SATA credit; malformed shapes cause 500s",
"severity": "Medium",
"confidence": "High-confidence inference",
"files": ["src/app/api/quizzes/[id]/attempt/route.ts:24-60", "src/lib/scoring.ts:21-27"],
"functions": ["POST /api/quizzes/[id]/attempt", "scoreQuestion"],
"scenario": "Crafted request: SATA with correct [A,B], submit answersJson {\"qid\":[\"A\",\"A\"]} → correctSelected=2 → full credit for one distinct option selected twice; answersJson 'null' → Object.keys(null) throws → 500; raw body persisted verbatim.",
"expected": "Server validates ids against the quiz set, dedupes, rejects malformed shapes with 4xx.",
"actual": "Only JSON parseability checked; duplicates double-count; garbage persisted forever.",
"impact": "Score integrity only exploitable via crafted HTTP (single-user app → low practical impact); corrupt attempt data persists.",
"evidence": "attempt/route.ts:27-30,58; scoring.ts counts occurrences (verified).",
"testCoverage": "None.",
"fixDirection": "Zod schema for answersJson (Record<questionId, string[]>, ids verified, dedupe, reject unknown); persist validated object.",
"fixComplexity": "M",
"regressionTest": "scoreQuestion with duplicate selections; API tests with unknown ids, 'null', arrays → 400/422.",
"needsRuntimeConfirmation": false
},
{
"id": "FIN-12",
"sourceIds": ["QUIZ-03", "FE-04"],
"title": "Quiz Finish double-submit creates duplicate attempts; failure path gives no user feedback",
"severity": "Medium",
"confidence": "Confirmed",
"files": ["src/components/quizzes/QuizViewer.tsx:219-288,506-513"],
"functions": ["QuizViewer.handleFinish"],
"scenario": "Double-click 'Finish Quiz' on the last question → two identical QuizAttempt rows; attempt history inflated. On POST failure only console.error runs; the button stays active.",
"expected": "One attempt per completion with in-flight guard.",
"actual": "No guard (per-question submit has submittingQuestionRef; finish has none); error path silent.",
"impact": "Duplicate history entries, confusing retake stats.",
"evidence": "QuizViewer.tsx:257-287 no guard ref; button not disabled (verified).",
"testCoverage": "None.",
"fixDirection": "finishingRef/isFinishing state; disable button in flight; visible error state on failure.",
"fixComplexity": "S",
"regressionTest": "Double-click Finish with delayed mocked POST → exactly one POST.",
"needsRuntimeConfirmation": false
},
{
"id": "FIN-13",
"sourceIds": ["QUIZ-04"],
"title": "Progress PATCH can land after Finish's DELETE — stale 'Continue' row reappears for a completed quiz",
"severity": "Medium",
"confidence": "High-confidence inference",
"files": ["src/components/quizzes/QuizViewer.tsx:148-159,271-281", "src/app/api/progress/route.ts:49-66"],
"functions": ["QuizViewer.saveProgress", "QuizViewer.handleFinish", "DELETE /api/progress"],
"scenario": "On the final question a non-awaited PATCH fires; user immediately clicks Finish; POST attempt succeeds; DELETE progress runs. If the PATCH lands after the DELETE (or DELETE fails), StudyProgress is recreated → quiz list shows 'Continue' for a completed quiz → resume → re-finish → duplicate attempt.",
"expected": "Attempt persistence and progress cleanup coordinated.",
"actual": "Three uncoordinated HTTP calls; no transaction, no sequencing.",
"impact": "Intermittent stale progress, duplicate attempts.",
"evidence": "QuizViewer.tsx:159 .catch(() => {}) swallows PATCH failure; DELETE is a separate request (verified).",
"testCoverage": "None.",
"fixDirection": "POST /attempt deletes the matching SEQUENTIAL progress row in the same Prisma transaction server-side.",
"fixComplexity": "M",
"regressionTest": "API test: POST attempt then assert progress row gone (transactional).",
"needsRuntimeConfirmation": true
},
{
"id": "FIN-14",
"sourceIds": ["CARD-02", "FE-05", "QUIZ-09"],
"title": "Fire-and-forget, unsequenced progress autosaves: out-of-order PATCHes regress resume state; multi-tab last-write-wins loses answers",
"severity": "Medium",
"confidence": "High-confidence inference",
"files": ["src/components/quizzes/QuizViewer.tsx:133-160", "src/components/flashcards/FlashcardViewer.tsx:265-294", "src/services/progressService.ts:36-64", "src/app/api/progress/route.ts:26-47"],
"functions": ["saveProgress", "upsertProgress", "PATCH /api/progress"],
"scenario": "On a slow connection, grade cards or submit answers rapidly: each action fires a non-awaited full-state PATCH; if PATCH(n) resolves after PATCH(n+1) the server stores the older index/results → resume point and tallies regress; closing the tab mid-flight loses the last grade. Two tabs editing the same quiz → later write clobbers the other tab's answers.",
"expected": "Latest state wins; writes ordered.",
"actual": "Last arrival wins; no sequence/version; no beforeunload flush.",
"impact": "Silent resume-point regressions, lost answers, possibly wrong final scores.",
"evidence": "FlashcardViewer.tsx:282-293 fetch().catch(() => {}); progressService unconditional update (verified).",
"testCoverage": "None.",
"fixDirection": "Serialize/queue saves (latest snapshot), add monotonic sequence or updatedAt compare-and-set server-side, flush on pagehide.",
"fixComplexity": "M",
"regressionTest": "Out-of-order PATCH simulation → only latest sequence persisted.",
"needsRuntimeConfirmation": true
},
{
"id": "FIN-15",
"sourceIds": ["CARD-03", "ADV-07"],
"title": "Completed flashcard session is never persisted as complete — resume re-shows the last (already graded) card instead of the summary",
"severity": "Medium",
"confidence": "Confirmed",
"files": ["src/components/flashcards/FlashcardViewer.tsx:61-65,196-203"],
"functions": ["FlashcardViewer completed init", "gradeCard completion path"],
"scenario": "Finish the last card → saveProgress stores currentIndex = order.length - 1 → reload: completed = currentIndex >= order.length is false → the last card is re-presented; re-grading it overwrites the recorded result.",
"expected": "Reload of a completed session shows the 'Set complete' summary.",
"actual": "Last card re-presented.",
"impact": "Confusing resume; minor result-map overwrite on re-grade.",
"evidence": "Lines 61-65 vs 196-203 (verified).",
"testCoverage": "None.",
"fixDirection": "Persist an explicit completed flag or save currentIndex = order.length on completion.",
"fixComplexity": "S",
"regressionTest": "Viewer init with currentIndex === orderJson.length shows summary.",
"needsRuntimeConfirmation": true
},
{
"id": "FIN-16",
"sourceIds": ["CARD-04"],
"title": "SRS review endpoint enforces membership only — no due-date or newCardsPerDay enforcement server-side",
"severity": "Medium",
"confidence": "Confirmed",
"files": ["src/services/spacedRepetitionService.ts:406-447,388-404", "src/app/api/spaced-repetition-sets/[id]/reviews/route.ts:6-22"],
"functions": ["reviewCard", "POST /api/spaced-repetition-sets/[id]/reviews"],
"scenario": "Direct POST can (a) introduce a brand-new card after the daily limit is exhausted (firstReviewedAt counts toward introducedToday), and (b) review a not-yet-due card early (FSRS reschedules, compressing the interval). The UI enforces limits only through queue construction.",
"expected": "Server enforces the same queue rules the UI shows.",
"actual": "reviewCard checks only set membership and expectedStateVersion.",
"impact": "'New cards per day' guarantee is not data-level; future-dated cards can be advanced early (single-user: crafted requests/other tabs).",
"evidence": "reviewCard 414-420 membership-only (verified).",
"testCoverage": "None.",
"fixDirection": "Reject if no state and newCardsAvailable<=0, or state exists and not due (outside learn-ahead).",
"fixComplexity": "M",
"regressionTest": "Service test: new card with limit 0 → 400/409; not-due review → 400/409.",
"needsRuntimeConfirmation": false
},
{
"id": "FIN-17",
"sourceIds": ["CARD-05", "ADV-02"],
"title": "Unbounded DB scans: every activity fetch reads the entire StudyActivity table; class polling loads all SRS card states every 60 seconds",
"severity": "Medium",
"confidence": "Confirmed",
"files": ["src/services/activityService.ts:65-68", "src/services/spacedRepetitionService.ts:54-142,153-220", "src/components/ui/Navbar.tsx:42-53", "src/services/classService.ts:11-22"],
"functions": ["getActivitySummary", "getSetSummary", "getStudyAvailabilityByClass", "listClasses", "Navbar.refreshDueCards"],
"scenario": "Every grade/review/quiz submit inserts a StudyActivity row; GET /api/activity reads the whole table; every page load and every 60 s the Navbar polls /api/classes which loads all sets + all card states for all classes. Query cost grows linearly forever.",
"expected": "Bounded, date-filtered queries.",
"actual": "Client-side aggregation over unfiltered findMany; N+1 per set.",
"impact": "Progressive page-load/nav lag in a long-lived self-hosted DB.",
"evidence": "activityService.ts:65-68 no where clause; Navbar interval 60s (verified).",
"testCoverage": "None.",
"fixDirection": "Date-filter StudyActivity, SQL-side aggregation, limit state scans to needed fields, cache availability.",
"fixComplexity": "M",
"regressionTest": "Activity summary with >371-day-old rows returns same result; query-shape assertion.",
"needsRuntimeConfirmation": false
},
{
"id": "FIN-18",
"sourceIds": ["FE-01", "GRP-03", "FE-02", "GRP-05"],
"title": "Library optimistic mutations are fire-and-forget: reorder/delete/rename never check res.ok, never roll back; cross-group reorder omits the origin group from the payload",
"severity": "Medium",
"confidence": "Confirmed",
"files": ["src/app/(protected)/[classSlug]/flashcards/page.tsx:244-295,313-372", "src/app/(protected)/[classSlug]/quizzes/page.tsx:231-282,311-374", "src/components/flashcards/CardManager.tsx:34-72"],
"functions": ["handleDragEnd", "handleDeleteGroup", "handleDeleteDeck", "handleRenameDeck", "saveEdit", "deleteCard"],
"scenario": "Drag a deck while offline → list keeps the new order; reload → order reverts silently (no error). Cross-group drag: newItems[oldIndex].groupId = targetGroupId mutates the object shared with activeDeck before affectedGroups is computed → origin group excluded from the PATCH payload → sortOrder gaps. Rename/delete fetches ignore res.ok; CardManager clears the editor even on failure.",
"expected": "Rollback to server truth + error message on failure.",
"actual": "Fire-and-forget fetches; in-place mutation; silent divergence until reload.",
"impact": "Lost operations without feedback; sortOrder gaps; phantom state. (Adversarial review: no visible break for single user → downgraded from High; the mutation-before-read payload bug is real.)",
"evidence": "flashcards/page.tsx:363-367 no await/catch; :343 mutation before :352 read (verified); SpacedRepetitionSets.tsx:277 shows the correct rollback pattern.",
"testCoverage": "None.",
"fixDirection": "Compute payload before mutating; await PATCH; on failure refetch or revert; check res.ok everywhere; disable drags while saving.",
"fixComplexity": "M",
"regressionTest": "Mock failing reorder → state refetched/reverted; unit test affectedGroups includes origin group.",
"needsRuntimeConfirmation": true
},
{
"id": "FIN-19",
"sourceIds": ["GRP-02", "CARD-07", "AUTH-06"],
"title": "Reorder/create endpoints trust client groupId and sortOrder — no class/type/existence/complete-set validation; cross-class group membership can expose content in another class's share link",
"severity": "Medium",
"confidence": "Confirmed",
"files": ["src/lib/validation/reorderSchemas.ts:3-11", "src/app/api/decks/reorder/route.ts:7-26", "src/app/api/quizzes/reorder/route.ts:7-26", "src/app/api/decks/route.ts:12,31", "src/services/deckService.ts:39-48", "src/services/quizService.ts:44-53", "src/app/shared/[classSlug]/[type]/[token]/page.tsx:64-83"],
"functions": ["PATCH /api/decks/reorder", "PATCH /api/quizzes/reorder", "createDeckFromImport", "createQuizSetFromImport", "SharedPage"],
"scenario": "(a) Crafted reorder assigns a deck from class A to a QUIZ-type group (or a class-B group) — FK passes, deck disappears from the library. (b) Partial reorder payloads leave duplicate sortOrders. (c) A deck assigned to another class's group appears in that group's public share link; the shared page validates only the group's class.",
"expected": "Server validates membership/type/class and renumbers authoritatively.",
"actual": "Blind write-through of client-computed values; shape-only Zod schema.",
"impact": "Content becomes invisible; ambiguous ordering; cross-class content can leak into a group share link (privacy nuance for a single user).",
"evidence": "reorderSchemas shape-only; routes' unconditional $transaction (verified).",
"testCoverage": "None.",
"fixDirection": "Server-side validation in reorder routes (same class, existing group of matching type, renumber 0..n-1, reject duplicates); validate groupId on create; per-item class check in SharedPage.",
"fixComplexity": "M",
"regressionTest": "Cross-class groupId rejected; QUIZ group for deck rejected; partial set renumbered; shared group page with cross-class item → notFound.",
"needsRuntimeConfirmation": true
},
{
"id": "FIN-20",
"sourceIds": ["FE-03"],
"title": "Shared quiz session resets and options reshuffle on any parent re-render (unstable inline quiz prop object)",
"severity": "Medium",
"confidence": "Confirmed",
"files": ["src/app/shared/[classSlug]/[type]/[token]/SharedViewer.tsx:149-159", "src/components/quizzes/QuizViewer.tsx:64-130"],
"functions": ["SharedViewer render", "QuizViewer init effect"],
"scenario": "Open a shared quiz link; any SharedViewer re-render (auto after setHasSavedSession, topics toggle, view switch) creates a new quiz object → QuizViewer's init effect re-runs → shuffledOptions regenerate (options move mid-question) and, without a saved localStorage session, a fresh random question order is generated (the current question changes).",
"expected": "Stable question/option order for the whole session.",
"actual": "Options (and possibly the current question) reshuffle after unrelated UI toggles.",
"impact": "Confusing UX; mis-reading moved options; answers tracked by id so scores stay correct.",
"evidence": "SharedViewer.tsx:151-156 inline object literal (verified); QuizViewer deps [quiz, retakeIds, isShared].",
"testCoverage": "None.",
"fixDirection": "Memoize the quiz object (useMemo on data.id) or make init mount-only/state-lazy.",
"fixComplexity": "S",
"regressionTest": "Render shared QuizViewer, answer, re-render parent → option order array unchanged.",
"needsRuntimeConfirmation": true
},
{
"id": "FIN-21",
"sourceIds": ["IMPT-02", "FE-08", "QUIZ-05", "ADV-10"],
"title": "/api/progress PATCH is completely unvalidated and client JSON.parse of persisted progress is unguarded — malformed rows crash pages",
"severity": "Medium",
"confidence": "Confirmed",
"files": ["src/app/api/progress/route.ts:26-47", "src/services/progressService.ts:18-65", "src/app/(protected)/[classSlug]/flashcards/page.tsx:55-67", "src/components/flashcards/FlashcardViewer.tsx:42-44,50-54,61-65"],
"functions": ["PATCH /api/progress", "upsertProgress", "getProgressLabel", "FlashcardViewer init"],
"scenario": "PATCH with contentType:'QUIZZ', mode:'weird', orderJson:'not json', currentIndex:-5 — all accepted and persisted (no Zod anywhere in the route). Malformed orderJson then crashes the flashcards list page at render (getProgressLabel JSON.parse) and the viewer at mount (unguarded useState initializers); bogus contentId → FK violation → uncaught 500. QuizViewer guards the same parse (QuizViewer.tsx:104-116); the flashcard paths do not.",
"expected": "400 on invalid payloads; safe fallback on read.",
"actual": "Garbage persisted; render-time SyntaxError white-screens pages.",
"impact": "Page crash on the flashcards tab; latent trap for any future writer bug.",
"evidence": "progress/route.ts imports no validator (verified); flashcards/page.tsx:58-61 bare JSON.parse; FlashcardViewer.tsx:43,52,63 bare parses.",
"testCoverage": "None.",
"fixDirection": "Zod schema for progress PATCH (enums, JSON-string validity, index >= 0); wrap client parses in try/catch with fallback (mirror QuizViewer).",
"fixComplexity": "S",
"regressionTest": "API: malformed orderJson → 400; render: corrupt progress row → page renders with fallback.",
"needsRuntimeConfirmation": true
},
{
"id": "FIN-22",
"sourceIds": ["OPS-02"],
"title": "Entrypoint migrate-deploy chain is fragile: failure = crash loop with no recovery path; runner prisma CLI install is unverified and unpinned",
"severity": "Medium",
"confidence": "High-confidence inference",
"files": ["docker-entrypoint.sh:2-4", "Dockerfile:16-36", "prisma.config.ts:7"],
"functions": ["docker-entrypoint.sh", "RUN npm install prisma@^7.8.0"],
"scenario": "set -e + npx prisma migrate deploy + exec node server.js. On a FRESH volume migrate succeeds (no drift detection — the DB is then incompatible per FIN-01, so the app 500s but the container 'runs'). On conflicting DB state (db-push DB, manual tampering, locked file) migrate fails → container exits → restart: unless-stopped → infinite crash loop, no documented recovery. The runner's unpinned npm install prisma@^7.8.0 (no lockfile, re-resolves the whole tree, runtime network dependency for npx) is plausible per Prisma 7 docs but never exercised by CI.",
"expected": "Migration failures are observable and recoverable; image contents reproducible.",
"actual": "Silent crash loop / broken-but-running container; no smoke test anywhere.",
"impact": "Prolonged outage without diagnostics on secondary triggers; nondeterministic images.",
"evidence": "entrypoint set -e (verified); adversarial review weakened the fresh-volume crash-loop framing (migrate succeeds there).",
"testCoverage": "None (CI never runs the image).",
"fixDirection": "Retry-with-backoff or one-shot init job; clear failure logs + documented recovery; install prisma pinned from the lockfile (or copy from builder); CI container smoke test.",
"fixComplexity": "M",
"regressionTest": "Container smoke: fresh volume → 200 and _prisma_migrations populated; corrupt volume → actionable log, no silent loop.",
"needsRuntimeConfirmation": true
},
{
"id": "FIN-23",
"sourceIds": ["OPS-05"],
"title": "Non-reproducible installs: npm install (not ci) in deps; unpinned prisma re-install re-resolving the whole tree in the runner",
"severity": "Medium",
"confidence": "Confirmed",
"files": ["Dockerfile:5,30", "package.json:38"],
"functions": ["docker build stages"],
"scenario": "deps stage npm install can silently mutate the lockfile; the runner's npm install prisma@^7.8.0 against the standalone package.json re-resolves all caret ranges, downloads native modules, and floats image content with the registry.",
"expected": "Images reproducible from package-lock.json.",
"actual": "Runner image content not a function of the repo lockfile.",
"impact": "Nondeterministic images; invisible dependency drift (incl. prisma patch releases).",
"evidence": "Dockerfile:5,30 (verified); standalone package.json is a full copy.",
"testCoverage": "None.",
"fixDirection": "npm ci in deps; pin prisma exact version or copy CLI from builder stage.",
"fixComplexity": "M",
"regressionTest": "Two builds from same commit → prisma --version identical.",
"needsRuntimeConfirmation": false
},
{
"id": "FIN-24",
"sourceIds": ["OPS-07"],
"title": "No healthcheck, no backup, no documented recovery — outages are invisible to the orchestrator and the DB is a single point of failure",
"severity": "Medium",
"confidence": "Confirmed",
"files": ["docker-compose.yml:5", "docker-entrypoint.sh:3", "Dockerfile:35"],
"functions": ["compose orchestration", "entrypoint"],
"scenario": "A hung-but-running app shows 'running' in docker ps while /login 500s; a corrupt study.db means years of study data with no backup and no documented recovery procedure; no WAL mode (default rollback journal).",
"expected": "Observable health + backup + recovery path.",
"actual": "None of the three.",
"impact": "Prolonged undetected outages; permanent data loss on disk failure.",
"evidence": "compose has no healthcheck; entrypoint no retry (verified).",
"testCoverage": "None.",
"fixDirection": "Add healthcheck (wget -qO- http://127.0.0.1:3726/login); document volume backup; consider WAL + backup script.",
"fixComplexity": "S-M",
"regressionTest": "Compose healthcheck flips unhealthy when entrypoint fails.",
"needsRuntimeConfirmation": true
},
{
"id": "FIN-25",
"sourceIds": ["OPS-10", "TEST-10"],
"title": "CI builds and pushes the image without running tests, lint, prisma validate, or a container smoke test — every deployment-breaking defect ships with a green pipeline",
"severity": "Medium",
"confidence": "Confirmed",
"files": [".forgejo/workflows/build.yml:12-25", "package.json:6-11", "vitest.config.ts"],
"functions": ["CI workflow"],
"scenario": "Any of FIN-01/02/05/06/07 land on main → CI is green (docker build && push only) → broken image tagged latest. No PR pipeline, no test step, no coverage gate.",
"expected": "Tests, lint, prisma validate, and a container smoke gate the image.",
"actual": "None run; pipeline reports success while running zero tests.",
"impact": "Broken production artifacts shipped; regressions invisible until deployment.",
"evidence": "build.yml steps (verified); package.json test script never referenced by any workflow.",
"testCoverage": "The gap itself.",
"fixDirection": "Add npm ci && npm test && lint && prisma validate jobs plus a container smoke (curl /login on fresh volume); tag by commit SHA.",
"fixComplexity": "M",
"regressionTest": "CI fails when the container doesn't return 200 on a fresh volume (catches FIN-01/FIN-05).",
"needsRuntimeConfirmation": false
},
{
"id": "FIN-26",
"sourceIds": ["QUIZ-07"],
"title": "SATA scoring divides by correctIds.length with no zero-guard → NaN scores",
"severity": "Low",
"confidence": "Confirmed",
"files": ["src/lib/scoring.ts:27", "src/lib/validation/importSchemas.ts:16-18"],
"functions": ["scoreQuestion (SATA branch)"],
"scenario": "A SATA question with zero correct options → 0/0 → NaN; scoreQuiz sums NaN; persisted score NaN. Unreachable through the current import path (Zod enforces >=1 correct) but unguarded at score time — any future content-edit feature or DB drift corrupts scores silently.",
"expected": "Defensive 0 for degenerate questions.",
"actual": "NaN propagates.",
"impact": "Latent corruption risk; none today.",
"evidence": "scoring.ts:27 no guard (verified).",
"testCoverage": "None.",
"fixDirection": "if (correctIds.length === 0) return 0;",
"fixComplexity": "S",
"regressionTest": "scoreQuestion with 0-correct SATA → 0; scoreQuiz finite.",
"needsRuntimeConfirmation": false
},
{
"id": "FIN-27",
"sourceIds": ["QUIZ-08"],
"title": "Historical review and category breakdown recompute scores from current content, not the stored attempt",
"severity": "Low",
"confidence": "Confirmed",
"files": ["src/components/quizzes/QuizResults.tsx:32-52", "src/components/quizzes/CategoryBreakdown.tsx:17-35"],
"functions": ["QuizResults", "CategoryBreakdown"],
"scenario": "If option correctness/type/set ever differs from attempt time (content editing is added later), per-question review scores and category bars diverge from the persisted total; 'Retake Missed' ids derive from current content. Today content is immutable so the recompute matches.",
"expected": "Render the attempt as scored.",
"actual": "Only total + answersJson stored; everything per-question recomputed.",
"impact": "Latent historical-corruption display bug; benign today.",
"evidence": "QuizResults.tsx:35-52 recompute vs :54 persisted banner (verified).",
"testCoverage": "None.",
"fixDirection": "Persist per-question points snapshot at submit time; render stored points with fallback for legacy rows.",
"fixComplexity": "M",
"regressionTest": "Score quiz, flip an option's isCorrect, re-render → per-question scores still match stored attempt.",
"needsRuntimeConfirmation": false
},
{
"id": "FIN-28",
"sourceIds": ["IMPT-03"],
"title": "Card add/edit endpoints bypass Zod: empty/whitespace strings storable; CreateTab silently drops incomplete cards",
"severity": "Low",
"confidence": "Confirmed",
"files": ["src/app/api/cards/[id]/route.ts:11-16", "src/app/api/decks/[id]/cards/route.ts:11-25", "src/components/flashcards/CardManager.tsx:34-48", "src/components/import/CreateTab.tsx:46-50"],
"functions": ["PATCH /api/cards/[id]", "POST /api/decks/[id]/cards", "saveEdit", "handleCreate"],
"scenario": "Edit a card, clear both textareas, Save → 200 with blank front persisted; PATCH with {} → silent no-op 200; CreateTab with 3 cards where one is blank → 2-card deck created with no warning.",
"expected": "400 on empty content; no silent drops.",
"actual": "Empty strings persisted; silent card dropping.",
"impact": "Blank/garbage cards in decks; misleading save success.",
"evidence": "cards/[id]/route.ts:11-16 (verified); CreateTab.tsx:46 filter.",
"testCoverage": "None.",
"fixDirection": "Shared cardContentSchema with safeParse in both endpoints; surface incomplete-card errors in CreateTab.",
"fixComplexity": "S",
"regressionTest": "API: PATCH card with '' → 400; CreateTab 3-cards-1-blank → error, nothing created.",
"needsRuntimeConfirmation": false
},
{
"id": "FIN-29",
"sourceIds": ["IMPT-04"],
"title": "No payload size/string-length/array-length caps on import schemas and routes; whitespace-only names accepted",
"severity": "Low",
"confidence": "Confirmed",
"files": ["src/lib/validation/importSchemas.ts:3-45", "src/app/api/decks/route.ts:5-25", "src/app/api/quizzes/route.ts:5-25", "next.config.ts:3-6"],
"functions": ["flashcardImportSchema", "quizImportSchema", "POST /api/decks|quizzes"],
"scenario": "A 20 MB JSON with 100k cards → minutes-long UI freeze (per-keystroke parse+repair+validate pipeline in ImportTab), huge transaction; ' ' as deckName passes (no trim); very long strings bloat rows. SRS schemas cap at 120/5000 chars — import schemas don't.",
"expected": "Bounded inputs consistent with SRS schemas.",
"actual": "Unbounded everything; no body-size guard.",
"impact": "Self-DoS, DB bloat, visually-empty names.",
"evidence": "importSchemas.ts has no .max()/.trim() (verified); spacedRepetitionSchemas.ts:4-5 has caps.",
"testCoverage": "None.",
"fixDirection": "Add trim/min/max to strings, .max() on arrays, description caps, content-length guard, debounce ImportTab.",
"fixComplexity": "S-M",
"regressionTest": "Schema tests: whitespace names rejected, oversized arrays/strings rejected.",
"needsRuntimeConfirmation": false
},
{
"id": "FIN-30",
"sourceIds": ["IMPT-05"],
"title": "SATA constraint mismatch: LLM instructions require >=2 correct options, schema enforces only >=1",
"severity": "Low",
"confidence": "Confirmed",
"files": ["src/lib/validation/importSchemas.ts:16-24", "src/services/settingsService.ts:38-39"],
"functions": ["quizImportSchema", "getDefaultInstructions"],
"scenario": "LLM returns a SATA question with exactly one correct:true → import succeeds despite instructions saying 'two or more options with correct: true'. Scoring still works (single correct), so no corruption — doc/schema drift.",
"expected": "Rejected per documented rule.",
"actual": "Accepted.",
"impact": "Semantic drift; 1-correct SATA graded with partial-credit formula.",
"evidence": "importSchemas refine >=1 vs settingsService.ts:38-39 (verified).",
"testCoverage": "None.",
"fixDirection": "Per-type refine: sata → correctCount >= 2 (or align instructions).",
"fixComplexity": "S",
"regressionTest": "Schema: SATA with 1 correct fails; 2+ passes; MC 0/2 fails.",
"needsRuntimeConfirmation": false
},
{
"id": "FIN-31",
"sourceIds": ["IMPT-06"],
"title": "Unvalidated name/groupId override fields on import POST routes throw 500s; PATCH routes mask all failures as 404",
"severity": "Low",
"confidence": "Confirmed",
"files": ["src/app/api/decks/route.ts:12,27-32", "src/app/api/quizzes/route.ts:12,27-32", "src/app/api/decks/[id]/route.ts:22-33", "src/app/api/quizzes/[id]/route.ts:22-33"],
"functions": ["POST /api/decks|quizzes", "PATCH /api/decks/[id]|quizzes/[id]"],
"scenario": "POST with name:123 → TypeError: name.trim is not a function → unhandled 500; PATCH with wrong-typed body → 404 'Deck not found' for an existing deck (misleading diagnostics).",
"expected": "400 with a validation message.",
"actual": "500 / misleading 404.",
"impact": "Error-handling quality only (UI never sends wrong types).",
"evidence": "decks/route.ts:12,27-32 (verified).",
"testCoverage": "None.",
"fixDirection": "Validate the whole request envelope with Zod; narrow PATCH catch to P2025 for 404.",
"fixComplexity": "S",
"regressionTest": "POST numeric name → 400; PATCH wrong-typed body → 400 not 404.",
"needsRuntimeConfirmation": false
},
{
"id": "FIN-32",
"sourceIds": ["GRP-04"],
"title": "Group deletion leaves duplicate sortOrder values in Uncategorized; no unique constraint and no tie-break → ambiguous, unstable order",
"severity": "Low",
"confidence": "Confirmed",
"files": ["src/app/(protected)/[classSlug]/flashcards/page.tsx:270-275", "src/app/(protected)/[classSlug]/quizzes/page.tsx:257-262", "prisma/schema.prisma:24-39,52-67", "src/services/deckService.ts:5-7"],
"functions": ["handleDeleteGroup", "DELETE /api/material-groups/[id]", "listDecksByClass", "listQuizSetsByClass"],
"scenario": "Group A has decks sortOrder 0,1,2; Uncategorized has 0,1. Delete group A → SetNull moves rows without renumbering → duplicates; rendering ties resolve to SQLite scan order which can change after VACUUM. Dragging self-heals; imports into Uncategorized use max+1 leaving old collisions.",
"expected": "Renumbered sequential sortOrders after delete.",
"actual": "Stale group-relative values kept.",
"impact": "Cosmetic unstable ordering after the common 'delete group' operation.",
"evidence": "DELETE route plain delete; orderBy single-key (verified).",
"testCoverage": "None.",
"fixDirection": "Renumber in the delete route transaction; add deterministic orderBy tie-break (createdAt).",
"fixComplexity": "S",
"regressionTest": "Integration: delete group → all Uncategorized sortOrders unique and sequential.",
"needsRuntimeConfirmation": true
},
{
"id": "FIN-33",
"sourceIds": ["GRP-06"],
"title": "Keyboard users cannot move items between groups; no drag announcements",
"severity": "Low",
"confidence": "Confirmed",
"files": ["src/app/(protected)/[classSlug]/flashcards/page.tsx:298-311", "src/app/(protected)/[classSlug]/quizzes/page.tsx:285-309"],
"functions": ["handleDragOver", "sensors"],
"scenario": "KeyboardSensor allows within-group reorder, but cross-container keyboard moves require onDragOver to move the active item between containers; handleDragOver is a no-op → cross-group moves are pointer-only. Screen readers get no live announcements.",
"expected": "Full DnD parity for keyboard.",
"actual": "Within-group only.",
"impact": "Accessibility gap; no data corruption.",
"evidence": "Empty handleDragOver bodies (verified).",
"testCoverage": "None.",
"fixDirection": "Implement handleDragOver for keyboard container switches or add explicit 'Move to group' actions.",
"fixComplexity": "M",
"regressionTest": "Keyboard test moving an item across two groups.",
"needsRuntimeConfirmation": true
},
{
"id": "FIN-34",
"sourceIds": ["CARD-08"],
"title": "Restart deletes progress fire-and-forget; a slow DELETE can remove the fresh session's newly created progress row",
"severity": "Low",
"confidence": "High-confidence inference",
"files": ["src/app/(protected)/[classSlug]/flashcards/[deckId]/page.tsx:42-62", "src/app/(protected)/[classSlug]/flashcards/page.tsx:117-125"],
"functions": ["handleRestart", "DELETE /api/progress"],
"scenario": "Click Restart → two unawaited DELETEs fire → viewer remounts → first grade PATCHes a new progress row → if a DELETE lands after the PATCH, the new row is deleted → next load shows no progress (session silently restarted).",
"expected": "Restart clears old progress only.",
"actual": "Delete-after-create race can remove the new row.",
"impact": "Occasional unexpected session reset (small window).",
"evidence": "[deckId]/page.tsx:46-61 .catch(() => {}) (verified).",
"testCoverage": "None.",
"fixDirection": "Await DELETEs before remount or use per-deck session ids so late DELETEs can't touch newer rows.",
"fixComplexity": "S",
"regressionTest": "Route-order simulation.",
"needsRuntimeConfirmation": true
},
{
"id": "FIN-35",
"sourceIds": ["CARD-09"],
"title": "Concurrent first review of the same new card hits the unique constraint → unhandled generic 500 instead of 409",
"severity": "Low",
"confidence": "Confirmed",
"files": ["src/services/spacedRepetitionService.ts:426-442", "src/lib/spacedRepetitionApi.ts:4-9"],
"functions": ["reviewCard", "spacedRepetitionErrorResponse"],
"scenario": "Two rapid reviews of a never-reviewed card (double-tap or two tabs): both see current=null, both create; loser hits P2002 → generic 500 'Spaced repetition request failed' (the update path maps this to 409 via optimistic concurrency; the create path doesn't).",
"expected": "409 'already reviewed'.",
"actual": "500 with no guidance.",
"impact": "Confusing error in an edge double-submit; review still recorded by the winner.",
"evidence": "reviewCard create path lacks the updateMany guard (verified).",
"testCoverage": "None.",
"fixDirection": "Catch P2002 → 409, or upsert-guard with retry.",
"fixComplexity": "S",
"regressionTest": "Two concurrent reviewCard calls → one 409.",
"needsRuntimeConfirmation": false
},
{
"id": "FIN-36",
"sourceIds": ["CARD-10"],
"title": "'Previous card' during the 350 ms grade animation races the pending timeout — index jumps forward, result map inconsistent",
"severity": "Low",
"confidence": "High-confidence inference",
"files": ["src/components/flashcards/FlashcardViewer.tsx:190-204,531-544"],
"functions": ["gradeCard", "previous-card handler"],
"scenario": "Grade a card, click Previous within 350 ms: click sets currentIndex-1; the pending timeout then fires setCurrentIndex(currentIndex+1) from a stale closure → jumps forward past the card the user navigated to; saveProgress writes results inconsistent with the visible card.",
"expected": "Navigation wins.",
"actual": "Timeout overwrites navigation.",
"impact": "Transient wrong position/result map; self-corrects on next action.",
"evidence": "Timeout uses closure currentIndex (verified).",
"testCoverage": "None.",
"fixDirection": "Disable prev during swipe or use functional setState/refs in the timeout.",
"fixComplexity": "S",
"regressionTest": "Component test simulating grade→previous→timeout sequence.",
"needsRuntimeConfirmation": true
},
{
"id": "FIN-37",
"sourceIds": ["CARD-11"],
"title": "SRS set page never refreshes on window focus — stale membership after deck deletion in another tab",
"severity": "Low",
"confidence": "Confirmed",
"files": ["src/components/spaced-repetition/SpacedRepetitionSets.tsx:167-192"],
"functions": ["SpacedRepetitionSets.load"],
"scenario": "Keep the SRS page open; delete a member deck in another tab → the UI still lists the deck (DB is cascade-clean); removing/dragging it yields 404 errors until manual reload. Navbar refreshes on focus; the sets page doesn't.",
"expected": "UI reflects DB state.",
"actual": "Mount-only load.",
"impact": "Stale list; harmless 404 messages.",
"evidence": "useEffect mount-only (verified); contrast Navbar.tsx:45.",
"testCoverage": "None.",
"fixDirection": "Add focus listener / deck-changed event to re-run load.",
"fixComplexity": "S",
"regressionTest": "Component-level focus-refresh test.",
"needsRuntimeConfirmation": true
},
{
"id": "FIN-38",
"sourceIds": ["CARD-06"],
"title": "Day boundary hardcoded to Arizona (UTC-7): 'today', new-card limit, and streak roll over at 07:00 UTC for non-Arizona users",
"severity": "Low",
"confidence": "Confirmed",
"files": ["src/lib/spacedRepetition.ts:157-161", "src/services/activityService.ts:28-30", "src/lib/spacedRepetition.test.ts:56-61"],
"functions": ["getArizonaDayBounds", "getEffectiveDue", "toArizonaDateKey"],
"scenario": "For a UTC+8 user the day flips at 15:00 local; due 'today' and the daily new-card allowance become available mid-afternoon and reset mid-afternoon. Internally consistent (tests assert the design); the label 'Arizona time' in ActivityBanner confirms intent — but it's never a setting.",
"expected": "Configurable timezone.",
"actual": "Fixed UTC-7 offset.",
"impact": "Confusing day boundaries for non-Arizona users.",
"evidence": "spacedRepetition.ts:157-161 (verified); tests codify it.",
"testCoverage": "Yes — unit tests assert the current behavior.",
"fixDirection": "Make the timezone a Setting (default America/Phoenix) threaded through the pure functions.",
"fixComplexity": "M",
"regressionTest": "Parameterize with another offset and assert boundaries.",
"needsRuntimeConfirmation": false
},
{
"id": "FIN-39",
"sourceIds": ["AUTH-05"],
"title": "Proxy destroy() cookie-clear header is lost on redirect; page-level check ignores sessionGeneration",
"severity": "Low",
"confidence": "Confirmed",
"files": ["src/proxy.ts:30-43", "src/app/(protected)/layout.tsx:10-13", "src/lib/auth.ts:53-56"],
"functions": ["proxy()", "ProtectedLayout", "isAuthenticated"],
"scenario": "Stale/tampered cookie → proxy redirects to /login, but session.destroy() attaches Set-Cookie to the discarded NextResponse.next() response, not the redirect → the browser keeps the bad cookie → every navigation redirects again until re-login. Layout backstop checks only isAuthenticated, not generation.",
"expected": "Invalid cookie cleared on redirect.",
"actual": "Clear header dropped; defense-in-depth inconsistency.",
"impact": "Minor UX (redirect loop until re-login).",
"evidence": "proxy.ts:30-42 (verified); iron-session destroy writes to the passed response.",
"testCoverage": "None.",
"fixDirection": "Build the redirect first, then getIronSession(request, redirectResponse) before returning; make isAuthenticated generation-aware.",
"fixComplexity": "S",
"regressionTest": "Integration: stale-generation cookie → redirect response carries Set-Cookie clearing study-app-session.",
"needsRuntimeConfirmation": true
},
{
"id": "FIN-40",
"sourceIds": ["AUTH-08"],
"title": "Session cookie Secure flag off in the shipped compose (plain-HTTP deployment, no TLS story)",
"severity": "Low",
"confidence": "Confirmed",
"files": ["src/lib/auth.ts:17-21", "docker-compose.yml:8-12"],
"functions": ["sessionOptions"],
"scenario": "Default docker compose up → secure=false → session cookie and login POST travel in cleartext on the LAN; any host can sniff and replay. SECURE_COOKIES=true on plain HTTP would break login entirely — the flag logic is correct; the deployment lacks TLS.",
"expected": "HTTPS with secure cookies.",
"actual": "Plain HTTP, no HSTS.",
"impact": "Credential/session sniffing on shared networks; critical if port-forwarded.",
"evidence": "auth.ts:18 (verified); compose sets no SECURE_COOKIES.",
"testCoverage": "None.",
"fixDirection": "Document/require a TLS proxy (Caddy/Traefik) + SECURE_COOKIES=true.",
"fixComplexity": "S",
"regressionTest": "Unit test flag computation across env combos.",
"needsRuntimeConfirmation": true
},
{
"id": "FIN-41",
"sourceIds": ["DBAUD-02", "AUTH-07"],
"title": "/api/share accepts arbitrary targetType — repeatable junk all-NULL ShareLink rows; no CHECK constraints or enum validation anywhere",
"severity": "Low",
"confidence": "Confirmed",
"files": ["src/app/api/share/route.ts:32-44", "src/services/shareService.ts:105-123", "prisma/schema.prisma:92-109,123-134"],
"functions": ["toggleShareLink", "POST /api/share"],
"scenario": "POST /api/share {targetType:'FOO', contentId:'x'} → toggleShareLink matches no branch → creates ShareLink with all three FKs null; SQLite unique indexes treat NULLs as distinct → unlimited junk rows; no cleanup path. StudyProgress has the same unvalidated contentType (FIN-21).",
"expected": "Only DECK/QUIZ/GROUP with exactly one target.",
"actual": "Any string accepted; junk rows persist.",
"impact": "Data hygiene (owner-controlled); no privilege boundary crossed.",
"evidence": "toggleShareLink fall-through create (verified).",
"testCoverage": "None.",
"fixDirection": "Zod enum validation at the route; optional CHECK constraints via raw migration.",
"fixComplexity": "S",
"regressionTest": "POST invalid targetType → 400, zero rows; GROUP twice → toggles to one row.",
"needsRuntimeConfirmation": false
},
{
"id": "FIN-42",
"sourceIds": ["DBAUD-03"],
"title": "SQLite dev.db was committed in git history (8 commits) and remains in blobs on the LAN remote",
"severity": "Low",
"confidence": "High-confidence inference",
"files": ["git history (dev.db blobs in b7ce314..7af0935)", ".git/config:8-10", ".gitignore:17"],
"functions": ["git history"],
"scenario": "Anyone with repo/server access can git show 7af0935:dev.db — a real SQLite DB containing StudyProgress rows (verified blob markers; no password hash found in the checked blobs, but content not fully enumerable read-only).",
"expected": "DB files never in VCS.",
"actual": "Present in history incl. the db-push drift variant.",
"impact": "Personal study content exposure to anyone with repo/server access; conditional (no credentials found).",
"evidence": "git log --all -- dev.db → 8 commits (verified).",
"testCoverage": "n/a.",
"fixDirection": "If the remote is ever shared: git filter-repo --path dev.db --invert-paths + force-push.",
"fixComplexity": "S-M",
"regressionTest": "git log --all -- dev.db empty after rewrite.",
"needsRuntimeConfirmation": false
},
{
"id": "FIN-43",
"sourceIds": ["OPS-09"],
"title": ".gitignore gaps: production data/ directory and study.db* not ignored; scratch files (out.css, temp.css, test.css) tracked",
"severity": "Low",
"confidence": "Confirmed",
"files": [".gitignore:16-20", "docker-compose.yml:13-14", "git ls-files"],
"functions": ["git", "compose volume"],
"scenario": "Running production compose from the repo creates ./data/study.db — not ignored → a careless 'git add .' commits the real production database. temp.css/test.css/out.css are tracked junk.",
"expected": "Database artifacts never committable.",
"actual": "Only /dev.db* guarded.",
"impact": "Risk of leaking the entire study database to the Forgejo remote.",
"evidence": ".gitignore contents vs compose volume path (verified).",
"testCoverage": "n/a.",
"fixDirection": "Add data/, /study.db*, audit-results/, .reasonix/; untrack the junk files.",
"fixComplexity": "S",
"regressionTest": "git check-ignore data/study.db returns the path.",
"needsRuntimeConfirmation": false
},
{
"id": "FIN-44",
"sourceIds": ["FE-07", "ADV-04"],
"title": "Dashboard fetchClasses has no .catch and no res.ok check — unhandled rejection and misleading empty state (or render crash on error JSON)",
"severity": "Low",
"confidence": "Confirmed",
"files": ["src/app/(protected)/page.tsx:30-37"],
"functions": ["fetchClasses"],
"scenario": "Network/server error on the dashboard → unhandled promise rejection → 'Your desk is ready' empty state despite existing classes; a 500 JSON error body stored into classes makes classes.map crash the render.",
"expected": "Error banner + retry.",
"actual": "Silent empty state / crash.",
"impact": "Misleading UI after transient failures.",
"evidence": "page.tsx:33-36 try/finally without catch (verified).",
"testCoverage": "None.",
"fixDirection": "Add catch → error state with retry; check res.ok before parsing.",
"fixComplexity": "S",
"regressionTest": "Reject /api/classes → error UI, not empty state.",
"needsRuntimeConfirmation": false
},
{
"id": "FIN-45",
"sourceIds": ["FE-09"],
"title": "Effect fetches without .catch in GenerateTab and ShareMenu — unhandled rejections, degraded UI states",
"severity": "Low",
"confidence": "Confirmed",
"files": ["src/components/import/GenerateTab.tsx:18-23", "src/components/ui/ShareMenu.tsx:38-48"],
"functions": ["GenerateTab effect", "ShareMenu effect"],
"scenario": "Settings/share fetch fails → unhandled rejection; GenerateTab textarea stays empty with no error (setInstructions(undefined) makes a controlled textarea temporarily uncontrolled); ShareMenu shows 'Enable link sharing' even when a share token exists server-side.",
"expected": "Error state.",
"actual": "Silent degraded UI.",
"impact": "Minor UX confusion; console noise.",
"evidence": "Missing .catch in both (verified).",
"testCoverage": "None.",
"fixDirection": "Add .catch → error message/retry; validate data.value before setState.",
"fixComplexity": "S",
"regressionTest": "Reject fetches → no unhandled rejection, error message shown.",
"needsRuntimeConfirmation": false
},
{
"id": "FIN-46",
"sourceIds": ["FE-10"],
"title": "Cross-class navigation fetch race: slow response from class A can render class A's data under class B's header",
"severity": "Low",
"confidence": "High-confidence inference",
"files": ["src/app/(protected)/[classSlug]/flashcards/page.tsx:205-241", "src/app/(protected)/[classSlug]/quizzes/page.tsx:192-228"],
"functions": ["fetchAll", "init effect"],
"scenario": "Navigate quickly between two classes: class A's in-flight fetch resolves after class B's → setDecks(A-data) renders wrong-class content until the next action. Module-level cache never invalidated (brief stale flash on revisits).",
"expected": "Only the current class's data applies.",
"actual": "Last-resolved-wins with no guard.",
"impact": "Wrong-class content displayed transiently.",
"evidence": "No abort/stale-response guard (verified).",
"testCoverage": "None.",
"fixDirection": "AbortController or sequence check per classSlug; invalidate/drop the module cache.",
"fixComplexity": "S-M",
"regressionTest": "Out-of-order resolution of two fetchAll calls → only latest class's data set.",
"needsRuntimeConfirmation": true
},
{
"id": "FIN-47",
"sourceIds": ["ADV-03"],
"title": "Logout has no error handling — failed logout strands the user with an unhandled rejection",
"severity": "Low",
"confidence": "Confirmed",
"files": ["src/components/ui/Navbar.tsx:55-59"],
"functions": ["handleLogout"],
"scenario": "Network failure during POST /api/auth/logout → router.push('/login') never runs; unhandled rejection; the user appears logged out in UI but the session cookie persists.",
"expected": "Graceful failure handling.",
"actual": "None.",
"impact": "Stranded session on transient errors.",
"evidence": "Navbar.tsx:55-58 (verified).",
"testCoverage": "None.",
"fixDirection": "try/catch + user feedback; force navigation regardless.",
"fixComplexity": "S",
"regressionTest": "Mock failed logout → navigation still occurs or error shown.",
"needsRuntimeConfirmation": false
},
{
"id": "FIN-48",
"sourceIds": ["ADV-05"],
"title": "slugify can yield an empty slug (unreachable class) and renames never update the URL slug",
"severity": "Low",
"confidence": "Confirmed",
"files": ["src/services/classService.ts:4-10,47-64,66-71"],
"functions": ["slugify", "createClass", "updateClass"],
"scenario": "Create a class named '!!!' → slug '' → class created but unreachable via /[classSlug] (empty segment). Rename a class → slug stays the old value; bookmarked/old URLs keep pointing at the old slug while the header shows the new name.",
"expected": "Always-nonempty unique slugs; rename updates the slug (or redirects).",
"actual": "Empty slug possible; rename never touches slug.",
"impact": "Unreachable content; stale URLs after rename.",
"evidence": "classService.ts:4-9 slugify drops all non-alphanumerics (verified); updateClass updates name only.",
"testCoverage": "None.",
"fixDirection": "Fallback slug (e.g. 'class' + counter) when slugify returns ''; decide rename-vs-slug policy (update slug or 301 old → new).",
"fixComplexity": "S",
"regressionTest": "createClass('!!!') → non-empty unique slug; rename → old slug resolves or redirects.",
"needsRuntimeConfirmation": true
},
{
"id": "FIN-49",
"sourceIds": ["ADV-08"],
"title": "Shared-viewer localStorage session keys are item-scoped, not token-scoped — progress collides across share tokens and with the owner's own sessions",
"severity": "Low",
"confidence": "High-confidence inference",
"files": ["src/app/shared/[classSlug]/[type]/[token]/SharedViewer.tsx:26-33", "src/app/shared/[classSlug]/[type]/[token]/SharedGroupViewer.tsx:24-41", "src/components/flashcards/FlashcardViewer.tsx:129-150"],
"functions": ["localStorage session save/load"],
"scenario": "Two share links for the same deck/quiz (or the owner studying locally) share one localStorage key (flashcard_progress_<id> / quiz_progress_<id>): progress from one token's session restores into another's; restored orders can reference cards not in this payload → stuck blank viewer (recoverable via restart).",
"expected": "Session scoped to the share token.",
"actual": "Scoped to content id only.",
"impact": "Cross-token session bleed; stuck viewers in edge cases.",
"evidence": "Key construction in SharedViewer/SharedGroupViewer (verified).",
"testCoverage": "None.",
"fixDirection": "Include the token (or a per-viewer random id) in the localStorage key.",
"fixComplexity": "S",
"regressionTest": "Two tokens for the same content → independent sessions.",
"needsRuntimeConfirmation": true
},
{
"id": "FIN-50",
"sourceIds": ["GRP-07"],
"title": "Group sortOrder uses an inverted desc convention (newest-first) with an unvalidated PATCH — latent trap for future group reordering",
"severity": "Observation",
"confidence": "Confirmed",
"files": ["src/app/api/material-groups/route.ts:26,43-47", "src/app/api/material-groups/[id]/route.ts:11-21"],
"functions": ["GET/POST /api/material-groups", "PATCH /api/material-groups/[id]"],
"scenario": "Groups order by sortOrder desc (newest first) while every other sortOrder in the codebase is asc; PATCH accepts arbitrary/duplicate sortOrder values with no validation. Internally consistent today (no group drag UI); the convention is fragile and the PATCH is a footgun.",
"expected": "Consistent, validated semantics.",
"actual": "Inverted convention, unbounded PATCH values.",
"impact": "Latent ordering trap; self-harm only via crafted requests.",
"evidence": "material-groups/route.ts:26 (verified).",
"testCoverage": "None.",
"fixDirection": "Remove sortOrder from the group PATCH contract or validate/normalize it; document the convention.",
"fixComplexity": "S",
"regressionTest": "PATCH with sortOrder 'abc' → 400.",
"needsRuntimeConfirmation": false
},
{
"id": "FIN-51",
"sourceIds": ["FE-11"],
"title": "Collapsed-groups state read in an effect — brief expand flash on every library visit",
"severity": "Observation",
"confidence": "Confirmed",
"files": ["src/app/(protected)/[classSlug]/flashcards/page.tsx:190-195", "src/app/(protected)/[classSlug]/quizzes/page.tsx:177-182"],
"functions": ["collapsed-groups localStorage init"],
"scenario": "Collapsed groups render expanded for one frame after navigation, then collapse (effect runs post-paint). Cosmetic; source of a known lint warning.",
"expected": "Collapsed from first paint.",
"actual": "One-frame flash.",
"impact": "Cosmetic layout jump.",
"evidence": "useState {} + effect sync (verified).",
"testCoverage": "None.",
"fixDirection": "Lazy useState initializer or useSyncExternalStore.",
"fixComplexity": "S",
"regressionTest": "Render with saved collapsed state → collapsed on first paint.",
"needsRuntimeConfirmation": true
},
{
"id": "FIN-52",
"sourceIds": ["AUTH-10"],
"title": "Shared quiz links ship the full answer key (isCorrect, rationale) to anonymous viewers — by design, but worth an explicit warning",
"severity": "Observation",
"confidence": "Confirmed",
"files": ["src/services/shareService.ts:13-23", "src/components/quizzes/QuizViewer.tsx:377-487", "src/components/ui/ShareMenu.tsx:130"],
"functions": ["getShareLink", "QuizViewer shared grading"],
"scenario": "Anyone with a shared quiz token can read correct answers and rationales from the payload — inherent to the client-side self-grading design and documented ('Anyone with the link can view and study'). The token IS the answer key.",
"expected": "Documented sharing contract.",
"actual": "Exactly that.",
"impact": "None beyond the documented contract; consider a confirmation in the Share menu.",
"evidence": "shareService include without select-sanitization (verified).",
"testCoverage": "None.",
"fixDirection": "Optional: warning text in ShareMenu; a future 'practice-only' share mode without isCorrect.",
"fixComplexity": "S",
"regressionTest": "Assert shared payload omits attempts/progress/answersJson.",
"needsRuntimeConfirmation": false
},
{
"id": "FIN-53",
"sourceIds": ["TEST-01"],
"title": "False-confidence test: 'distinct valid state for every rating' never asserts distinctness — scheduleRating rating-dependence is untested",
"severity": "Low",
"confidence": "Confirmed",
"files": ["src/lib/spacedRepetition.test.ts:15-23"],
"functions": ["scheduleRating (test)"],
"scenario": "The loop over AGAIN/HARD/GOOD/EASY asserts only reps===1, lastReview===now, due>now per rating — no cross-rating comparison. If ratingMap broke (all ratings → Good), the test still passes. previewRatings (a different function) is tested separately; scheduleRating's rating dependence is never asserted for learning/review states.",
"expected": "Ratings produce distinct schedules.",
"actual": "Distinctness never asserted.",
"impact": "False confidence in the SRS scheduling core.",
"evidence": "spacedRepetition.test.ts:15-23 (verified).",
"testCoverage": "The finding.",
"fixDirection": "Assert pairwise due ordering AGAIN < HARD < GOOD < EASY and distinct due timestamps; add a graduated-state lapse case.",
"fixComplexity": "S",
"regressionTest": "The improved test itself.",
"needsRuntimeConfirmation": false
}
],
"notes": "Severity: Critical = destructive data loss / credential exposure / unauthorized access / unrecoverable migration failure / app-wide failure; High = major feature failure / persistent state corruption / deployment failure; Medium = meaningful incorrect behavior; Low = limited concrete defect; Observation = useful concern without confirmed defect. Confidence: Confirmed / High-confidence inference / Unverified risk. FIN-01..FIN-53 are the consolidated, deduplicated findings; source IDs from specialist auditors are preserved in sourceIds. FE-06 ('no auth on API routes') was refuted by the adversarial review and is recorded in REJECTED_FINDINGS.md. Test-gap findings beyond FIN-53 (scoring/auth/SRS-service/attempt/jsonRepair/shuffle/rateLimiter missing tests, no integration harness) are detailed in TEST_GAPS.md."
}