Plan 17 Word Import - SekiroKenjii/Quizzivy GitHub Wiki

Word exam import β€” milestone delivery plan

Status: delivery plan accepted by Thuong on 2026-09-22; implementation started. The planning change itself implements no import functionality. Product owner: Thuong. Engineering owner: the implementing developer. Teacher pilot acceptance belongs to Thuong and the participating teachers, not to automated tests.

1. Scope, authority and baseline

The supplied Word import specification, version 1.0, defines the target. Its copy is unchanged, including its older reviewed baseline. This plan reconciles that target with released v0.5.0, main 954fbec83b12a77d845174e3a55e47dd426f41b1, and develop 1e9e7a2710b9c610c2ac53c7ae1cbad87fc965d7. Their application trees include the recent authoring fixes, safe version lifecycle and refreshed UI.

The current product spec, architecture and api/openapi.yaml still describe implemented behavior. Proposals below do not silently supersede them. At each accepted contract gate, update the relevant product sections/version and plan, then OpenAPI, then run make gen, before implementing either side. No new dependency or migration is selected by this planning change.

Delivery means: real Word file β†’ durable review β†’ one coherent draft β†’ manual authoring β†’ immutable publication β†’ assignment β†’ student attempt β†’ grading. Both free-form .docx and legacy .doc are required for general availability. The six stages sequence implementation; they do not reduce the release scope. OCR/scans, PDF input, solving missing answers, new interactions such as matching, automatic publication and automatic assignment remain outside this milestone. Internally rendered PDF/page images are comparison artifacts, not PDF import.

On 2026-09-22 Thuong requested consideration of both cloud API and privately hosted AI. No vendor, model, external data transfer or infrastructure purchase has been approved. Compare both using the same evaluation protocol (Β§8).

1.1 Implementation checkpoint β€” source evidence

W-01 now has an authorized local corpus supplied by Thuong: eight DOCX files (five related exams, two companion-key documents and one annotated homework), 49 two-page exam PDFs and one reference/advertising PDF. The five new exams have 31 top-level labels and 35 answer targets each, including a five-child cloze group. The matching key document contains all 175 target labels. This is a structural correspondence check, not independent verification of answer truth. The PDF family has keys for 50 tests but only 49 exam files; the missing exam is excluded from coverage calculations. Private sources, extracted text, answer data, hashes and detailed local reports are not committed.

PDFs are authorized layout/semantic evaluation references, not Word parser fixtures or a change to the supported upload formats. A conversion from PDF cannot validate original OOXML style/numbering behavior. The corpus is clustered in a few English-exam families; it does not yet establish broad format coverage, legacy .doc compatibility, listening coverage or a teacher-effort baseline. All files are inventory data until expected semantic outputs are reviewed. Any tuning/holdout split must account for related templates and shared key files.

The W-03 source spike is server/internal/platform/word, with a local-only cmd/word-inspect command. It retains paragraph/run locations, explicit marks, table properties, relationships and unresolved object evidence; bounds ZIP/XML and locator growth; and does not infer questions or answers. See its README for limits and reproducible checks. It uses no additional dependency and is not wired into the API or student application. A separate bounded resolver now derives supported inherited semantic marks and automatic list labels, retaining property provenance and explicit findings for unsupported or ambiguous cases. It handles main-body list instances, nested levels, starts/overrides/restarts, style associations and common number formats without rewriting source text. Conditional table styles, ambiguous toggles, non-Latin marks, numbering style links, custom/symbol numbering and revision-dependent counters remain unresolved. Full style/numbering fidelity, asset decoding, semantic recognition and the W-12 coverage gate remain open; the package README defines the supported subset.

All eight supplied DOCX files pass inventory with unresolved findings retained. One sequential local measurement with two Go scheduler threads took 12–130 ms per file and 13–133 MB peak process RSS, including optional JSON output; these are source-inventory measurements, not end-to-end import SLOs. A synthetic 50-question inventory benchmark took about 0.6 ms/iteration and 0.53 MB allocated per iteration on the development machine. A local LibreOffice rendition of one exam completed with a sampled process-group peak near 204 MiB. Production converter isolation and capacity are still unproven; this local render is not the W-13 security gate.

Corpus-driven cases to carry into semantic validation and review:

  • Instructions refer to underlines missing from the five new DOCX documents and their style definitions. A rendered sample confirms the missing marks. Missing evidence must ask for a teacher correction, never a guessed underline.
  • Compact keys may concatenate question labels without spaces; decimal labels such as 23.1 are identities, not decimal numbers. Some child keys omit the Question prefix. Bind within the selected test, not across the whole key file.
  • Annotated homework interleaves original questions, learner responses and teacher corrections. These must not be treated as interchangeable answer keys.
  • PDF references include semantic underlines, shared passages, page continuations, option-label references, and three-way true/false/not-given exercises. Preserve meaning or surface an explicit unsupported/mapping decision; never reduce three choices to a binary true/false interaction.

W-01–04 are still in progress: the complete editor evaluation, remaining domain/API contracts, reviewed expected outputs, converter isolation, cloud/private model evaluation and teacher walkthrough are not satisfied by this extraction spike.

1.2 Implementation checkpoint β€” content editor experiment

W-03 now includes a candidate application-owned semantic AST, bounded strict validation, plain-text projection, a read-only renderer and a Tiptap adapter. The synthetic editor harness exercises editing, undo, document switching, stable gaps, merged tables, rejected rich paste and a 320 px layout without changing production routes or storage. Its browser checks run in CI; a real-build test separates editor code from the reader and enforces transfer budgets. Changes exist only in the harness session.

Tiptap 3.31.3 and Lexical 0.51.0 both preserved the tested model semantics on the same synthetic fixtures. Tiptap is the current interactive candidate, not a final selection based on teacher or native-IME acceptance. The comparison procedure, dependency rationale and remaining limitations are documented with the harness. This does not complete W-05 or authorize storing editor JSON: the domain/API contracts, server validation, real asset bindings and integration with immutable versions must precede production use. No teacher document was sent to a service.

1.3 Implementation checkpoint β€” content contract foundation

W-02 now has additive OpenAPI components, generated Go/TypeScript types and an editor-independent Go content value object. Shared synthetic conformance cases pin structure, Unicode, projection, table/gap rules and unsafe-data rejection against the frontend validator and JSON Schema. The Go parser also bounds raw JSON and rejects duplicate keys and malformed encodings before validation. The content contract names ownership, recovery, integration and rollback requirements. That checkpoint added no question write, schema migration, asset delivery or durable browser storage; Β§1.4 now connects the bounded inline option subset. D-02 and the recovery policy within D-04 are approved. Thuong also approved D-03 group ordering and shared recording allowances. Final editor acceptance, the complete graph/revision API and implementation of these approved policies remain open.

1.4 Implementation checkpoint β€” inline option slice (W-05a)

Option formatting now connects bank/builder to normalized storage and immutable snapshot/restore, previews, attempts/results and teacher review. The bounded profile is one paragraph of marked text and breaks; it preserves pronunciation underlines without exposing answer metadata or enabling unbound media/gaps. text remains an exact literal/plain projection. Migration 00032 is additive; no old snapshot is rewritten. See 19-content-contract.md for legacy writes and rollback constraints. The pilot formatting affordance is opt-in with VITE_RICH_OPTION_EDITOR; existing rich readers/editors remain available.

This is a reviewable W-05/W-06 sub-slice, not completion of either package. Question prose is integrated in Β§1.5; structured paste, grouped materials, shared playback, revision recovery and the import journey remain later work packages.

1.5 Implementation checkpoint β€” question prose (W-05b)

Optional semantic prompts and explanations now use bounded paragraphs, headings, lists and tables with marks, breaks and safe links. Migration 00033 preserves these documents through bank duplication, publication, restoration, previews, attempts, results and teacher review. Existing Markdown stays unchanged. Prose cannot bind assets or gaps; fill-blank prompts retain their historical path. Since T-R4.64 the rich editor is how every teacher writes a prompt or an explanation; a field stored as Markdown stays Markdown until its teacher converts it.

Explicit Markdown conversion previews the supported subset and refuses unknown structures without modifying the original. Removing rich structure is an explicit teacher action with a warning; the literal projection is escaped before it goes back through the Markdown reader. Editor code remains lazy and absent from the learner dependency graph. Explanation delivery follows the existing result policy in SQL, while active papers and previews never select either explanation column. Legacy writes cannot silently discard these documents.

This remains a partial W-05/W-06 slice. Structured clipboard conversion, stable blank bindings, shared material graphs, revision recovery and final editor/pilot acceptance are not implied by these integrations. The two new direct dependencies, unified and remark-parse, were already transitive dependencies of the Markdown reader; declaring them directly lets the conversion reuse the same parser instead of approximating Markdown with regular expressions.

1.6 Implementation checkpoint β€” shared question validation (W-06a)

Bank/manual/internal writes and publication now share questions.Input.Validate. Publication adapts resolved draft questions to that input and keeps violations anchored to the affected question/section. This closes true/false key cardinality, empty option/blank answers, unsupported types, blank correspondence and invalid content gaps between the write and snapshot paths. Question points use exact hundredths without silent rounding; publication rejects empty exams and total overflow before creating a snapshot. Frontend form checks mirror the interaction rules. Historical published versions are not rewritten.

This is the validation portion of W-06. Rich blank bindings, structured paste, asset authoring and group/revision integration remain separate work.

1.7 Implementation checkpoint β€” stable blank bindings (W-06b)

Rich fill-blank prompts now bind gaps to answer metadata independently of node order or labels, including table cells and nested lists. Migration 00034 preserves these bindings through bank writes, duplication, immutable publication, restored drafts, learner attempts, results and teacher review. Copies remap both ends of the relation. Answer submissions keep their frozen blank UUIDs and existing grading.

The opt-in authoring flow previews legacy conversion, preserves orphaned answers for undo, requires explicit discard and blocks incomplete bindings before save. Legacy Markdown is unchanged. This advances W-06; structured paste, asset bindings, groups, revision recovery, import processing/review and teacher acceptance remain.

1.8 Implementation checkpoint β€” structured clipboard (W-06c)

Rich authoring now previews supported clipboard prose before changing a field. The local converter preserves inline semantic marks, list starts, safe links and table spans, using the existing application-owned content contract. It rejects unsupported/ambiguous source structure as a whole and never derives keys from underlines or other marks. Input and resulting documents have bounded budgets. Captured selection, unchanged-document checks and transaction history protect cancel, concurrent edits and undo. The parser loads only when formatted paste is requested, outside the learner reader and the initial editor dependency closure.

The pinned parse5 dependency parses inert HTML without DOM mounting, scripts or resource fetching. Files, externally dropped HTML, unbound assets/gaps, stylesheet rules, tracked changes and Word-specific list conventions remain explicit refusals. This is a bounded clipboard subset, not a claim of arbitrary Word conversion. Group/media authoring, revision-safe persistence, local recovery and native IME/ teacher acceptance remain subsequent gates.

1.9 Implementation checkpoint β€” group graph contract (W-07a)

The additive QuestionGroup contract now distinguishes actual shared context from existing sections. Tests-domain validation resolves the complete member catalog, validates interactions, binds material gaps to choice questions or stable rich-blank identities, and checks recording coverage and option-order constraints. Limits cover the complete resolved context, not just one material.

Detached copy remaps every editable identity and both ends of each gap binding, retains immutable media references and exact grading/text values, and rejects reused or invalid generated identities without mutating the source. Tests cover independent copies, repeated local gap names, changed catalog order, repeated audio nodes, separate member audio and mixed-scope rejection. The contract is structurally checked against generated types and shared content schemas.

This is a domain foundation, not enabled group authoring. W-07 still requires relational ownership/bindings, lifecycle locks, context-aware bank operations and the builder. W-08/09 must supply immutable graph snapshots and versioned delivery before group writes become available. No learner payload or existing test/section semantics changes in this checkpoint.

1.10 Implementation checkpoint β€” relational group foundation (W-07b)

Migrations 35–37 add independent bank/section group ownership, nullable ownership on existing questions, ordered section units, material/gap bindings and explicit recording scopes. Composite foreign keys prevent cross-group response and audio links. Stable blank targets survive transactional answer-row replacement; member ordinals support deferred reordering. Required reverse-reference indexes protect lifecycle query paths. Existing populated question indexes are built concurrently.

