Spec 16 Delivery Phases - SekiroKenjii/Quizzivy GitHub Wiki

Section of Quizzivy — Frontend Portal & Data Model Specification · Previous · Next

16. Delivery phases

Each phase ends deployable. Do not start the next with the current one red.

Phase Deliverable Exit criteria
0 — Scaffold Vite app, Tailwind, shadcn (neutral), router, i18n, API client, lint/test/CI. Neon skill installed; app schema + users/identities/classes migrations. R2 bucket + credentials. pnpm dev/test/build green; /login renders; migrations clean
1 — Auth, join, shells Password login, Google login, refresh + rotation, join-code flow end-to-end, guards, both layouts, settings E2E 2, 3, 4 pass
2 — Admin authoring Tests list, builder (all 5 types), question bank, media upload + validation, publish + version snapshot E2E 1 passes
3 — Assign & take Assignment create/list, student home, intro, take-test engine, audio player, integrity capture E2E 5, 6, 7, 8 pass
4 — Grade & results Monitor, attempt review + integrity timeline, grading with sample answers, result page honoring review + transcript flags Full E2E suite green, incl. 9
5 — Hardening Perf budgets, a11y pass, empty/error audit, mobile QA at 360px, rate-limit verification, EXPLAIN review of hot queries Lighthouse a11y ≥ 95; budgets met; no seq scans on hot paths

P1 after v1: presigned direct-to-R2 upload, CSV import (students, questions), password self-signup with email verification, dark mode, per-student time accommodations, per-question analytics.

The next accepted milestone is production Word exam import, implemented through the six-stage delivery plan and teacher workflow. The supplied document defines the target rather than current availability. Existing free-form .docx and .doc documents, explicit answer evidence, shared context, semantic formatting, recoverable review and atomic draft creation are required before general release. Imported and manually authored exams must share rendering, publication and grading contracts. Missing answers remain unknown; OCR, answer generation and automatic publication are excluded. PDF input is limited to a text layer (D-10) unless the teacher asks for "Read with AI" (below), and a test pasted as plain text is accepted as an exam source too (D-11). Each accepted detailed contract updates the relevant sections above and OpenAPI before implementation.

D4, the deck update and the AI assistant (v0.11.0; docs/plan/74d-d4-deck-update.md). The release after R4 rebuilds the take-test engine, the builder's shared content and the three previews to the v2 deck, and adds the AI assistant (D19 to D22): the provider adapter and its tiers (T-D4.30), the credits (T-D4.31), the sixteen operations (T-D4.32), "Read with AI" in the import (T-D4.33), the teacher's surfaces and Settings › AI assistant (T-D4.34 to T-D4.39), and these documents (T-D4.40). It ships when, with the key unset, every AI operation answers 501 and no entry point is drawn; with the key set, a live smoke meters one generation, one score suggestion and one scanned page and reads them back from the ledger; and a reservation that would overspend the pool is refused by its constraint. The Admin's AI credits page is R5's (T-R5.35), on D4's operations.

The native source intake checkpoint is configured separately from learner media. IMPORT_S3_BUCKET names a separate private bucket; IMPORT_WORK_DIR is an absolute, private disk directory. Both are required to enable intake. The existing S3 endpoint and credentials are reused, with no public bucket or CDN fallback. An import, its sources, its review and its commit belong to its creator. scope.all reaches every import by id, the history lists only the caller's own (§5), and another creator's import answers as a missing one does. The test a commit creates, with its questions and groups, belongs to the import's creator; the committer stays their created_by, the audit actor and committed_by (T-R2.12f). Creator and modifying actors are retained.

Availability is reported, not assumed. GET /teacher/imports/capabilities answers on every deployment:

  • intakeEnabled is true when the import store is configured.
  • processingEnabled is true when IMPORT_PROCESSING_ENABLED states that a worker runs beside the API. The API refuses that setting without import storage.

Without processing, processWordImport answers 503 IMPORT_PROCESSING_UNAVAILABLE and queues nothing, so no new run waits for a worker that does not exist. A run queued before processing was switched off waits until a worker returns or the teacher cancels it. Finished imports stay reviewable and committable. The client hides the feature where intake is off. Where processing is off, it withholds new imports, retries and reprocessing, and says why. Production runs both: O-24 was decided on 2026-09-25, and the import worker runs there as its own Fly process group.

The R4 history presents the existing list contract as one card/grid that changes at 960px of its outer container. Six ordered filter controls, search, page and page size use the URL; active-import polling pauses while idle and refreshes on resume. History returns six facets under the caller's own scope and search, ignoring the selected status. Repeated status values are OR-ed, with a single value still valid; Processing selects awaiting_sources, queued, processing and committing. Each history item carries reviewCounts {needsAction, toConfirm}|null, derived from the exact current review body. Machine completion, review save and candidate adoption update counts with that body; an unadopted candidate leaves current counts intact. An untouched historical draft remains unknown, while computed zero is an exact zero. Facets, pagination and hydrated items share one repeatable-read snapshot. Text sources carry their normalized character count; filename search excludes only their stored pasted-text.txt name, while title and companion-file search remain available.

