Spec 16 Delivery Phases - SekiroKenjii/Quizzivy GitHub Wiki
Section of Quizzivy — Frontend Portal & Data Model Specification · Previous · Next
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:
-
intakeEnabledis true when the import store is configured. -
processingEnabledis true whenIMPORT_PROCESSING_ENABLEDstates 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 ascancelled, flaggedclosedIdle, 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.