PostgreSQL integration checks cover ownership, kind/scope restrictions, guarded rollback and both full and intermediate migration round trips. New group rows block destructive schema rollback; no old content is reinterpreted or backfilled. This is storage groundwork only: repository lifecycle commands, legacy-writer barriers, bank/builder integration and W-08/09 snapshots/readers remain required before any group endpoint or write affordance is enabled.

1.11 Implementation checkpoint β€” atomic group storage (W-07c)

The internal group repository creates and reloads complete independent graphs in one transaction, including owned interactions and audit entries. Section-owned creation checks the enclosing draft revision and preserves standalone order when introducing ordered units. Invalid late media bindings roll back the entire graph. Bulk member reads validate relational media mirrors against the semantic content.

Existing question get, update, delete, duplicate, tagging, outline references, listing and facet paths hide or reject owned children. Active and archived groups protect shared and member media; creation and deletion contend on the same asset locks. Admin media-delete conflicts can name both published tests and groups. Database tests cover independent copies after source deletion, rollback, stale outline writes and both orders of the media create/delete race.

This repository is not connected to a group HTTP endpoint. Revision-safe group editing, bank/lifecycle commands, media usage UI, full draft readers and W-08/09 snapshot/delivery support are still required before enabling group authoring. No new migration or dependency is introduced beyond W-07b migrations 35–37.

1.12 Implementation checkpoint β€” revision-safe group lifecycle (W-07d)

Internal full-group edits now check the aggregate revision and enclosing test revision before changing any graph rows. The lock order is parent test, group, owned members by ID, then incoming media by ID. Two writers of the same revision produce one successful edit; the stale writer changes neither content nor audit. Member reordering and question-type/option-policy changes are atomic, including replacement of stable blank targets and material bindings.

Bank archive, restore and permanent deletion are revision checked; deletion requires archival and preserves audit history. Test-owned groups cannot use the bank lifecycle. Explicit section removal deletes the complete owned graph and compacts ordered units. Copy reads a coherent source revision and materializes fresh editable identities under the destination's revision and media locks. Source edits, archival and deletion cannot alter these independent copies.

Docker checks cover these operations, late-failure rollback, parent archival, concurrent edits and released media references. No endpoint is exposed yet: full draft readers, bank/builder UI and W-08/09 snapshots and delivery remain required. This checkpoint adds no migration, dependency or learner payload.

1.13 Implementation checkpoint β€” full-group publication (W-08a)

Migrations 38–39 add independent frozen groups, ordered units, membership, materials, stable gap bindings, explicit recordings and relational asset mirrors. No historical rows are backfilled. Composite keys keep frozen targets within the same section/group; the app role cannot update these new snapshot tables. Guarded rollback refuses stored graph data and preserves the concurrent index when returning to the intermediate migration.

Publication resolves ordered draft units, locks all context roots and referenced questions before reading their content/keys, validates nonempty complete groups and freezes the entire graph in one transaction. All question and group assets are locked together in stable order. Empty groups report an anchored publish violation even when their section contains valid standalone questions. Media reference queries protect frozen group files after the draft source is removed. Version listening counts include each member covered by a shared recording once.

PostgreSQL checks verify immutable content/keys/bindings/policy after source edits and removal, distinct recording identities for two groups using the same file, aggregate totals, media protection, privileges and migration round trips. New group authoring is still unavailable: group-aware preview/restore/duplicate, complete draft/UI readers, and W-09 delivery/shared-playback remain required. This is the publication foundation, not completion of W-08 or a release gate.

1.14 Implementation checkpoint β€” complete graph restoration/copy (W-08b)

The immutable group reader resolves teacher-only keys, frozen materials, stable gap targets and recording policy under its parent version lock, then validates the complete graph. It is an internal authoring reader, never a learner response. Restoration copies frozen groups into independent draft groups, remaps both ends of cloze links, preserves mixed unit order and replaces all previous owned context. A failed restore rolls back cleanup and new rows together; repeated restoration does not strand old owned questions. The restored graph can publish again with the same totals and grading content.

Draft duplication locks the source and its question references, independently copies every group and retains existing standalone bank-reference semantics. Assets needed by copied groups are locked together before materialization. Permanent deletion of an unreferenced archived test clears its owned draft graph without altering independent copies or append-only audit history.

Docker checks cover frozen reads, failed and repeated restore, fresh identities, cloze remapping, mixed-order duplication, source-test deletion and republication. No migration, dependency or API shape is added in this checkpoint. Grouped preview, complete draft summaries/outline/UI readers and W-09 delivery remain prerequisites before new group authoring becomes available.

1.15 Implementation checkpoint β€” safe grouped preview (W-08c)

Published preview now reads sections, ordered frozen members/materials/gap targets, recording policies and bound assets under the version lock. Its separate DTO and SQL omit answer keys and recording transcripts. The default follows current_version, including when the teacher selects an older version; explicit version selection remains available. The API returns additive section/group fields and signs only protected relational asset bindings.

The preview places shared material once before its member questions, renders rich tables, resolves cloze links to accessible question anchors and shows bound images and audio. A desktop/320px phone switch inspects the same renderer inside the supported 768px-or-wider admin shell. Preview playback never calls attempt ledgers; expired audio can refresh the preview's signed URLs. Unbound assets remain explicit unavailable states and never produce an asset request.

Docker integration checks cover frozen identity, cloze links, source revision isolation and default/explicit/missing versions. HTTP and recursive schema checks cover the learner-safe boundary; component/browser checks cover media allowlists, keyboard navigation, legacy flat papers and preview widths. No migration is needed. Complete draft summaries/outline, bank/builder UI and W-09 learner delivery remain required before group authoring can be enabled.

1.16 Implementation checkpoint β€” grouped dealing rules (W-09a)

Attempt question reads now include frozen group identity, member ordinal and the fixed-option dependency policy. Seeded dealing ranks complete groups and standalone questions inside each section; member order does not depend on SQL row arrival. Standalone ranks retain the historical salt/identity, so legacy papers keep exactly their previous seeded order. Option dealing preserves fixed-label members and does not mutate the loaded paper while shuffling unrelated choices.

Assignment create/update rejects shuffleOptions with the existing field-validation response when the selected version contains fixed-label group members. It checks the frozen selected version, including draft saves, before assignment/target/audit writes. A different version without this dependency is unaffected.

Property checks cover seed variation, row permutation, section boundaries, member contiguity/order, fixed options and input immutability. Docker checks cover frozen metadata reads and create/update rollback, including version-specific validation. Attempt/result/group payloads, shared playback counters and learner/context UI remain W-09 work; this checkpoint does not enable group authoring or shared audio.

1.17 Implementation checkpoint β€” safe attempt context (W-09b)

Start/resume/read attempt payloads now include optional additive groups using the existing safe preview projection. The tests module reads one frozen version under a share lock; the attempts application invokes that query only after authorizing the attempt. Frozen question membership detects when the context reader is required, and an unconfigured dependency reports 501 instead of dropping the materials. Historical standalone papers continue without the new dependency.

The shared transport projection retains stable question/gap/recording identities and omits grading data and transcripts. Every asset is authorized for the current student before metadata and a signed URL are returned. Media reachability includes relational group bindings only on versions the student has an attempt on, retaining the existing legacy-question path. Assignment targeting and unrelated versions grant no access.

Docker tests cover start, reload, takeover, preserved membership/material/policy, secret omission, missing dependencies/versions and cross-student/version media denial. Public transport tests cover both session endpoints and ensure authorization failure returns no partial payload. Existing recursive schema checks cover the new response branch. No migration or dependency is added. Shared recording accounting, learner/result/review UI, explicit delivery versioning and group authoring remain gated pending subsequent slices.

1.18 Implementation checkpoint β€” shared playback ledger (W-09c)

Migration 00040_create_group_audio_plays.sql adds attempt/recording counters and append-only per-gesture receipts. The new shared-play endpoint locks the attempt using existing writable-session rules, verifies its version-bound recording and material, and atomically increments once, records the receipt and appends a server timeline event. Retries return the current count; reusing a gesture ID on another recording returns a conflict. No limit rejects additional listening.

Session payloads expose counters by frozen recording identity. Two groups using one asset have independent allowances; reload/takeover retain counts; a new attempt has fresh counters. Teacher monitor and timeline totals include shared excess plays. Historical per-question playback and client event sequence keys are unchanged.

Docker tests cover concurrent identical and distinct gestures, cross-group and cross-attempt scope, foreign/version/session/closed/deadline rejection, reload, takeover, teacher totals and rollback of both counter and receipt if event writing fails. Migration checks cover up/down/up, schema equality, relational constraints, append-only privileges and refusal to drop a populated ledger. The shared browser player/retry queue, result/review context and explicit delivery-version marker remain pending; group authoring is not enabled by this server slice.

1.19 Implementation checkpoint β€” frozen delivery algorithm (W-09d)

Migration 00041_version_test_delivery.sql adds a constrained marker on the immutable version root. Historical standalone rows retain section_v1; only existing prerelease grouped snapshots are classified group_v1. New publisher writes explicitly use group_v1. The old per-section question/options order is preserved for every standalone paper. Attempts and result readers use the attempt's frozen version ID, independently of the current test default. Unsupported markers and grouped metadata under the legacy algorithm fail closed.

Docker tests cover empty up/down/up, metadata classification, refusal to discard a used group marker, new publication, reload/takeover/result order, and 100 seeds with all shuffle-switch combinations. No API shape or learner rollout changes. Shared player/retry queue and result/review context remain in progress.

1.20 Implementation checkpoint β€” learner shared reader (W-09e)

The attempt engine reuses the learner-safe material renderer from preview. Wide content areas show material and answer columns; phones offer remembered collapse state while keeping audio controls available. One recording player survives navigation between children. Gap buttons target the displayed question or its stable rich-blank input; navigation preserves pending answers. Group rendering is memoized so typing an answer does not revalidate the whole passage.

Each play gesture has a UUID persisted separately with learner/attempt identity and the attempt deadline. The single-flight retry queue keeps that UUID after network loss, reload or takeover. Optimistic counts use a minimum acknowledged position, rather than adding pending entries to a server total that may already include them. Independent recording IDs retain independent counts for the same asset. Stale responses cannot affect a new session; confirmed counts do not regress on a stale refetch. Logout clears local telemetry. Server-confirmed extensions update recovery's deadline. Invalid/expired entries are discarded.

Playback remains synchronous with the click, including over-limit playback. Network accounting does not gate playback. Submission gives pending telemetry a bounded three-second flush and proceeds with answer submission if unavailable; closed/expired sessions cannot add late listening evidence. The queue is bounded recovery, not a promise to reconstruct all playback after an offline close. A pending-sync label and retry action remain separate from answer save status.

Validation includes serialized gestures, ambiguous-response replay, session change, account isolation, expiry, stale totals, same-file independent scopes, non-blocking submission, synchronous playback, player recovery and stable blank focus. Production-build Chromium checks exercise 320px and desktop layouts, keyboard gap navigation, unchanged audio elements and reload after a committed play whose response was lost. Screenshot review confirms local table wrapping and side-by-side desktop layout. Native Safari/mobile QA and full result/review context remain required before group authoring is enabled.

1.21 Implementation checkpoint β€” shared review context (W-09f)

Submitted learner results and teacher paper/question review read frozen shared materials through the existing tests port after authorization. Learner transcript queries select only recordings explicitly released after submission; score, key and explanation flags never override this decision. Teacher paper review includes all shared transcripts and the attempt's confirmed counters. Cross-attempt question grading includes only the selected group and omits misleading aggregate counters. Signed asset delivery uses the existing ownership checks. A missing group reader fails closed; flat historical papers keep the existing path and payload.

One shared review renderer serves results and both grading modes. Filters retain material for visible members; gap navigation restores all results and focuses the correct child. Teacher member navigation retains the player. Transcripts use a keyboard-operable disclosure, and review playback does not record new gestures. The learner bundle still excludes admin code. No schema migration or dependency is added.

Validation covers all eight score/key/explanation combinations, released/private transcripts, outsider access, active-attempt refusal, authorized asset resolution, independent recording totals and question-scoped review. Contract leak guards now also reject plural transcript maps on active learner payloads. Production Chromium checks cover results at 320/1440px and both grading modes at 768/1440px, including keyboard gap focus, real audio playback without new accounting calls, filters and horizontal overflow. Screenshots were reviewed. Group authoring/draft summaries, assignment listening summaries, native mobile QA and the import workflow remain open; this checkpoint does not enable group authoring or establish pilot quality.