The R4 upload page keeps one source-intake instance mounted across ?source=paste changes, preserving selected files, the pasted text and the typed title. File mode retains automatic recognition, actual limits and retention, the privacy notice and a sticky Cancel/Start footer. Leaving during upload quietly stops intake, as before. Neither page infers provider policy from capability data. Paste mode is a plain textarea with a counter against pasteMaxCharacters (code points of the text as it will be sent, composed to NFC), no answer-key slot, and a "Found so far" count of sections, questions and answers. That count follows the server's recognition rules for text sources, not a rule of its own, and api/testdata/pasted-text-counts.json holds both to the same numbers; it is a hint, and the review is the authority. Start processing is disabled for an empty box, a text over the limit and a text with no numbered question, and runs the file flow's steps with the text in place of the exam file: create, paste, process. Pasted text that has not started asks before the teacher leaves.

The R4 processing page uses the deck's 720px frame and five presentation rows mapped to the actual run stage. It retains elapsed time, attempts and retry waiting, shows completed receipts only after their stages, and exposes no invented run-event history. The actual exam source's role and format select the pasted-text header, character receipt and plain-text/no-formatting receipt, independently of filename or title. Failed text imports keep the actual error code and stage; source-invalid and size titles and recovery instructions remain neutral about the pasted text or its companion file. Restart opens the paste mode. Cancellation and closed states use configured retention and finality; Ready uses the current review's real counts, severity and conflict rules. The active-stage spinner pauses on hover and keyboard focus and stays static under reduced motion; retry waiting has no active spinner. This page's source title is static.

POST /teacher/imports/{id}/sources/text accepts a closed JSON body with uploadId, expectedRevision and plaintext text, under content.tests.write. It normalizes NFC and stores a private pasted-text.txt exam source using the file upload's ownership, replay, revision, quota, intake-capacity and retention rules. ImportLimits.pasteMaxCharacters is 100000; file formats stay unchanged. The request schema rejects more than 100000 sent code points with 400. The command rejects NUL or invalid UTF-8 with 415, and more than 100000 normalized code points or 20000 nonblank lines with 413. Invalid UTF-8 is a command boundary assertion; JSON decoding can replace malformed wire bytes. The default raw-body limit still applies. A multipart .txt file remains unsupported.

Text extraction removes an initial BOM, normalizes line breaks and NFC, and emits plain paragraph blocks with original line-number IDs, no formatting spans and TEXT_MARKS_UNAVAILABLE. The private original and existing download, source view and retention operations remain available. word-pipeline-v3 fences these runs from v2 workers, and a run still queued under v2 when v3 is deployed fails after ten minutes with PIPELINE_RETIRED, so its teacher processes it again. A companion answer-key file uses the existing upload operation. A text source is never normalised or converted, so a worker needs no converter to read one (docs/setup/word-import-worker.md).

A pasted text is plain text. Whatever the clipboard held, only its text is stored: the page reads no HTML, so no bold, underline or colour exists to read, as with a PDF. Every pasted draft says so in an informational finding, TEXT_MARKS_UNAVAILABLE. Images and audio are not pasted; the page says so and the test builder adds them. A question that depends on an underline is marked by the teacher in review. The source view shows the one source as pasted, "Download original" returns the stored .txt, and the history names the source "Pasted text" with its character count, never by the stored file name.