1.22 Implementation checkpoint β€” group summary/lifecycle readiness

Draft list/detail totals and tag queries now include owned group questions and exclude independent bank groups. Listening counts use one count per member with its own or shared recording, matching published version summaries. The legacy question-only outline writer refuses grouped drafts before any metadata/structure change, preserving empty as well as populated groups. Metadata updates still work. The complete group-aware draft reader/editor remains required before public writes.

Archived test restoration/default-version changes now return an explicit conflict before touching the draft for both legacy and grouped versions. The history UI explains the need to restore the parent and disables those actions. Unused, non-current version deletion remains available while archived. Tests pin rejection atomicity and successful whole-group restoration after unarchiving.

Learner introductions include shared audio and transcript policies from the assigned version, with the minimum finite cap across shared and individual scopes. The copy states that each recording has its own limit and that shared questions share one allowance. Newer default versions do not change existing assignments. Docker tests cover mixed/independent groups, filtering, summary parity, immutable version selection and archived lifecycle; HTTP tests assert actionable conflict codes. No migration or dependency is added.

1.23 Implementation checkpoint β€” group authoring API (W-07e)

Seven teacher-only operations expose complete independent group graphs, bank summaries, copy, archive/restore and deletion. Client-generated identities cannot replace existing content; duplicates return a conflict. Group changes check the observed aggregate revision; section-owned writes also require the enclosing test revision. Bank list search is accent-insensitive and escapes literal wildcards; section-owned copies never appear as independent bank entries.

Draft sections now expose optional ordered units containing standalone questions and owned groups; questionIds remains the legacy standalone projection. Group and test detail reads lock the enclosing test before its group, matching writer order and returning coherent revision/outline data. Legacy question-only outline writes continue to refuse grouped drafts. Full mixed-outline writing and the builder/bank editing UI must ship before this stacked milestone is released.

The transport accepts at most 4 MiB for complete-group creation/replacement, before JSON buffering/validation, with the existing 1 MiB default elsewhere. Schema and aggregate limits remain cumulative. Authorized assets are resolved only from the saved graph; temporarily unavailable metadata/signed URLs are returned as explicit unavailableAssetIds, so a successful committed edit is not reported as a failed save. No source-document links are exposed. Typed web clients use the generated contract. No migration, dependency, external processing or infrastructure is added.

Docker checks cover complete-copy independence, lifecycle, stale revisions, accent/literal search, mixed draft reads, media protection and rollback. HTTP and contract checks cover teacher-only routes, known-length/chunked body budgets, recording policy/key round trips and saved-content acknowledgement during media outage. This is an authoring foundation, not import or pilot acceptance.

1.24 Implementation checkpoint β€” mixed outline writes (W-07f)

The test update contract accepts explicit outlineFormat: group_v1 with complete ordered units in every section. The legacy question projection must match the standalone units. Owned groups occur exactly once across the entire outline; omitting one, directly attaching an independent/foreign group, or inserting a member outside its aggregate fails without changing metadata or structure. Group removal uses the dedicated operation, and cross-test reuse uses independent copy.

One transaction locks the test and its owned groups before standalone questions, creates destination sections, moves complete groups, writes both projections and removes empty obsolete sections. Only moved groups advance their aggregate revision; all successful outline edits advance the enclosing test revision. Archived tests must be restored first. Empty groups remain visible and cannot be silently lost. Legacy writers still refuse grouped drafts; after explicit removal of the final group, they clear obsolete unit rows before replacing the old projection.

Docker integration checks use separate committed transactions for the stale-write case, matching real HTTP saves rather than sharing a test transaction's fixed now(). They cover movement into new/empty sections, preservation of empty groups, publication order, foreign/missing/detached-member rollback and legacy cleanup. Domain, transport and contract checks pin format/projection validation and explicit archive conflicts. Typed web helpers retain old writers and prepare mixed writes; the editor integration is still outstanding. No migration or dependency is added.

1.25 Implementation checkpoint β€” group bank editor and local recovery (W-07g)

The independent group bank is exposed under /admin/question-bank/groups, with search, URL/session filter retention, newest-first rows, an inline and menu copy action, a recent-copy indicator and confirmed bulk archive/restore/permanent removal. Creation opens the editor; duplication leaves the teacher on the list. The editor preserves the complete graph and presents one active member/material editor, keyboard ordering controls, stable gap targets, HTTPS links, authorized image/audio picking and upload, shared recording policy/transcripts and learner preview at desktop/phone widths. Historical Markdown remains editable. Material preview retains the active editor's undo history. No learner preview includes answer keys, explanations or transcripts.

Serialized autosave uses the group revision and preserves edits made while a request is in flight. Route exit flushes or explicitly keeps a confirmed local draft. The IndexedDB outbox is account/item scoped, expires within seven days from unsent creation and stores content/revision only, without files or signed URLs. Storage failures remain visible. Logout atomically clears records and fences old tabs; a newer editor of the same item fences older local writers. Restoring a stale draft retains its edits and offers an independently remapped bank copy, without replacing the current server graph. Import-review integration remains.

The real browser-to-Docker check exposed the first PUT endpoint being absent from the CORS preflight method list. PUT is now allowed for exactly the configured origins; regression tests retain the untrusted-origin boundary. The field primitive is vendored from the existing shadcn registry style using the repository's own utility imports. No dependency or migration is added. Mixed builder integration, context-aware bank insertion, import processing/review/commit and pilot gates remain outstanding; this checkpoint does not claim general import availability.

1.26 Implementation checkpoint β€” mixed builder authoring (W-07h)

The regular test builder displays section β†’ group β†’ member structure alongside standalone questions. Both pointer and keyboard movement treat the complete group as one unit, including an empty group and an empty destination section. Member ordering, bindings and shared media remain inside the complete-group composer. Client section identities survive acknowledgement, so a new section can be renamed, reordered and used as a group destination without being recreated on every save.

Group content and outline operations share a serial write queue and the latest acknowledged enclosing-test revision. Own section moves advance the active group's revision without replacing its newer local content; external writes still conflict. Cached group reads are cancelled before writes to avoid overwriting acknowledgements. The independent-bank recovery gate/outbox is shared with section-owned editing. Explicit route exit confirms local group persistence; save-and-leave, preview and publication flush content and outline. Standalone/title/outline local recovery is not included in this checkpoint. Group publication findings open the owned member.

The bank picker copies the complete selected group into a chosen persisted section. Saving a bank copy retains the builder and shows a confirmation/link. Group removal uses the explicit whole-graph operation; removing a section processes its groups before removing the section, reflecting completed removals if a later step fails. Learner preview projects all mixed units in order without teacher-only keys or transcripts. The embedded composer uses a compact content selector when its actual available width is narrow; desktop retains the material/member navigation.

No migration, API contract change or dependency is added. Docker/browser checks cover new section identity, concurrent editing/movement, complete material preview, bank copy/insertion, independent deletion and publication. The processing queue, import extraction/review/commit and acceptance gates remain subsequent work.

1.27 Native private source intake (W-10a)

The imports module implements teacher-only create/history/get, native DOCX upload and authorized original download. 00042_create_word_import_sources.sql adds imports, durable source reservations, immutable source sets and their role mappings. The source bucket is separately configured and disabled by default; staging uses a private disk directory. Legacy DOC conversion is deliberately not enabled yet.

Create identities are actor-scoped. Upload identities are import-scoped and pinned to role, expected revision, filename, format, byte count and SHA-256. Reservation commits before object storage, so a lost Put response or failed completion never creates an untracked object. Retries of identical pending bytes may re-Put the same key; completed originals are not rewritten. Completion locks the parent, advances one revision and copies the unchanged companion into the new source set. A stale completion leaves its reservation visible and counted, without attaching its bytes. Cancelled/non-intake states reject new source writes. Future cleanup must coordinate with in-flight upload writers; no automatic deletion is introduced here.

Conservative configurable development limits are 10 active imports per actor, 100 per installation, 32 retained source reservations per import, 256 MiB per actor and 1 GiB installation-wide. These are not approved production capacity promises. Quota reservation uses one short advisory transaction lock before the parent row; no conversion, parsing or storage I/O holds a database lock. Intake permits one expanded inspection per API process, with 25 MiB source, bounded XML/ZIP work, a 15-second inspection budget and a two-minute intake deadline. Multipart wire overhead is capped at 128 KiB beyond the source cap.

The request validator now preserves multipart parameter checks without buffering file bodies. Authentication/role middleware remains ahead of validation; the validator's duplicate security body snapshot is disabled only for streaming routes. Native originals and keys never enter media_assets. Downloads resolve the source inside its parent import and use a 60-second no-store attachment URL. Tests cover student denial, cross-parent lookup, lost responses, changed replay input, concurrent completion/quota races, immutable history, unsafe packages, private MinIO storage and migration reversal. The TypeScript API adapter shares the generated contract.

Remaining W-10/11+ work includes legacy conversion, durable processing, source replacement after review, upload-history UI, cleanup/retention, review and commit. This checkpoint does not expose the unfinished workflow to teachers or claim GA.

1.28 Durable queue foundation (W-11a)

00043_create_word_import_runs.sql adds source-pinned processing requests and append-only lifecycle events. Scheduling checks the import revision, requires a completed exam source, preserves request identity on retries and prevents multiple active runs. A failed import can schedule a new run without rewriting old results.

Claims use a short transaction-level advisory capacity lock followed by import and run row locks. Candidate selection skips locked parents and rechecks eligibility under the parent lock. Heartbeats share the capacity lock, preventing an expiring lease renewal from racing a new allocation. Global and actor limits count live leases. No source download, parsing or provider call holds these locks.

The runner heartbeats during context-aware processing, stops on lost claims and writes only with a current source/worker/fence/lease. Attempts and retry delays are bounded; expired final attempts become failed even when no new job can be claimed. Cancellation competes with completion under the same parent lock. Successful runs store at most 8 MiB of private object JSON and enter review; schema semantics remain the processor's responsibility. Terminal results and operational events are immutable. Stage events, retry causes, actor actions and lease recovery remain inspectable; logs/observers receive identifiers, timing and allowlisted codes, never raw errors.

Docker tests use the app role and exercise concurrent cancellation/completion, capacity, retry exhaustion, append-only privileges, source preservation, stale writes, and a subprocess SIGKILL/reclaim. Runner tests cover heartbeat/progress failure, shutdown, invalid output and late completion; the race detector passes. Migration reversal and the existing module checks run in the dedicated test database.

W-11 remains open for concrete pipeline/supervisor wiring and operational metrics exposure alongside W-12–16. No enqueue endpoint or teacher processing screen is exposed before there is a real processor. No external provider or retention policy is enabled by this checkpoint.

1.29 Ordered source extraction (W-12a)

The native extractor now emits versioned, source-bound blocks in actual XML order, with structural ancestry, per-fragment Unicode offsets, original numbering and supported inherited mark evidence. Raw fields, hidden/revised text, alternate branches, equations and objects remain inspectable and require review; none is implicitly accepted as learner content. Nested table grids have independent cell coordinates and supported vertical merge origins. Ambiguous/duplicate/overflowing properties remain unresolved rather than being guessed.

Embedded static PNG/JPEG can be decoded with input/pixel/dimension/output limits and re-encoded without private metadata. Animated or EXIF-bearing images and unsupported formats require a later explicit normalization decision. Assets stay private until a reviewed learner placement and authorized media binding exist. The word-extract CLI produces counts by default and optional new 0600 evidence files. It neither contacts a provider nor writes assessment records.

Synthetic coverage includes mixed table/paragraph order, nested/merged cells, numbering, Unicode spans, source replacement identity, fields across paragraphs, inline revisions, alternate branches and safe raster handling. A bounded fuzz run checks block identity and ancestry. An independent local XML walk matched every one of 13,489 fragments in the eight authorized DOCX files. Text coverage is not semantic accuracy; reviewed question/key outputs and wider source families are still needed. Private evidence can be much larger than candidate JSON and belongs in separately stored/paged source artifacts, not the run result envelope.

This checkpoint implements extraction building blocks, not the complete W-12 quality gate. Conditional styles, source rendition, durable artifact integration, recognition/reconciliation, review and atomic commit remain subsequent work.

1.30 Isolated normalization and rendition (W-13a)

A separately built LibreOffice/Python UNO/Poppler image converts binary DOC into native DOCX and renders private PDF/PNG source pages. The Go adapter supplies an immutable local image ID, fixed safe load policy, isolated disk directories, network/capability restrictions and CPU/memory/process/file limits. It admits one physical conversion container per Docker host, including containers surviving a worker crash; a second worker cannot kill the first worker's slot. Completed owned slots can be reclaimed. Host cancellation and an independent container deadline bound work. A disk watchdog stops oversized jobs without using host tmpfs.

Source originals are never modified. Converted DOCX passes native inspection; page images and output paths are checked before artifact access. The manifest records source/image/renderer identities and checksums. Layout review is always required; legacy conversion adds an explicit normalization-review finding. This is not a promise that desktop rendering or every legacy object is equivalent.

Docker tests exercise actual DOC round-trip with text/underline preservation, password-protected failure, native rendition, cancellation/timeout, per-process and global physical slots, orphan completion/reclaim, disk limits and runtime network/mount/cgroup restrictions. A local real DOCX produced 33 private source pages; sampled pages retain teacher annotations in the source view. That is not recognition or learner-content acceptance. Sources and renderings remain outside Git.

The image/tool/adapter are internal building blocks, with no new public route, provider transfer or production activation. Durable artifact storage, processing wiring, detailed loss reconciliation, real legacy-family goldens and W-21 operator cleanup/capacity gates remain open. Dependency reasons and the boundary are in the converter runtime instructions.

1.31 Durable private stage artifacts (W-13b)

Each stage reserves its complete immutable file plan before object storage. Source/run/role/claim ownership is relational; acknowledgements and completion require a live fenced claim. Whole-set publication waits for every file, so a partially uploaded rendition cannot appear complete. Pending bytes and sets count against bounded actor/global quotas. Complete evidence is reusable after takeover or explicit retry only for the same source, pipeline and component/configuration. Incomplete old attempts receive separate keys and cannot mutate successor results.

The private S3 adapter verifies upload checksums, uses create-only writes and reconciles exact lost-response retries against stored identity. Processor reads verify size and digest into disk-backed staging before parsing. Normalized DOCX, source pages, images, blocks, candidates and validation files remain private; none automatically becomes learner media. No retention policy is enabled.

Docker verification covers incomplete publication, cancellation/lease fencing, source ownership, immutable replay, concurrent quotas, private bytes, lost storage responses and staged-read tampering. Public source browsing, concrete processing, recognition, review and commit remain subsequent work.

1.32 Conservative source-linked recognition (W-14a)

The private candidate schema separates printed labels, stable local question/option IDs, answer states, provenance, findings and exact source coverage. The local rules recognizer handles manual/automatic numbering, labeled choices on separate or shared lines, Unicode/nonbreaking Word spaces, inferred continuation paragraphs, explicit inline/final keys and unambiguous horizontal/vertical key-table pairs. Paper and section scopes prevent restarted numbers from silently selecting another answer. Contradictions and dangling labels remain blocking findings. Teacher-confirmed whole option bold/underline conventions are explicit inputs; unspecified formatting is never evidence of correctness. Key-only marks stay out of candidate learner prose.

A bounded adapter retains raw extraction separately, projects supported semantic marks and flags private/revised/field/layout-dependent branches. Every meaningful block receives a coverage entry; partial text assignment cannot count as full coverage. Rules use bounded source/candidate sizes, question/option counts and indexed answer lookup. No source text is deduplicated. The CLI defaults to counts; optional candidate output is a new private 0600 file, with no external services.

Synthetic tests cover Vietnamese offsets, explicit conflicts, repeated labels, collected papers, nonbreaking spaces, private text, generated labels and ambiguous table geometry. Fuzzing exercises bounded recognition and source-range invariants. The eight local DOCX files expose additional non-choice/group cases; running them is a compatibility exercise, not a reviewed accuracy or pilot result. Files and candidate outputs remain outside Git.

W-14 remains open for grouped cloze/material attachment, typed/written key mapping, saved profile persistence and corpus-reviewed reconciliation. W-15 provider policy, W-16 full validation, durable processing and review/commit integration remain open.

1.33 Durable processing assembly (W-11b)

A standalone cmd/import-worker now joins immutable source sets, private object verification, Docker rendition, native OOXML extraction and the deterministic recognizer. It uses the application database role and private storage credentials; API signing/Google credentials are not required. The API never starts Docker. The worker polls serially, obeys database actor/global lease limits, heartbeats, job deadlines and cancellation, and records safe IDs/codes/timing only.

Completed normalization and extraction sets are reusable after a retry. Version identities include the converter image or extractor projection and normalized source identity; candidate lineage includes every contributing source stage. Original DOCX coordinates remain original; a normalized legacy source would use its immutable artifact identity. Private reads verify recorded length and SHA-256 before parsing. An integrity mismatch is a terminal error, not an outage retry.

Raw evidence is split into contiguous files of at most 100 blocks, with a separate inventory and recognition projection. Candidate JSON is a private artifact; word-run-result-v1 contains only source lineage and artifact-set IDs. Completion still requires teacher review and never writes questions or assessments.

Validation: all backend unit tests and lint pass. PostgreSQL/MinIO with the real Docker converter exercise an exam/key pair, interrupted extraction, reuse of both completed renditions and successful review handoff. Adapter tests reconstruct the complete raw block sequence and verify every artifact checksum. Worker config, serial draining, cancellation and safe error logging are covered. No new dependency or migration is introduced. See worker operation.

Public queue/progress controls, full W-16 validation, review, commit, retention and production capacity/rollout gates remain open. This internal worker does not enable public legacy intake, external AI processing or production activation.

1.34 Takeover checkpoint β€” corpus recognition, review and commit (2026-09-25)

The stack above (W-02 to W-14a, W-11b) was reviewed area by area with an adversarial second pass. Its package safety, fenced queue and converter isolation were kept; every confirmed finding was fixed on this branch, except the items listed as still open at the end of this section. Against the supplied papers the rules-v1 recognizer produced about 1,300 findings for a 31-question paper, read one section and could not separate the papers of a combined key, so it was replaced rather than tuned.

Recognition (rules-v2). Evidence becomes logical lines (table rows joined by tab); a state machine builds sections, shared-passage groups and questions; keys are read per paper and per section. It covers Roman sections with continuous numbering, same-line options split only at the next expected letter, per-question instructions grouped into inferred sections, a question promoted to a group by its sub-labels, cloze gaps bound to choice questions or to blanks (open cloze), true/false tables, multi-blank keys and teacher-graded rewrites with the key as sample. Misprinted numbers are kept and flagged; letters for a question without options stay unsupported; coloured text inside a question is flagged as a possible answer leak. Measured on the 54 supplied papers (5 DOCX with their key, 49 DOCX rebuilt from the PDFs with the combined ĐÁP ÁN.docx): 2,562 of 2,626 questions receive their key automatically, 37 papers need no decision beyond source formatting; the rest are matching exercises (unsupported by design) or misprints in the source, each surfaced as a finding. PDF-derived files carry no underline, so pronunciation questions from them always ask for the underline. This is a regression harness, not a pilot result.

Review (W-16, W-18 core). One revisioned draft per import is written in the transaction that completes its run. A newer machine draft that arrives after teacher edits is kept aside, and the teacher adopts it explicitly (review/adopt, revision checked). Findings are derived on every read from the draft's content (bank validation is reused), joined with source notices; blockers need an edit, review items need an acknowledgement keyed to the content it judged. Malformed edits are 422.

Commit (W-20 core). core/adapters.ImportCommitter runs the tests and questions use cases on one transaction and records the commit in it. Fresh IDs make repeated imports independent; concurrent or replayed commits yield one test.

Operations. Processing is queued and cancelled from the API. DOCX is processed natively without a converter; .doc needs the converter and IMPORT_LEGACY_DOC. Heartbeat, shutdown, quota and retired-pipeline handling were corrected. A reprocess that fails or is stopped returns an import that already has a draft to needs_review with the draft intact (18-word-import-ux Β§7); the latest run keeps its status and error code so the review can say what happened. A failed import takes a replacement file in place. A question's own blanks are numbered 1, 2, … within the question, as the bank editor numbers them; passage gaps keep the source label.

Screens (WU-01 to WU-04, WU-06 core). History with search and status filter; upload with the server's limits, idempotent create β†’ upload β†’ process; a detail page per state (progress by real stage with elapsed time, failure with a specific action, stop or close with honest copy); and the review workspace: extracted source beside the rebuilt exam, finding filters with previous/next, provenance per field, one editable question at a time, autosave with a single save in flight, and a summary that creates the draft test. Deviations from 18-word-import-ux, to revisit with the design deck (it has no import boards yet): the summary is a dialog rather than the /confirm route; shared passages are edited in the test builder, not in review; there is no split, merge or move; learner preview is the builder's.

Verified in a browser against the real API, worker and MinIO on 2026-09-25: upload of a real .docx with a separate key, processing, review with exclusions of unsupported matching items, draft creation, builder, learner preview and publish; stopping a reprocess, a failed reprocess, and replacing the file of a failed import.

A version's shared group contexts now load in six batched queries, whatever the number of groups, instead of about five per group.

Still open: W-15 (assisted recognition), the rest of W-19 (a side-by-side compare of the kept draft and the reprocessed one; adoption replaces the draft whole), W-21/W-22 (capacity, retention, runbooks, pilot), and media/audio attachment in imports. Three low findings stay open because none of them shows on the supplied papers: element locators repeat the namespace URI in every segment, so the locator budget runs out near 80k elements rather than at the node limit; one group save parses each material about seven times; and a student's group context mints and reads each asset separately. The publish response's zero manualCount and audioCount predate this stack and are tracked in SekiroKenjii/Quizzivy#136.