Recognition is rules-v3. For an exam whose evidence version is text-lines-v1 the recognizer also reads the forms the paste page tells the teacher to type, and no others: * as the first or last non-space character of an option marks it correct (several starred options of one question are one answer, and a * anywhere else is content); an answer line under a question, Answer: B, Ans: B, Answer - B, Key: B or Đáp án: B; lower-case option letters; a bare 1: as a question label; a bare roman numeral on its own line, and a named part such as Part A or Bài tập 1, as headings and never as the title; and a run of three or more underscores as a gap. Answer: Not given gives a short answer the teacher fixes in review, because true / false / not given is not a question type before R6. A one-line answer key is read for every format, Word and PDF files too (#233): Answer key: 1-B 2-C, also Answers:, Key: and Đáp án: before the pairs, whose separator is -, :, . or ), is the key's entries when it holds at least two pairs, so Đáp án: 1-B 2-C is no longer attached to the last question as one value. A line with fewer than two pairs keeps its old meaning. Formatting is never evidence of correctness; these conventions are text. docs/plan/17-word-import.md §1.41 records the scope and what is measured.

/teacher/imports creates an empty record idempotently and lists history by status, title or current filename. A source upload accepts exactly one native .docx, with bounded package/content inspection, a maximum 25 MiB compressed body and the Word inspector's expansion/XML limits. .doc is accepted where IMPORT_LEGACY_DOC is set. Operators set it only beside a worker that has the isolated converter; a run without one fails with LEGACY_CONVERSION_UNAVAILABLE. A successful upload only acknowledges stored source bytes.

A .pdf is accepted by its %PDF- signature. The worker reads it with PDFium inside a WebAssembly sandbox: no filesystem, no network, 256 MiB of memory, one minute, 60 pages. It never converts a PDF. It reads only the text layer, one line per block. Page numbers are kept out of the exam. Lines repeated at the top or bottom of the pages are kept out too, and the teacher confirms them, since a repeated instruction may belong to the exam. A line holding two questions side by side, as a two-column page produces, needs review. A PDF carries no underline, bold or colour marks for recognition, and the draft says so in an informational finding. A scan, a locked file, a broken file or one over the limits fails with PDF_NO_TEXT, PDF_PROTECTED, PDF_INVALID or PDF_TOO_LARGE, and the teacher needs another file; a scan is read instead when the import asked for "Read with AI".

Read with AI (D4, T-D4.33; docs/plan/17-word-import.md §1.42). createWordImport takes readWithAi (default false), stored on the import in word_imports.read_with_ai and fixed from then on. It is refused with 422 VALIDATION_FAILED naming readWithAi when the server has no assistant or the caller lacks ai.use. It matters only to a PDF that fails the text-layer check: a .docx, a pasted text and a PDF with a text layer take the path they always took, send nothing and spend nothing. The worker draws up to 40 pages in the PDFium sandbox (a longer file fails PDF_TOO_LONG_FOR_AI before anything is held or sent; a scan over the general 60-page limit stays PDF_TOO_LARGE), reserves 60 credits a page for the whole file before the first call (a shortfall is AI_CREDITS_EXHAUSTED or AI_PAUSED with nothing sent), and sends the pages to the capable tier four at a time. The teacher who started the run pays, in one ledger row for the file. What comes back is each page's printed lines, not questions: they become pdf-lines-v1 evidence, and the recognizer, its source links, review and the transactional commit are the text layer's, so the provider is an untrusted reader and writes no field of a draft. A page the service cannot read is a review-required finding on that page, never a silent gap; every page read counts toward one informational finding, "{n} pages were read by an AI service outside Quizzivy", which is the import's record of what left the system; a file with no readable page fails AI_UNREADABLE, and a provider that stays down fails PROVIDER_UNAVAILABLE, which is terminal because a retry would pay for the pages again. An import whose scan was read keeps the extraction, so processing it again spends nothing. The Sources pane says that scanned pages are read by an AI service outside Quizzivy and that pages with selectable text are not sent. No check refuses a file that names a student: it would be a false sense of safety, and the teacher carries the responsibility for the file.

Each accepted exam/key addition or replacement creates an immutable source set, retaining its unchanged companion and prior originals. Uploads require the current import revision while awaiting_sources. Exact retries reuse their upload identity; changed input conflicts. A durable reservation precedes each object write, and both pending and completed bytes count towards actor/global quotas. No database transaction is held during upload or inspection. The API admits one expanded inspection per process. Interrupted reservations remain observable until retention removes the import's files. Only completed sources can receive a 60-second download URL, forced to attachment/octet-stream. Source identifiers are never media asset IDs.

Retention keeps an import's original files, artifacts and review draft as follows:

  • After commit: 30 days, counted from the commit.
  • After cancellation: 7 days.
  • Idle imports: an import waiting on a teacher (awaiting_sources, failed, needs_review) that nobody has touched for 60 days, draft saves included, is closed as cancelled, flagged closedIdle, and its files are removed at once. An upload, a reservation or a draft save counts as a touch.

The API sweeps at start-up and then daily, and retries a failed sweep after an hour. It exists wherever import storage does, with or without a worker. Each sweep works through everything due, a batch at a time, within a five-minute budget. An import whose removal fails is passed over for the rest of that sweep.

For each import the sweep deletes the objects first, then the draft, then sets files_removed_at, and it audits the closure and the removal. Removal applies only to committed or cancelled imports, which can never be processed again.

The history row, the source metadata, the runs and the commit record remain. Downloads, the source view and the review answer 410 IMPORT_FILES_REMOVED, and the client says the files were removed under the policy.

Native extraction retains XML order and source-bound identities for paragraph, container, object and unassigned-content blocks. Source fragment offsets use Unicode code points and exclude generated labels. Hidden/revised text, fields, ancillary parts and unsupported objects remain review evidence. No formatting is interpreted as correctness without a confirmed convention. Nested table coordinates retain merge evidence; ambiguous grids are flagged. Selected PNG/JPEG assets may be normalized under bounded decoding limits, but remain private until explicitly reviewed and bound to learner content. Source blocks are separate from machine candidate JSON and must not be exposed on student endpoints.

An internal offline converter can normalize binary DOC and render private source pages under fixed development resource limits. Native originals remain unchanged; converted DOCX and raster output are revalidated. Source, renderer, immutable image and artifact identities accompany the rendition. Layout and legacy conversion require explicit review; visual source pages do not imply a question-coordinate map. A single Docker slot prevents orphan/retry overlap, and the container's independent deadline remains active after worker failure. This tool is not yet public legacy intake or production activation.

Private stage artifacts have a durable reservation before object storage, pinned by source revision, role, run, claim and component configuration. Pending bytes count towards separate actor/global limits. A set becomes readable only when all its declared files are stored; completed files and sets are immutable. Current claims may reuse completed evidence for the same source, pipeline and component, including after an explicit retry. Incomplete evidence from an older claim is retained for accounting but cannot be adopted by a newer worker. Conditional, checksum-verified writes prevent changed replay from replacing stored bytes. Source blocks and page files stay outside the bounded run-result JSON. There is no learner access through these objects; they are removed with the import's other files under the retention policy.

Processing requests retain their source-set revision, pipeline version and replay identity. One queued/running request per import is permitted, with at most 50 retained requests per import and 1–10 automatic attempts per request. Claims and heartbeats serialize capacity accounting across worker processes. Live leases expire after a configured 1 second–5 minutes; every state/result write checks the worker identity, fencing token, lease and source revision. Exhausted crashes become failed runs. Retrying a failed import creates a new immutable run identity.

The offline deterministic recognizer produces word-candidate-v1 proposals from source-linked text, never bank questions. It distinguishes unknown, known and conflicting choice keys; explicit option IDs survive reordering. Numbering, sections/papers, same-line options, continuation paragraphs and explicit inline, final or companion choice keys retain Unicode source ranges. Restarted labels and ambiguous table associations do not authorize a guessed match. Bold/underline only become answer evidence under an explicitly teacher-confirmed convention; key-only marks are removed from the proposed learner prose.

Every meaningful block remains in the coverage ledger. Unassigned ranges, private branches, uncertain structure, default points and unresolved fidelity are findings. Source comments, hidden/revised text and fields are never automatically copied into learner prose. This initial recognizer handles labeled choice structures; grouped cloze, typed/written keys, saved profiles and assisted free-form recognition remain open. A proposal is not a reviewed draft or approval to commit an assessment.

An internal runner stops cooperating processors on timeout, lease loss or shutdown. Processing runs outside transactions. Only a current claim can store a bounded private JSON object and transition to needs_review. Terminal runs cannot be changed. Run events retain actor/worker/stage/failure codes without document contents. The standalone import-worker assembles source verification, private rendition, chunked raw extraction and deterministic recognition. It verifies object lengths and SHA-256 before parsing, reuses completed stages pinned to source/configuration, and stores candidate content privately. Its bounded result contains only lineage and artifact-set identities. The API does not start this worker or require Docker. One synchronous job per process, database lease limits, a 512 MiB Go soft-memory target, a five-minute job deadline and the converter's separate hard limits are conservative development defaults, not the approved production capacity envelope. Full domain validation, public processing controls and production supervision remain required before release.

Thuong approved two ownership/recovery policies for this milestone:

  • Groups and shared materials belong to independent editable copies. Saving a dependent group to the bank or copying it into another test carries every required member/material and remaps identities; subsequent source edits or deletion cannot affect the copy. Relational asset references protect reused immutable files from deletion; reuse of bytes is not shared editable content.
  • Unsent edits may be stored locally, isolated by account, for at most seven days from the unsent revision's creation, and cleared on logout. Recovery is explicit and revision-aware; local-only changes never display as server-saved. Local storage failure or quota exhaustion must remain visible and cannot silently discard unsent changes. Source/answer files are not stored in this recovery outbox. The group editor implements this through IndexedDB, with expiry checked before recovery, explicit restore/discard and a global logout fence that rejects older writers in other tabs. Opening a newer editor of the same item fences the previous local writer; it cannot overwrite or clear the newer editor's outbox. Section-owned group editing uses the same recovery gate and outbox. An explicit route exit first confirms local persistence; save-and-leave flushes both content and outline. Test title/outline and standalone questions are not stored locally. Import review and standalone authoring recovery still require integration.

Section of Quizzivy — Frontend Portal & Data Model Specification · Previous · Next


Generated from docs/quizzivy-spec-v0.3.md. Edit that file in the repository; changes made on this page are overwritten.

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