1.35 Availability (SekiroKenjii/Quizzivy#138)

Whether import works depends on the deployment, and the product now says which case applies.

The contract. GET /teacher/imports/capabilities answers intakeEnabled (the private import store is configured) and processingEnabled. It answers on every deployment, including one without imports.

The processing switch. processingEnabled comes from IMPORT_PROCESSING_ENABLED, set on the API beside a worker. The API refuses to start with it but without import storage. The API cannot see a worker, so the operator states that one exists. Without the switch, 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 stays queued until a worker returns or the teacher cancels it.

The web.

  • The tests list links to the history when intake is on, and to a new import only when processing is on too.
  • /admin/imports/* sits under one route gate that explains when import is off.
  • With storage but no worker, finished imports stay reviewable and committable. The following are withdrawn, each with a calm note saying why:
    • the upload;
    • retry and reprocess;
    • "start over".
  • The run's error code stays visible even when retrying is withdrawn.
  • A queued import says it waits for processing to return. Its screen re-reads the capabilities every 30 seconds while it waits.
  • Starting a new import re-reads the capabilities before it creates anything.
  • A 503 from process re-reads the capabilities, so a screen opened before a redeploy corrects itself on the next click.

Production. Import stayed off in production until O-24 was decided; Β§1.40 turns it on. The steps are in docs/setup/word-import-worker.md Β§ Production. .doc is not part of that runbook. Its converter needs a Docker daemon, the production image has none, and hosting one means a separate privileged Machine.

1.36 A worker that lets the database sleep (SekiroKenjii/Quizzivy#143)

The worker used to poll every two seconds, which would have kept Neon's compute awake around the clock once deployed. It now claims until the queue is empty, then sleeps until one of three things happens:

  • A wake signal. The API sends it after queueing a run (IMPORT_WORKER_WAKE_URL β†’ the worker's POST /wake on IMPORT_WORKER_WAKE_ADDR). Signals coalesce, and a failed send is retried briefly. The sender outlives the API's HTTP drain. Reading a queued import or a history page that shows one runs the Nudge command, so a lost signal heals while a teacher watches. The query itself stays free of side effects.
  • Work falling due. The queue's NextDue mirrors Claim's filters, so a run Claim would never take cannot keep it polling.
  • IMPORT_WORKER_IDLE_POLL. The safety-net timeout, 1h by default and 6h in production.

It never polls sooner than every two seconds, not even when woken. That is the wake listener's rate limit.

1.37 An import store that works on R2

Cloudflare's S3 matrix marks full-object SHA-256 checksums unsupported. The import store's create-only writes sent exactly that, so they would have failed on R2 while passing on MinIO. storage.PutImmutable now works like this:

  • It checks the size and SHA-256 before sending anything.
  • It sends Content-MD5 with If-None-Match: *.
  • It stores the digest as object metadata, which a lost-response retry compares.

The worker's SHA-256 check on every read is unchanged. make verify-r2-imports (cmd/verify-import-storage) drives this code against the real bucket.

1.38 Retention (D-08)

Thuong approved the policy on 2026-09-25:

  • After commit: original files, artifacts and the review draft are kept 30 days.
  • After cancellation: kept 7 days.
  • Idle imports: an import waiting on a teacher that nobody has touched for 60 days is closed, and its files are removed at once.
  • What stays: history rows (title, status, actors, times, source filenames).

How it runs. worker.Sweeper runs in the API as jobs.SweepImportFiles, at start-up and then every 24 hours, and an hour after a failed sweep. The API exists wherever import storage does, including a review-only deployment with no worker. Each sweep has a five-minute budget:

  • It closes idle imports in batches of 50 until none are left.
  • It walks the due imports in (updated_at, id) order with a cursor, so an import that fails is passed over and cannot hold back the rest.
  • It logs counts only, and at Warn when anything failed.

For each import it does four things, in order:

  1. Deletes every object (storage.Client.Delete treats a missing key as done).
  2. Deletes the draft, the only place exam content lives outside the test.
  3. Sets files_removed_at.
  4. Audits import.files_removed.

A failure leaves the import unmarked for the next pass.

Idle closure. It is audited as import.closed_idle and sets closed_idle. That makes the import due at once, even if a pass is interrupted before its files go. "Untouched" is the import's updated_at. A source reservation (so an upload in progress), a draft save, an adoption and a commit all touch it while holding the row lock. So a sweep can neither close an import mid-upload nor delete a draft a teacher has just saved.

Late uploads. An upload whose object lands after its import closed deletes that object when Finish refuses it.

Schema. Migrations 00050 and 00051 (20-data-model.md):

  • A CHECK allows files_removed_at only on committed or cancelled imports. Those can never be processed again, so stage reuse never meets a missing object.
  • The application role gains DELETE on word_import_drafts only.

Readers.

  • The source download, the source view and the review answer 410 IMPORT_FILES_REMOVED.
  • WordImport.filesRemovedAt drives a calm note on the detail and review pages. Downloads and the review link disappear there.
  • ImportCapabilities.retention carries the three periods for the policy line on the upload page and the idle warning, so the copy never drifts from the code.

1.39 PDF sources

Thuong decided on 2026-09-25 that production takes PDF as well as .docx. The scope he accepted:

  • Text-layer PDFs only. A scan or photo has no text to read and fails with PDF_NO_TEXT. There is no OCR.
  • No formatting marks. Underline, bold and colour are not read from a PDF. Every PDF draft says so in an informational finding (PDF_MARKS_UNAVAILABLE). Questions that depend on underlines (pronunciation, "find the mistake") are marked by the teacher in review.
  • Keys come from text. An answer key must be a separate key file or explicit 1. A lines. Answers marked by colour or underline in the exam are not seen.

Reading. platform/pdftext runs PDFium compiled to WebAssembly inside wazero (github.com/klippa-app/go-pdfium):

  • The sandbox has no filesystem, no network, 256 MiB of linear memory and a one-minute deadline. A hostile PDF can at worst fail its own read, without Docker.
  • The deadline kills the instance. go-pdfium runs every call under its own context and Close waits for the running call, so closing alone let a PDF of nested forms run for hours; TestTheDeadlineStopsADocumentThatWouldKeepPDFiumBusy pins it. The kill is awaited before the pool closes, which also removes a data race on go-pdfium's instance map.
  • Each read gets a fresh sandbox and keeps only the compiled module. The first read in a process takes about 2.5 s to compile; later ones take about 0.1 s.
  • Limits: 60 pages and 2 Mi characters. Running out of sandbox memory is reported as PDF_INVALID, not PDF_TOO_LARGE.

It builds lines from PDFium's characters, not its rectangles, because rectangle text can repeat a neighbour's first character:

  • Characters that share a line band join in stream order, keeping PDFium's spaces.
  • A gap wider than twice the typical glyph height becomes a tab, which is how Word's option columns (A. …β‡₯B. …) and key tables reach the recognizer.

Evidence. adapters.PDFEvidence makes each line one paragraph block, with IDs like p2-l14 and version pdf-lines-v1. It keeps page furniture out of the exam, working inward from the two lines at the top and bottom of each page:

  • Page numbers (12, - 3 -, Trang 2/4, Page 2 of 4) whose number is the page's own, and whose total is the page count, are hidden as ANCILLARY_CONTENT_REQUIRES_REVIEW, an informational note.
  • Repeated lines are hidden when the same line sits at the same edge of another page. Two lines are the same when they differ only where each holds its own page number, so Trang 1/4 - MΓ£ đề 132 matches Trang 4/4 - MΓ£ đề 132 and TiαΊΏng Anh 3 matches itself on page 3. A page of one or two lines counts as both top and bottom. These are PDF_REPEATED_LINE_REQUIRES_REVIEW, which the recognizer keeps review-required although the lines are outside the exam: a repeated instruction may belong to it.
  • Never hidden: a line the recognizer reads as a question or a section (recognition.QuestionStart, recognition.SectionStart), so CΓ’u 2 (NB). at the top of page 2 stays a question.

An exam line that holds a second question label after a tab, followed by at least two words, is flagged PDF_COLUMNS_REQUIRE_REVIEW. That is what a two-column page turns into; it is flagged, not split, and the note suggests the Word file. The recognizer accepts pdf-lines-v1 beside ooxml-blocks-v1 and is otherwise unchanged.

No text. A PDF whose text layer is empty fails in the reader. One whose only text is furniture, or an exam with under 20 visible characters a page (a typed title over scanned pages), fails the same way with PDF_NO_TEXT.

Pipeline. A PDF is never normalized, with or without a converter. Extraction takes the source format, and ExtractionVersion(format) fences stage reuse per format. PipelineVersion is word-pipeline-v2, so a worker that predates PDF cannot claim a PDF run. A run left for v1 retires after ten minutes as before.

Intake. .pdf is accepted by its %PDF- signature within the first KiB. A password, a broken file and a scan are found by the worker in its sandbox, not by the API. limits.formats lists pdf. Migration 00052 widens the source format CHECK.

Failures a teacher can act on, all terminal: PDF_NO_TEXT, PDF_PROTECTED, PDF_INVALID, PDF_TOO_LARGE.

Corpus. The local corpus has 49 exam PDFs and a separate .docx key. It is never committed. Read with that key, the result was:

  • every PDF read, with 2,451 questions;
  • 2,379 answers known (97%), 65 in conflict and 7 unknown;
  • the conflicts come from matching sections, which the recognizer does not support, and from source typos such as C/ in/ in;
  • in every PDF, only the running header was hidden, and no line was flagged as two columns.

1.40 Production (O-24)

Thuong decided on 2026-09-25 to put import in front of real teachers, after the four changes it depended on: the worker wake (Β§1.36), the R2 import store (Β§1.37), retention (Β§1.38) and PDF (Β§1.39).

  • Image. The Dockerfile builds ./cmd/import-worker beside the API and copies it to /app/import-worker. The PDF sandbox is pure Go, so the image stays distroless and static. A local check ran the built image's worker against Postgres and MinIO: a real PDF reached review in about a second, at about 330 MiB resident.
  • Fly. fly.toml declares [processes] app and worker, scopes [http_service] to app, and gives the worker a 1 GB [[vm]]. The import settings are in [env]: bucket, work directory, the processing switch, the wake URL and listener on the private network, and a six-hour idle poll. No new secret is needed; the store reuses S3_*.
  • Guards. deployment_test.go boots both processes on fly.toml plus the documented secrets. It also pins the service scope and the worker's memory, and refuses import storage without the S3_* credentials.
  • Still open. D-07's capacity envelope. The quotas and the one-job, one-lease concurrency stay at their development defaults until measured with real use.
  • Release gate, met 2026-09-26. Thuong added quizzivy-imports to the R2 token. make verify-r2-imports then passed all eleven checks, and wrangler reported r2.dev access disabled, no custom domain and no CORS rule.

1.41 Pasted text sources (R4)

Decided on 2026-10-03 under Thuong's delegated authority, when the design deck drew "Paste a test": a teacher may paste an exam as text instead of uploading a file. Built in R4 as T-R4.55 (the source, intake, extractor and worker), T-R4.56 (recognition) and T-R4.57 (the page); 74-r4.md holds the contract, the migrations and their as-built notes. The scope Thuong accepted:

  • Plain text only. Every paste is stored and processed as plain text, whatever the clipboard held. The page is a textarea and nothing reads the clipboard's HTML, so no mark exists to read, as with a PDF (Β§1.39), and the deck's "formatting kept" labels do not ship (DG-12). Every pasted draft says so in an informational finding, TEXT_MARKS_UNAVAILABLE, as a PDF's says PDF_MARKS_UNAVAILABLE. Recognition never takes an answer from a mark, so no key is lost; a question that depends on an underline is marked by the teacher in review.
  • Keys come from text. An answer is a *, an answer line or a key line the teacher typed, or a separate key file. A pasted import may still take an answer-key file through uploadImportSource.

Intake. POST /teacher/imports/{id}/sources/text (pasteImportSource, content.tests.write) takes the closed JSON body {uploadId, expectedRevision, text}. It is its own operation because uploadImportSource streams a multipart file, so the contract validator would see neither the text nor its limit. A .txt file on uploadImportSource is still refused.

  • The contract refuses a text of more than 100,000 code points as sent (400 VALIDATION_FAILED) and one with no non-space character. The command (command.Paste) then composes the text to NFC, counts code points, and refuses a NUL or invalid UTF-8 (415 IMPORT_SOURCE_INVALID), a text of more than 100,000 code points after composing, or of more than 20,000 non-blank lines (413 IMPORT_SOURCE_TOO_LARGE). ImportLimits.pasteMaxCharacters is 100000; formats still lists file extensions only.
  • The text is stored as UTF-8 at the usual private key with the constant name pasted-text.txt, role exam, through the upload's own path (UploadHandler.handlePrepared): the reservation, the quotas (a paste counts as an import and its bytes as source bytes), the intake slot and the source revision are the upload's, and so are retention (Β§1.38) and the ownership rules. A replay with the same uploadId, revision and text returns the first receipt; a changed one is 409 IMPORT_CONFLICT. No rate-limit entry exists, as for uploadImportSource.
  • The source is ImportSource.format = text with characters, the code-point count after composing (migrations 00090 and 00091: the format CHECK admits text, and characters is 1 to 100000, set exactly when the format is text). "Download original" returns the stored .txt, and the source view returns the line blocks. The history search skips a text source's stored filename; the title and a companion file's name still match.
  • Nothing about a paste is a test without questions: a text with no question reaches review with the blocking NO_QUESTIONS finding.

Extractor and evidence. adapters.TextEvidence (import_text.go) is to a text source what PDFEvidence is to a PDF. It drops a leading byte-order mark, turns CRLF and CR into LF, composes to NFC and makes each non-blank line one paragraph block l{n}, n being the line's number in the composed text, with no spans. The evidence version is text-lines-v1, and a source of that format is never normalised or converted: normalizeSources passes it through and ExtractionVersion("text") fences stage reuse as text-lines-v1:projection-v1. The worker reads at most 400,000 bytes of it, and needs no converter, sandbox or Docker (docs/setup/word-import-worker.md). Recognition accepts ooxml-blocks-v1, pdf-lines-v1 and text-lines-v1.

Conventions. The recognizer learns the forms the paste page tells the teacher to type. They are gated on examRules{text: version == "text-lines-v1"} (text_rules.go), so a Word or PDF paper recognises as before, with one exception below:

  • * as the first or the last non-space character of an option marks it correct and is taken out of its content; several starred options of one question are one answer, and a * anywhere else is content.
  • An answer line under a question reads Answer: B, Ans: B, Answer - B, Key: B and ĐÑp Γ‘n: B.
  • Option letters may be lower-case, a bare 1: is a question label, a bare roman numeral on its own line is a part heading, a named part (Part A, BΓ i tαΊ­p 1) is a heading and never the paper's title, and a run of three or more underscores is a gap (dots and ellipses keep their threshold).
  • Answer: Not given gives a short answer the teacher fixes in review. True / False / Not given is not a question type until R6 (76-r6.md T-R6.13).
  • A one-line key (Answer key: 1-B 2-C, also Answers:, Key: and ĐÑp Γ‘n: before the pairs) is read as the key's entries when it holds at least two pairs, whose separator is -, :, . or ). This rule is read for every evidence version, Word and PDF too (#233, decided 2026-10-04): ĐÑ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. The other conventions are text, not formatting, so Β§1.32's rule stands: formatting is never evidence of correctness.

Versions. recognition.Version is rules-v3 and PipelineVersion word-pipeline-v3, so a worker that predates text sources never claims a text run. A run still queued under v2 at the deploy retires after ten minutes with PIPELINE_RETIRED and is retried, as at the PDF release.

The page. T-R4.57 builds the paste mode of the upload page. Its "Found so far" count is web/src/features/imports/paste.ts, which follows the server's text path, not the deck's WI_detect, so the page cannot contradict the review. api/testdata/pasted-text-counts.json holds both sides to the same numbers. Start processing stays disabled for an empty box, a text over the limit and a text with no numbered question. The screens are in 18-word-import-ux.md Β§4.1 and Β§12.

Measurements. What is measured, and what is not:

  • The fixture holds 120 cases (sections, questions, answered count and missing numbers): the deck's sample and the page's example, one or more cases for each rule above, and texts aimed at one rule each. A Go test builds each case's exam with TextEvidence, runs the recognizer and compares it with the review summary and its MISSING_ANSWER findings (TestPastedTextCountsUseRealTextEvidence, core/adapters/tests/import_paste_counts_test.go); the web unit test paste-scan.test.ts asserts the same numbers from paste.ts.
  • Mutation runs, each mutant killed by a test: T-R4.55's source behaviour 14 of 14; T-R4.56's rules and the text gate once each, the one-line key apart; T-R4.57's paste.ts 79 rules and the page and hook 24. These are source mutations, not proof against a real database.
  • The deck's sample is taken from intake to its draft in TestTextPipelineDeckSampleHasExactReviewCounts (text_counts_pipeline_test.go) and finds the same four numbers (3 sections, 10 questions, 8 answered, questions 4 and 7 missing).
  • Not measured here. The time from Start processing to the Ready card on a cold worker is T-R4.51's measurement (DG-16): at 10 s or less the footer takes the deck's "Processing takes a few seconds."; otherwise the file flow's sentence stays. And the local corpus run, which should report only the keys the one-line rule now reads in Word and PDF files, is not recorded in the repository; the corpus is never committed.

1.42 Read with AI (D4, T-D4.33)

Decided with the AI assistant (D19 to D22, 2026-10-10), which turned on cloud processing with data minimisation: a teacher who creates an import may ask for its scanned and photographed PDFs to be read by the AI assistant instead of refused (Β§1.39 "No text"). It is the one place the import sends anything outside Quizzivy, and Β§8's rule holds: the provider receives a bounded bundle and returns untrusted candidates, and the deterministic recognition, validation, review and transactional commit stay what decide what enters a test.

  • The switch. createWordImport.readWithAi (default false) is stored on the import (word_imports.read_with_ai, migration 00125) and cannot change. It is refused with 422 VALIDATION_FAILED (details.readWithAi) when the server has no ANTHROPIC_API_KEY or the caller lacks ai.use. A worker without the key refuses a scan as PDF_NO_TEXT, whatever its import asked.
  • What is read. Only a PDF that fails the text-layer check (Β§1.39): an empty layer, or under 20 visible characters a page. A .docx, a pasted text and a PDF with a text layer take exactly the path they took, with the switch on or off, send nothing, and spend nothing.
  • Pages. platform/pdftext draws every page in the same PDFium WebAssembly sandbox as the text reader: no filesystem, no network, 256 MiB, a three-minute deadline. A page is a JPEG of at most 1568 pixels on its longest side (the size a vision model takes without scaling), quality 85, at most 1 MiB. A file of more than 40 pages fails PDF_TOO_LONG_FOR_AI before anything is reserved or sent. Forty pages of 300 dpi scans took about 30 s and 322 MiB resident, forty 12-megapixel photographs about 33 s and 530 MiB (a 4-core container, one process doing nothing else); the worker's 1 GB machine holds either beside its 512 MiB Go target.
  • Credits. Read holds 60 credits a page (the import feature's ceiling) for the whole file before the first page is sent, so a file is never half read for want of credits: a shortfall is AI_CREDITS_EXHAUSTED and a paused teacher AI_PAUSED, with nothing sent and nothing held. The teacher who pays is the one who started the run. Pages go to the capable tier four at a time. The reservation ends once, in one ledger row for the file, with the summed usage: it settles what the finished pages billed and releases the rest, on success, on failure and on a cancelled context (which settles under a context that is not cancelled, so a stopped worker returns the credits).
  • What comes back. Each page's printed lines, in reading order, with a tab between cells that sit side by side. The model is asked to transcribe and nothing more: not to correct, solve or mark an answer, and to skip handwriting. The lines become pdf-lines-v1 evidence with the same ids (p2-l14), so page furniture, side-by-side questions, the recognizer, its source links, review and commit are the PDF text layer's. A line is cut to 2,000 characters, a page to 300 lines, control characters are dropped and the text is NFC. The answer is decoded against a schema that holds only readable and lines; the provider has no tool and writes no field of a draft.
  • A page the model cannot read (it says so, refuses, or answers something that fails the schema) is a finding on that page, not a gap: a hidden marker block p3-l1 carries AI_PAGE_UNREADABLE, a review-required UNSUPPORTED_DOCUMENT_OBJECT finding the teacher resolves. Every other page counts toward one informational AI_PAGE_READ finding, whose count is the pages read; that finding is the import's record of what left the system. A file with no readable page, or under 20 characters a page, fails AI_UNREADABLE.
  • Failures are terminal (the credits of a retried read would be spent again): a provider that stays down, rejects the request or outlasts the ten-minute read fails PROVIDER_UNAVAILABLE, after the finished pages are settled. The teacher chooses "Process again". An import that already read its scan keeps the extraction, so processing it again (another key paper) does not read it twice.
  • Nothing of a student. An import holds exam documents. A check that refuses a file whose metadata or first page names a student is not attempted: it would be a false sense of safety. The Sources pane says what is sent, and the teacher carries the responsibility for the file.
  • The worker loads ANTHROPIC_API_KEY and the tier settings as the API does, builds the credits application over its own pool, and raises its per-run timeout from 5 to 15 minutes while the assistant is on. PipelineVersion stays word-pipeline-v3: bumping it would fail every queued run at the deploy. A v0.10.x worker that claims an import made with the switch refuses a scan as PDF_NO_TEXT, so deploy the API and the worker together.

2. Current code and the actual gaps

Area Verified current behavior Required work
Questions questions/domain/question.go stores legacy Markdown plus additive semantic prose/options and five supported types Versioned semantic content for prompts, options, explanations and materials; keep historical text readers
Validation Shared question validation covers manual/internal writes and publication, including true/false cardinality and exact points Extend the same path to rich gap/group/asset graph invariants and import findings
Draft outline tests/domain/test.go has sections with question IDs; the UI currently calls these groups Separate section and actual shared-context group without reinterpreting old sections
Shared materials Instructions are a small text note in SectionInstructions.tsx First-class rich passage/table/image/audio material and group editing/delivery
Rendering Markdown.tsx sanitizes Markdown; QuestionBody.tsx and StudentPreview.tsx render options as text One allowlisted content renderer used by builder, preview, engine, review and results
Randomization attempts/domain/deal.go shuffles individual questions inside sections and all eligible options Versioned group-aware deal policy, fixed member order where required and explicit option-reference constraints
Audio Counters and audio-play requests are per version question Shared playback scope that survives group navigation, reload and takeover, with existing optimistic-play semantics
Lifecycle Duplicate/archive/permanent-delete, version restoration and current-version selection exist Extend all copying, reference queries, deletion locks and restoration to the complete new graph
Transactions platform/db.Context can bind a pool or transaction; publish/autosave use transactions A transaction-aware cross-module import materialization port, not sequential HTTP calls
Autosave useAutosave.ts serializes/flushes pending writes and exposes stale/save failure Reuse behavior with revision control, route-leave blocking and durable import edits; unmount alone cannot guarantee browser-crash recovery
Jobs core/jobs/prune.go schedules token cleanup in memory Persistent import queue, fenced worker claims, checkpoints, cancellation and recovery
Infrastructure Go API, PostgreSQL 18, private object storage; no Word converter or model runtime in package manifests Isolated bounded document worker and optional model adapter; no heavy jobs inside API requests

This is a domain/platform milestone with an import workflow, not an upload button added to the current question editor.

3. UX contract and evidence of success

Detailed screens, interaction states, responsive behavior and acceptance scripts are in 18-word-import-ux.md. The primary journey is:

  1. Choose files: exam, optional answer key, optional listening assets. Automatic recognition is the default; templates/profiles are optional conveniences.
  2. Process: real stages and persistent history; the teacher may leave safely.
  3. Review: linked source/result views, issue-first navigation, inline fixes, explicit missing answers and visible save state.
  4. Confirm: learner preview and a current validated summary; create one draft.
  5. Continue authoring: normal builder, with publication remaining separate.

The UI must answer: What was found? What needs my decision? Where is the source? Has my edit been saved? What happens next? Avoid confidence percentages and a large technical processing dashboard in the normal teacher flow.

Measure total teacher effort, including upload, processing wait, correction, preview and builder corrections. A visually attractive importer that transfers cleanup work to the builder does not pass. Compare matched document families with the same teacher's manual authoring baseline, controlling for order effects.

4. Proposed architecture and content contracts

4.1 Boundaries

Add modules/imports/{domain,application,repositories,http} and web/src/features/imports/. Imports owns sources, jobs, candidates, review, evidence, issue resolution and commit identity. It does not own grading or write other modules' tables directly. Tests owns assessment structure and snapshots; questions owns supported interactions/keys; media owns publishable assets; attempts owns delivery, playback counters and grading.

Keep document parsing/conversion and provider clients behind ports. Wire adapters in core/adapters and modules in core/wiring; extend architecture tests. A dedicated worker command can use the same Go module/deployment repository while running separately from the API. Do not introduce a broker or microservice framework unless queue/capacity measurements show a need.

The converter child process has no network, database or object-store credentials. The worker stages input/output through a controlled temporary directory and handles storage itself. The provider client is a distinct adapter with bounded network access. A private GPU inference endpoint is a possible deployment choice, not a requirement to run a model on the developer laptop.

4.2 Versioned content

The W-02 foundation defines an application-owned ContentDocument discriminated union (contract details):

  • legacy_markdown_v1: preserve the original string and existing rendering.
  • semantic_v1: typed blocks/runs, paragraphs, ordered/bullet lists, safe links, tables with header/read-order semantics, image/audio references, marks and stable gap nodes. Include bold, italic, underline, superscript and subscript.

Define node count, nesting, text/URL/table/image bounds in the contract. Unknown node/mark/attribute types produce a finding or a rejected write; they are not silently dropped. Raw Word HTML never becomes trusted content. Reject unsafe URL schemes and external-resource loads. Teacher answer annotations/evidence belong outside learner content, not in hidden CSS or data attributes.

Persist the small content AST as validated JSONB where appropriate, while keeping question options, keys, blank answers, memberships and media references relational. This does not overturn the existing decision to normalize assessment snapshots. Mirror asset references into relational bindings so deletion checks and FK locks do not depend on scanning arbitrary JSON. Maintain a deterministic plain-text projection for Vietnamese search; never index serialized markup as question text.

Evaluate a Tiptap/ProseMirror editor adapter against a second candidate before selection. The stored schema remains Quizzivy's, not a plugin's undocumented internal shape. Rich editor code stays admin-only; students load a small read-only renderer. Pin marks/tables/gaps, paste/undo/IME behavior, keyboard accessibility, Vietnamese input, license and bundle budgets in the editor spike.

4.3 Section, group, stimulus and bank reuse

Approved semantics with W-07a graph validation/copy foundations:

  • A section organizes the exam; old sections remain sections.
  • A group has ordered members, shared instructions and one or more stimulus bindings. Standalone questions remain possible without synthetic visible groups.
  • A stimulus is a passage, table, image, audio or composite content. Stable IDs link cloze gaps and question/blank targets, independently of printed numbering.
  • Approved by Thuong: test-owned editable groups/materials. Copying into another test or the bank creates an independent graph with all required members and materials. Source edits/deletion cannot change the copy. Immutable asset bytes may be reused through separately protected relational bindings.
  • Context-dependent bank questions must expose their required group/materials. The picker offers copy with context, copying all required members and remapping gaps/assets; a lone child cannot be inserted without its dependencies. Copies do not mutate the source bank item or source test. The exact dependency relations and behavior after source-test deletion are part of this contract gate, not a deferred cleanup issue.
  • Snapshot all materials, group memberships, ordering rules, semantic content and audio policies. The source import is never needed to render a published exam.

dealVersion/equivalent distinguishes legacy and group-aware delivery. Old attempts retain the existing algorithm, seeds, question IDs and answer binding. Approved D-03: when question shuffling is enabled, shuffle groups and standalone questions as units within a section; always preserve authored member order inside shared-context groups. Keep authored unit order when shuffling is disabled. Mark questions with semantic option-label references as non-shufflable unless references are represented structurally. Reject incompatible assignment configurations with a named group/question, rather than silently weakening them.

Approved D-03: a shared recording's play allowance belongs to an explicit versioned group-audio binding, not merely the underlying asset ID. All children share that allowance; different groups may use the same file independently. Existing per-question behavior remains readable; shared scopes do not grant new plays on child navigation, reload or takeover of the same attempt. Preserve synchronous user-gesture .play(), optimistic accounting and post-limit reporting. Do not silently change audio policy into hard network authorization before playback.

4.4 Import persistence and identity

Final names/DDL come after Β§4.3, following PostgreSQL 18 conventions and the Neon skill. Proposed responsibilities:

Relations Integrity and access expectations
Imports / source sets UUIDv7 identity, creator, current source/draft revision, status, resulting test; owned by the creator, scope.all reaching every import, with scope checks on every resource (T-R2.12f)
Sources / artifacts Role exam/key, checksum, detected MIME, bytes, immutable private key, lineage, source revision; originals and answer files never become student media
Runs / stage artifacts Fencing token, lease, attempt count, source/component versions, bounded cost/time, cancellation intent and outcome
Blocks / source references Stable IDs per source revision, part/paragraph/table-cell/run offsets, marks and coverage; rendered page coordinates only when actually available
Candidates / review revisions Immutable schema-versioned candidate; bounded reviewed graph with revision and digest; known/unknown answer is a union, not all-false flags
Issues / decisions Entity/field target, reason, evidence, severity, resolver, affected revision/digest; invalidate relevant decisions after substantive edits
Asset bindings Validated asset ID plus explicit learner placement and dependency; independently guarded access for source artifacts
Commit records One resulting test per import, approved revision/digest, idempotency key and outcome stored with the created graph
Profile revisions Declarative numbering/answer conventions, creator/scope and version; no executable regex/code supplied unchecked by users

Use timestamptz, exact numeric points and indexed foreign keys. Plan indexes around import history filters, runnable jobs, expired leases and source blocks. Do not partition prematurely. Enforce graph ownership with relational constraints where possible and full domain validation where not. Audit/events remain append-only; no new UPDATE/DELETE privileges on those tables.

Source coordinates do not survive a replacement by assumption: create a new revision and an explicit mapping/diff. Coverage is many-to-many when reuse is intentional; every meaningful block needs an assignment, metadata role or reasoned exclusion. Duplicate text is not sufficient evidence of duplicate questions.

4.5 Durable work, saving and committing

Lifecycle: awaiting_sources β†’ queued β†’ processing β†’ needs_review β†’ committing β†’ committed, with failed/cancelled branches. Store stages separately. A technically successful run always enters review. Cancelled imports remain inspectable until retention/deletion; retryable failures preserve completed artifacts and edits.

Queue claims use bounded PostgreSQL transactions, lease expiry and a monotonically changing fencing token. Late worker writes must match both source revision and claim token. Cancellation and commit contend on the import state, so only one can win. Do not hold a database transaction during parsing, conversion or provider calls.

Review saves use a single-flight queue plus expected revision. A 409 never auto-overwrites another tab's changes. A draft-level coordinator flushes before changing questions/routes, reprocessing, preview and commit. Saved means server acknowledged. For crashes with unsent edits, design a bounded per-user IndexedDB outbox with visible recovery/conflict handling. Thuong approved account-isolated local recovery with a maximum seven-day lifetime from unsent revision creation and clearing on logout; source/answer files are excluded. Enforce expiry before any read/replay, surface quota/storage failures, and never report local-only changes as server-saved. The group bank editor implements this outbox and recovery checks; import review still needs to integrate the same policy. Browser unload requests alone are not durability. Server-acknowledged edits must survive all browser failures.

Prepare immutable validated media before commit. Commit runs complete domain validation against the approved revision/digest, then creates bank content, test, groups/materials, bindings, audit entries and commit record in one transaction. Use transaction-bound application adapters; existing handlers that start isolated transactions are not sufficient. Take reference locks in one documented order on all participating insert/delete paths. A unique import-result constraint and idempotency key make double click, concurrent commit and lost response safe.

Same import + same commit identity returns the existing test; a mismatched digest conflicts. Intentional β€œcreate another exam” creates a new import identity. Reprocessing produces a separate candidate and a three-way comparison against the previous machine candidate and current manual draft. Teacher edits win by default; conflicts require per-field decisions and new validation.

5. API and screen inventory

Use the operations in source spec Β§10 as the minimum, not the final OpenAPI. All import operations live under /admin/; every nested artifact/run route must derive scope from its parent and check it. A UUID is not a capability token.

Resolve these contract gaps in W-02 before implementation:

Addition/detail Reason
Explicit source upload/finalization, byte/content validation and orphan handling Create-record success must not imply a complete file upload; retries must not duplicate source sets
List/get processing runs and cancellation result History and recovery must be observable rather than inferred from one status
Paginated block views and rendition manifest Large sources must not be downloaded/rendered as a single giant DOM
Candidate comparison and revision-guarded apply Reprocess must not overwrite manual work
Revision-scoped issue decisions and explicit exclusions Bulk confirmation must name the visible set, decision and target digest
Save/list/select/delete declarative profile revisions Saved profiles in the required journey need real persistence and scope
Artifact download authorization, expiry and asset binding operations Keys/source pages are private even when the teacher previews learner content
New group/stimulus, snapshot, delivery and shared-play contracts The existing tests/questions APIs do not describe the required assessment graph

Pin request/response size limits, 202 job identities, 409 stale/state conflicts, 422 content findings, 413 limits, 415 unsupported containers, quota responses, idempotency behavior and localized errors. Define ETag/expected revision on every mutation; retain both tokenized source IDs and human labels. Validation tokens bind revision, digest, validator version and referenced assets; commit rechecks.

History filters, pagination, selected issue/question/source view and panel widths survive child navigation. Use existing saved-filter and side-column conventions. No streaming transport required initially: adaptive polling while visible is enough.

6. Six stages and reviewable work packages

Each W-ID is a bounded PR into develop, with code + contract + focused tests and relevant document updates. Split further if a package cannot be reviewed coherently. Use work/word-import-<id>-<slug> branches. Feature flags keep incomplete paths hidden; a merged foundation does not claim a usable import release.

Stage / ID Deliverable and main surfaces Depends on Exit evidence
1 / W-01 Authorized corpus inventory, expected outputs, document families, holdout split, manual effort baseline Teacher sample access Rights/access recorded; missing corpus families visible; synthetic fixtures distinguished from teacher data
1 / W-02 Content/group/reuse/audio/revision contracts, threat model, API design and document updates This plan; representative fixtures D-01–D-04 below resolved; migration/deletion/rollback paths reviewed; AC matrix has no unowned requirement
1 / W-03 Editor/converter/extractor spikes and cloud/private recognition benchmark harness W-01; approved data policy Reproducible fidelity/resource results; dependency choices justified; no real exam leaves approved scope
1 / W-04 Interactive review prototype and teacher usability walkthrough UX plan; representative scenarios Teacher can find source, resolve ambiguity and recover edits without coaching; layout decisions recorded
2 / W-05 Versioned content schemas, safe renderer, legacy adapters and search projection W-02, W-03 Old/new fixtures render; option underlines survive; unsafe nodes rejected; student entry chunk stays small
2 / W-06 Rich editor in bank/builder, content validation shared by direct/internal writes W-05 Full editing/paste/undo and five types; true/false and publish cardinality regressions pass
2 / W-07 Draft groups/materials, context-aware bank insertion, copying/reference lifecycle W-02, W-06 Empty groups, reorder, cloze remap, dependency-aware copy and deletion races pass
2 / W-08 Snapshot/restore/duplicate/preview for full graph W-07 Editing/deleting sources cannot change published content; old versions still preview/restore
2 / W-09 Group-aware attempts, assignment validation, shared audio scope, grading/result rendering W-08 Manually authored passage/cloze/listening test completes; legacy deal remains identical; leak/audio canaries pass
3 / W-10 Imports module, private source uploads, ownership/quota/state contracts W-02 Type mismatch, invalid roles, unauthorized downloads and upload retries covered
3 / W-11 Durable queue/worker, leases, fencing, cancellation, metrics W-10 Kill/restart worker and reject late result; no source/edit loss; API remains responsive
3 / W-12 OOXML inventory/extraction with locators, numbering/styles/tables/assets W-03, W-11 Golden coverage corpus including repeated numbering, nested tables, marks and unsupported objects
3 / W-13 Isolated .doc normalization and source rendition W-03, W-12 Originals retained; converted lineage; losses visible; blocked network, timeout and memory limits verified
4 / W-14 Deterministic recognizer, declarative profiles, explicit answer reconciliation W-12, W-13 Restarts and companion keys correctly map; unknown/conflicting keys remain findings
4 / W-15 Assisted recognizer adapters, budgets, kill switch and versioned evaluation W-03, W-14 Cloud and private paths compared; schema/provenance validated independently; outage is recoverable
4 / W-16 Coverage/fidelity/domain validator and invalidation rules W-05–W-09, W-14, W-15 Every meaningful block accounted for; unsupported interaction cannot pass by relabeling
5 / W-17 Import history, upload/setup and durable progress UI W-04, W-10, W-11 Leave/return, retry/cancel and URL filter state work in Vietnamese/English
5 / W-18 Linked review, field editing, answers, split/merge/move, issues and autosave/conflicts W-06, W-16, W-17 Source-linked correction; keyboard-only workflow; save failure and browser recovery tested
5 / W-19 Source replacement, candidate comparison, selective apply, revalidation W-18 Manual edits protected; new source coordinates reconciled; equivalent confirmations only
5 / W-20 Learner-safe preview, final summary, atomic idempotent commit and builder handoff W-08, W-16, W-18 Failure after each persistence step leaves no partial test; duplicate/lost-response commit returns one result
6 / W-21 Capacity/security/a11y evaluation, retention/cleanup and operator runbooks W-09–W-20 Limits/SLOs approved and exercised; restore/drain/kill-switch/rollback drills pass
6 / W-22 Teacher pilot, correction-time evaluation, fixes and versioned release W-21 All AC-01–17 pass, critical escaped defects cleared, teacher acceptance and monitored rollout

Critical path: contracts β†’ content β†’ group/snapshot/delivery parity β†’ full validation β†’ review/commit β†’ pilot. Extraction/worker work can proceed separately after contracts stabilize, but no simultaneous heavy local processes are assumed. Do not estimate a release date from task count: W-01–04 must establish document complexity, converter/runtime fit and domain scope. At that gate provide remaining effort ranges, uncertainties and the next checkpoint; re-estimate after W-09 and the recognizer holdout run. No staffing or calendar commitment is implied here.

7. Extraction policies to make explicit

Source class Proposed treatment
Paragraphs/runs/automatic numbering Resolve inherited style/numbering; retain original labels and stable structural positions
Option tables, same-line options, columns Preserve cell/column order and geometry evidence; ambiguity blocks mapping until a teacher chooses
Headers/footers Inventory and suggest metadata/exclusion only with reason; do not discard possible instructions
Footnotes/endnotes Preserve referenced content and source links; unresolved semantic attachment is review-required
Text boxes/floating shapes Inventory text/anchor/order; show actual rendition when order is layout-dependent; semantic loss blocks commit
Comments/hidden text Keep in private source evidence; never automatically render to learners; teacher classifies relevance
Tracked changes Show revision ambiguity and require accepted/current vs original interpretation; keep both evidence branches
Underline/bold/color Preserve semantic marks. Treat them as keys only through an explicit confirmed convention; remove key-only marks from learner content
Embedded images Validate/decode bounds, promote usable assets, request alt text when necessary; unsupported vector/OLE objects become findings
Active content/macros/external relationships Reject unsafe source classes or isolate unsupported objects without executing/fetching; no silent successful empty import
Scans/encryption/corruption Actionable unsupported/invalid state; no OCR or lossy fallback disguised as success

8. Cloud API versus private inference

Update (D19, decided by Thuong on 2026-10-10; Β§1.42). Cloud processing is approved for the AI assistant's features, the provider is Anthropic's API, and "Read with AI" is built on it. The "admin-managed approved configuration" this section and 18-word-import-ux.md Β§4 ask for is the server's environment (ANTHROPIC_API_KEY, the fast and capable tiers and their prices, set by the operator as secrets) and the centre's rules in app.ai_settings; a teacher chooses no model and no infrastructure, only whether to read one import with AI, and the Sources pane says in plain language what leaves the system. The kill switch is removing the key, which turns every AI feature off at the next start. The rest of this section, the comparison and the private path, still describes the measurements to make before a vendor and model are fixed for any other use, and private inference is an unbuilt candidate.

Both approaches use the same local extraction, source inventory, validation, review and transactional commit. AI receives a bounded evidence bundle and returns untrusted candidates; it never has application-write tools. Deterministic recognition runs in either deployment. No automatic cloud fallback from private processing without a separately approved data policy.

Criterion Cloud API path Private inference path Evidence required
Quality Candidate provider/model chosen from benchmark Candidate model/runtime/quantization chosen from benchmark Same holdout documents and schema; correct answers/coverage/grouping per family, not model confidence
Data handling Explicit allowed payload, region, retention/training controls, contractual terms Private network, controlled model/log/storage access; infrastructure provider still matters Document flow map and approved processing policy; no automatic claim of complete privacy
Capacity Provider limits, throughput, outages and variable token usage GPU/CPU RAM/VRAM, context/KV cache, throughput, cold starts and OOM behavior Measured concurrent workloads, queue wait and recovery; no laptop RAM guess used for sizing
Cost Input/output usage, retries, extraction/storage and operational time Runtime idle/active cost, storage, hardware/hosting and operational time Cost per successfully reviewed exam and monthly scenarios from actual usage/quotes
Operations Credentials, rate limits, request budgets and provider degradation Deployment, model weights/license, upgrades, monitoring and availability Named owner, kill switch, rollback and maintenance estimate
Portability Provider-specific strict schema may differ Runtime/model strict schema may differ Canonical candidate validator and adapter conformance tests

Benchmark sequence: (1) deterministic baseline, (2) small tuning corpus per candidate, (3) frozen settings on holdout, (4) repeated difficult families to expose variance, (5) correction-time pilot, (6) maximum-input and outage tests. Record component versions, tokens/cost, latency, peak RAM/VRAM, omissions, key association, format/group fidelity, unsupported detection and escaped defects. Define corpus family weights before scoring. Mandatory invariants veto a candidate regardless of an attractive mean accuracy or price.

Recommendation at this point: keep both behind a narrow recognizer port and delay the model/vendor decision until these measurements. Prefer the path that meets the quality/privacy gates with the lowest total teacher and operating effort, rather than token price alone. Do not build a generic plugin system or multiple production integrations before this gate.

9. Quality gates, capacity and release

9.1 Traceability to the supplied acceptance criteria

Criteria Responsible packages Evidence at release
AC-01, 03 W-12–15, W-22 Real .doc/.docx and companion-key golden + upload-to-attempt E2E
AC-02 W-06, W-14–16, W-18 Unknown/conflicting/ambiguous keys; no generated key accepted without source/teacher evidence
AC-04 W-05, W-06, W-08, W-09 Pronunciation/options/tables survive edit β†’ snapshot β†’ student render
AC-05 W-07–09 Stable cloze links, grouped shuffle and shared playback; legacy replay fixture
AC-06 W-12, W-16, W-18 Complete coverage ledger; intentional reuse and exclusions reflected in totals
AC-07 W-08–10, W-20 Authorized asset delivery and recursive payload/content leakage checks
AC-08 W-18, W-19 Reload/save failure/two-tab conflict/reprocess/manual-merge scenarios
AC-09, 10 W-20 Duplicate/concurrent/lost-response commit and injected transactional failure tests
AC-11 W-11, W-13, W-15, W-21 Worker kill, stale fencing, cancellation races, provider outage and deployment drain
AC-12, 14 W-10–13, W-21 Authorization matrix, malicious archive/XML/markup/relationship/active-object fixtures
AC-13 W-05, W-08, W-09, W-21 Historical snapshot/attempt corpus, version restore/delete and rollback compatibility
AC-15 W-04, W-17–21 Keyboard/screen reader/zoom, 768px admin, 320px learner, Vietnamese/English
AC-16 W-03, W-21 Approved supported envelope, retention, numeric SLO/quality targets and measured results
AC-17 W-01, W-22 Teacher baseline/pilot, correction effort and resolved critical escaped defects

Unit tests target parsers/validators/identity and decision invalidation. Integration tests exercise real PostgreSQL reference locks, transaction atomicity and job recovery. Browser tests exercise user actions/states, including editable tables, IME, autosave and source navigation. Live E2E must include real file upload through published learner rendering/grading; stubbed browser tests alone cannot prove it. Keep all existing snapshot, audio-gesture, event-session, refresh-single-flight and router-chunk canaries. New tests must validate outcome, not mirror implementation.

9.2 Proposed measurement targets, not current capabilities

Initial benchmark envelope candidates: 25 MiB compressed exam/key each, 200 MiB expanded package each, 100 rendered pages, 200 questions, 100 embedded images. These are starting hypotheses; image pixels, XML depth, archive entries, nested object count, conversion time/memory and review payload limits must also be set. Reject over-limit inputs before consuming unbounded resources and offer a clear split/reduce-file instruction. No arbitrary silent truncation is allowed.

Proposed SLO hypotheses for a 50-question editable-text exam under the approved load: autosave acknowledgment p95 ≀1s excluding the 1.5s debounce; commit p95 ≀3s; processing p95 ≀120s excluding queue wait; expired-worker recovery ≀2min. Measure queue wait separately, plus maximum-input latency and time to interactive review. Tune/approve numbers using W-03/W-21; do not present them as a production promise.

Proposed pilot outcome: at least 50% median reduction in active authoring/correction time against matched manual work, with no critical escaped answer/context defects in the release corpus/pilot. This is a proposed product target, not an accuracy claim. Agree probabilistic per-family thresholds and completion-rate targets after the baseline, then freeze them before holdout evaluation. Report sample sizes, uncertainty and the difficult families separately. A small corpus cannot establish zero real-world failure risk.

Local capacity: use one conversion/model/test worker at a time, inspect available RAM before starting and reserve at least 2 GiB for the user's applications. Stop task-owned heavy work if that reserve is breached. Do not load model weights or launch GPU services on this laptop by default. Larger benchmarks need separately sized infrastructure; check capacity before provisioning or running them.

9.3 Retention, observability and rollout

Propose separate retention classes: original/key files and committed provenance; temporary conversions/renditions; superseded machine candidates; abandoned reviews; reusable published media. Decide durations with Thuong using pilot needs/storage measurements before release. The existing 13-month integrity policy is not an implicit import-retention policy. Never cascade import deletion into a committed test or referenced asset. Preserve append-only audit history and minimal commit identity required to prevent duplicate materialization.

Operator tooling reports IDs, stage timing, backlog, error codes, retries, stale claims, save conflicts, cleanup failures and cost. It must not log document text/answers/transcripts/signed URLs. Test actual alert delivery, not just the presence of an alert config. Runbooks cover converter/provider outages, stuck claims, partial uploads/orphans, lost commit responses, leakage response, storage failure and coordinated database/object restoration. Existing open production alert/PITR verification work remains a prerequisite where this milestone relies on it; do not mark those dependencies done without operational proof.

Expand-contract deployment: ship readers first, then new writes behind flags, then pilot, then general availability. W-21 must exercise stale cached browser bundles and enforce a reader capability/reload boundary before enabling interactive new-format writes; deployment of a new HTML entry alone is not that proof. Keep a release capable of reading all new content as the rollback floor; an old binary that cannot read semantic_v1 is not a safe rollback after new-format publication. Disable new imports/provider calls without removing revisions or breaking existing attempts. Drain/fence workers on deploy and verify backup/restore before enabling production writes.

10. Decision register and first implementation checkpoint

ID Decision Proposed direction Owner / deadline
D-01 Content/editor Application-owned versioned AST, lightweight student renderer, evaluated editor adapter Engineering + Thuong; W-02/03 before W-05
D-02 Group/material ownership and bank reuse Approved by Thuong: independent copies; full context copied into bank/other tests; source deletion cannot affect copies Engineering; reference/DDL review remains before W-07
D-03 Audio/deal semantics Approved by Thuong: shuffle groups as units within a section, keep child order; a shared recording has one allowance per group/attempt, preserved on navigation/reload/takeover, independent across groups using the same file Engineering; snapshot/API/ledger integration and legacy regressions before W-08/09
D-04 Access and unsent recovery Recovery approved by Thuong: per-account local drafts, at most seven days, logout clearing, explicit local/server save states. Existing admin authorization still applies Group bank recovery implemented and tested; import-review integration pending
D-05 Converter/extractor dependencies Benchmark structured OOXML extraction and isolated LibreOffice normalization; Mammoth is a comparison candidate only Engineering; W-03 before dependency addition
D-06 Cloud/private model and data policy Evaluate both, no silent provider fallback or external upload Thuong + engineering; before real external benchmark/assisted processing
D-07 Limits/SLOs/cost Measure Β§9.2 hypotheses and approve supported envelope and spending cap Thuong + engineering; W-21 before release
D-08 Retention/cleanup Decided 2026-09-25: files and draft kept 30 days after commit and 7 after cancel; imports idle 60 days are closed and their files removed; history rows kept (Β§1.38) Thuong
D-09 Quality and pilot Verified real corpus, holdout, matched manual baseline and explicit release thresholds Thuong + pilot teachers; W-01 baseline, thresholds before holdout
D-10 PDF input Decided 2026-09-25: text-layer PDFs are accepted beside .docx; scans are refused without OCR; underline, bold and colour are not read; keys come from a key file or explicit lines (Β§1.39) Thuong
D-11 Pasted text input Decided 2026-10-03, built in R4 (Β§1.41): a test pasted as plain text is accepted as an exam source beside .docx and PDF, up to 100,000 Unicode code points after NFC and 20,000 non-blank lines; no formatting is read; the answer conventions the paste page states are read for text sources only (text-lines-v1), except the one-line answer key, which is read in Word and PDF files too (#233, decided 2026-10-04); rules-v3 and word-pipeline-v3 Thuong (delegated); R4

First checkpoint deliverables are W-01–04: source family/coverage inventory, proposed contracts with concrete examples, extraction/editor/provider comparisons, and a tested teacher review flow. The initial planning input had no real exam documents; the subsequently supplied local corpus is inventoried in Β§1.1. Synthetic tests and successful source inventory do not close W-01 or the teacher-quality gate. Review expected outputs and confirm answer truth before making recognition-quality or release-time claims.

11. Primary technical references checked during planning

  • Mammoth documentation: underline needs deliberate mapping; conversion is not sanitization and direct Markdown output is deprecated. Useful benchmark candidate, insufficient as a complete evidence/coverage architecture by itself.
  • LibreOffice conversion filters: command-line conversion is available. Fidelity, isolation and resource envelopes still require tests on our files.
  • Tiptap content output: JSON editing is available; this is not evidence that all Word semantics survive.
  • vLLM structured outputs: schema-constrained output is possible for the private-inference candidate; schema validity alone does not establish recognition quality.

Verify versions/licenses/security configuration again at dependency selection.


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

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