Plan 19 Content Contract - SekiroKenjii/Quizzivy GitHub Wiki
Status: content foundation plus W-05a options and W-05b question prose, with
pilot authoring affordances, stable blank bindings and bounded clipboard conversion.
Group graph validation/copy foundations are additive; group persistence and import
endpoints remain pending.
api/openapi.yaml is the structural authority. Go's shared/content.Parse and
the frontend validator enforce the cross-node rules below. They share synthetic
accept/reject fixtures; generated TS types are the frontend model. Go's domain
value object stays independent of HTTP/generated types. The generator retains
these standalone components intentionally until the rich endpoints consume them.
ContentDocument discriminates by format. legacy_markdown_v1 contains exactly
markdown and preserves that string (including whitespace and Unicode form);
its reader is the existing sanitized Markdown renderer. semantic_v1 contains
one or more blocks. It never contains editor JSON or raw HTML nodes.
Blocks: paragraph, heading (levels 1–3), ordered/unordered list, table, image and audio. Inline nodes: text with allowlisted marks, break, stable gap and HTTPS link containing text only. Marks are bold, italic, underline, strike, superscript and subscript; duplicates and simultaneous super/subscript are invalid. A blank paragraph is valid. Each list item begins with a paragraph; unordered start is 1.
Tables contain source-order rows/cells, with explicit header, rowSpan and
colSpan. Place each cell at the first unoccupied column, skipping rowspans.
Spans cannot overlap, escape the grid or leave holes; every row has the same
nonzero occupied width. A row may have no new cells only when prior spans cover
it completely. Tables may contain lists, but never another table, even through
a list. Unsupported source structure remains a review finding, not silent loss.
A gap has a stable case-sensitive ASCII identity (1–64 characters, first alphanumeric, then alphanumeric/underscore/hyphen) and a 1–32 scalar display label. IDs are unique per document. Binding gaps to question/blank identities is an enclosing graph invariant, not an inferred relationship from displayed labels.
Image/audio nodes carry UUID asset references and required alt/label text. These are inert in the foundation reader. References are deduplicated case-insensitively in first-use order; one UUID cannot claim both kinds. The enclosing application must validate existence, authorization, kind, ownership and relational bindings, and take the existing media reference locks before persistence/publication. No parser/renderer fetches a URL from these nodes. Private source/key files cannot be used as learner media merely because their IDs are known.
Links permit explicit HTTPS, ASCII DNS-style host labels (international domains must be punycoded), optional decimal port 1–65535, and Unicode path/query/fragment. IPv6 literals are outside this initial portable subset. Reject credentials, whitespace, C0/C1 controls, backslashes and malformed percent escapes. Never fetch links during import or validation. Render with the existing safe external-link attributes. The validator does not assert the destination's reputation or reachability.
Limits are per content document, not a whole exam; larger exam/group/job budgets
remain W-02/W-21 work. ContentDocument.x-content-limits pins these values:
| Budget | Limit / counting rule |
|---|---|
| JSON input | 1 MiB before decoding; also enforce enclosing HTTP/worker input limits |
| Nesting | 16 JSON property/array edges; root depth 0, including scalar leaves |
| Values | 24,576 JSON values; object property names are not values |
| Width | 2,048 elements/properties per array/object before shape validation |
| All string values | 200,000 Unicode scalar values including type/format names, IDs and URLs |
| Content nodes | 2,048 blocks, inline nodes (including linked text) and table cells |
| Visible text | 100,000 Unicode scalars across text, gap labels, image alt and audio labels; legacy Markdown counts in full |
| URL / table | 2,000 scalars per URL; 50 rows, 12 occupied columns |
JSON Schema lengths and both semantic validators count Unicode scalar values, not UTF-16 code units or UTF-8 bytes. Reject malformed UTF-8, unpaired escaped surrogates and NUL; do not replace them with U+FFFD or normalize Vietnamese text. Transport parsing rejects duplicate object keys (including escaped aliases), trailing JSON, non-JSON numbers and oversized/deep/wide values before constructing a recursive domain graph. Frontend validation of already-parsed objects cannot recover duplicate-key evidence: future server writes must feed original raw JSON to this parser, not marshal a permissively decoded object back into it.
Every object is closed. Unknown nodes/marks/properties—including grading keys, source coordinates, arbitrary style/classes and event handlers—are rejected, never stripped. Structural JSON Schema validation is necessary but insufficient; it does not express graph, Unicode, URL or aggregate invariants. The Go error contains no input text. This validation cannot infer whether teacher-entered ordinary text discloses an answer; review and learner-preview policy still apply.
Plain-text projection retains text and link labels, emits breaks as newlines and
gaps as [label], separates top-level blocks by two newlines, list items/cell
blocks/table rows by one newline, and table cells by tabs. Spans do not duplicate
text. Asset labels participate; URLs, IDs, marks and provenance do not. The
projection is deterministic search input, not a faithful rendered exam or a
canonical review digest. Legacy strings remain exact.
Thuong approved independent group/material graphs when copying to another test or saving to the bank. Copies retain all required members/materials, remap gap and membership identities, and survive source edits/deletion. Reused immutable asset bytes have distinct relational bindings. Source provenance is bookkeeping, never a live content dependency. DDL, deletion locks, version snapshots, copy/restore and concurrent-reference tests remain W-07/W-08 work.
Thuong also approved account-isolated recovery for unsent edits for at most seven days, cleared on logout. Implement a bounded local outbox only with explicit local/server save states, revision-based conflict handling, expiry before reads and replay, visible quota/storage failures and account-switch/logout tests. Do not renew expiry merely by reading an old revision. Exclude uploaded original and answer files. Server acknowledgements alone define server durability. Storage and UI implementation remain W-18 work; this foundation adds no browser storage.
Readers ship before new writes. Add rich question fields and their relational asset bindings with explicit contract/migrations, then builder, preview, publication/restore, attempts/results and answer-leak tests. Preserve the complete historical snapshot/deal/audio behavior. Do not backfill or rewrite old published versions as part of adding readers. After new-format writes begin, rollback must retain a binary capable of reading them; a flag disables new writes, not readers. Old snapshots can remain read-only through that rollback floor. Final D-01 editor acceptance and the complete group/revision endpoint contracts remain open.
When question shuffling is enabled, groups and standalone questions move as units within their section. Sections and members within a group keep authored order; with shuffling disabled, all units keep authored order. Group membership, order and delivery version are frozen in the snapshot and retained for attempt reload and takeover. Historical snapshots continue using the existing algorithm/seeds. Option-label dependencies must be checked against option shuffling separately.
A recording shared by a group has one configured play allowance across its children for that attempt. Child navigation, reload and takeover cannot reset it; another group using the same file has a separate allowance. Thus an explicit versioned group-recording binding identifies the playback scope, not the asset alone. Publication freezes the binding/policy; restoration/copy creates an independent draft binding. Preserve synchronous user-gesture playback, optimistic accounting, replay deduplication, reporting and transcript visibility policies.
Required integration evidence: grouped/standalone deal ordering with shuffling on/off; fixed child/cloze order; stable answers after reload/takeover; counts shared across children but separate across two groups using the same asset; idempotent playback retries and session handover; immutable published bindings; and unchanged legacy deal/audio/leak canaries. This is an approved product policy, not an enabled player, new ledger schema or implemented group endpoint.
QuestionGroup is an additive admin contract; current section/test writes do not
accept it. The tests domain resolves a GroupBundle against exactly its member
questions, validates existing question invariants and rejects missing, duplicate
or foreign members. The catalog's fetch order and UUID letter case do not define
authored member order. Empty drafts are allowed; publication rejects emptiness.
Fixed option order applies only to choice members and rejects a conflicting
shuffle configuration rather than silently changing the assignment policy.
A stimulus owns a content document plus one binding per gap. question targets
a choice member; blank targets a rich fill-blank member's stable prompt gap.
Printed labels may repeat. Binding IDs are local to a document, while response
targets are unique across the group's materials. Blank row IDs are not stable
targets because ordinary question writes may replace those rows. Unsupported
targets, orphan gaps, missing keys, invalid content and mixed asset kinds fail
before copy. These are graph checks in addition to the structural OpenAPI schema.
Each audio asset appearing in materials has exactly one explicit recording binding, even when repeated across materials. It cannot also carry a member-level allowance in that group. Other member audio retains its own policy. Different groups may reuse the same file under independent recording identities. Group recording transcripts are teacher metadata: future learner/review projections must be explicit and disclosure-policy gated, never JSON casts of this graph.
The complete resolved group is limited to 4 MiB, 200 members, 16 materials and 16 recordings. Titles allow 200 Unicode scalars and recording transcripts 100,000; the content-document limits still apply independently. Persistence must validate original bounded raw input, authorized media kinds and relational references, not treat successful domain validation as an access grant.
GroupBundle.Copy first validates and deep-copies all data, then generates fresh
group, member, option/blank, material, local-gap and recording identities. Both
ends of cloze links are remapped together; exact text, keys, scores, order and
audio policy remain unchanged. Asset IDs stay stable; future storage creates
independent protected bindings. An invalid/repeated ID provider returns no
partial copy and cannot mutate its source. Source provenance is not a live link.
Relational ownership, group-only bank insertion, deletion races, optimistic revisions, snapshot/restore and learner delivery are not supplied by this value model. Keep new writes disabled until those lifecycle paths and readers ship; do not claim W-07 complete from these foundation tests.
OptionContent is a deliberately smaller semantic document: exactly one
paragraph, with text/marks and breaks only. It inherits all ContentDocument
budgets. It cannot bind media/gaps or carry links, tables, source evidence or
answer keys. content.ParseOption checks original raw JSON, including duplicate
keys; generated Go request fields use json.RawMessage so typed decoding does
not destroy that evidence. Frontend validation uses the same content validator
plus this profile. The stored text must equal the exact plain projection.
Legacy options are literal text, unlike legacy Markdown prompts. Conversion
wraps text/breaks without interpreting Markdown, HTML or normalization.
The additive nullable option field travels through bank CRUD/duplication, publication, restore, published/draft preview, attempts, results and teacher review. Migration 00032 stores it per normalized option; it does not rewrite historical versions. Absent content retains existing rich formatting only with a matching current option ID and unchanged text, checked while the parent write lock is held. Explicit null clears it. Stale or changed legacy writes roll back instead of dropping marks. New clients send the complete document or explicit null. Existing full-question concurrency semantics are otherwise unchanged; revision-based authoring conflicts remain a later work package.
VITE_RICH_OPTION_EDITOR defaults to false and controls the pilot affordance to
format plain options in both bank and builder. It is a UI rollout flag, not an
API authorization boundary. Authenticated question writes validate rich content
regardless of the flag. Existing rich options always remain readable/editable.
The option editor loads on demand, accepts the inline profile only, maps Enter
to a line break, rejects formatted/file paste visibly, and updates the existing
bank save/builder autosave coordinator immediately. No local recovery claim is
made. Disabling the flag must not remove readers or roll back migration 00032.
W-05b below extends this checkpoint to prompts/explanations. W-05/W-06 remain partial: broader content adapters, five-type authoring, safe structured paste, final D-01 acceptance and complete shared validation are still pending. W-07–W-09 group/media behavior is unchanged.
The next profile, QuestionContent, carries semantic prose in nullable
promptContent and explanationContent. It allows paragraphs, headings, lists
and tables with text, breaks and safe links; recursively rejects assets and gaps.
This preserves the existing media/reference lock paths and leaves graph binding
to W-07/W-08. Prompts for fill_blank retain legacy Markdown until stable gap
bindings are integrated. Explanations support every question type.
Each non-null document must project exactly to its companion string. Raw JSON strictness and aggregate budgets are checked on question writes and publication. Absent rich fields in legacy updates preserve stored documents only when the companion text is unchanged, under the question row lock. Explicit null clears the document. The new UI sends explicit document/null values. Existing concurrency semantics remain; this is not revision-conflict resolution or durable recovery.
Migration 00033 adds nullable prompt/explanation JSONB to bank and snapshot questions, without backfill or new privileges. Publication, restoration, copying and reads carry the fields independently of grading keys. Active paper/preview queries never read explanations. Result SQL gates both explanation columns with the existing review policy. Rollback after rich writes retains these columns and capable readers; destructive Down is only for disposable/pre-rollout data.
Since T-R4.64 every prompt and explanation is written in the form it is stored in: rich content in the rich editor, a Markdown string in the Markdown editor, and a new field is rich text, the builder's and the group composer's starter prompt included (T-R4.31b, T-R4.34). Markdown conversion is explicit and refuses unsupported nodes rather than dropping them (for example a table's column alignment, raw HTML, code, images, quotations and links that are not HTTPS). Media, gap bindings, structured clipboard import, IME/teacher acceptance and full five-type rich prompt authoring remain later gates.
QuestionPromptContent extends the prose profile with gaps inside paragraphs,
headings, lists and table cells. QuestionContent remains gap-free for explanations.
A rich fill-blank prompt needs at least one gap, and its gap IDs must match the
non-null gapId set on blank metadata one-to-one. No inference from labels,
ordinals or plain projection is permitted. Other interaction types reject gaps;
legacy Markdown rejects non-null gap IDs. All document budgets remain unchanged.
Migration 00034 stores nullable gap_id on bank and snapshot blanks, with format
checks and uniqueness scoped to the parent question. Legacy rows stay null.
A gap identity survives draft edits even though normalized answer-row UUIDs may
be replaced. Publication freezes identities and answers together. Learner answers
continue to use frozen blank UUIDs, and existing grading logic remains unchanged.
Duplication and version restoration allocate fresh local gap IDs and replace both
AST references and metadata in the same write transaction. Source edits do not
mutate a copied graph or snapshot. Student payloads contain no new grading keys.
Explicit Markdown conversion accepts only unique supported markers with matching answer rows and previews before applying. Repeated/unsupported markers block conversion. In rich authoring, newly inserted gaps receive empty answer rows; moving a node preserves its key. Removing a node retains orphaned answers for undo and blocks save until they are restored or explicitly discarded. Removing formatting restores ordinal markers before escaping the prose projection. New authoring uses the existing opt-in flag; stored rich blank prompts stay editable when disabled. This is not structured clipboard import or pilot acceptance.
Migration Down first restores the old prompt restriction. It therefore refuses rich fill-blank rows without losing data. After such writes, retain the schema and these readers as the rollback floor and disable only new authoring.
The editor intercepts formatted/file paste at the DOM-event boundary, before
ProseMirror's HTML parser runs. parse5 8.0.1, MIT, is an explicit dependency for
inert standards-based HTML parsing. Raw clipboard HTML is never mounted, saved,
sent to a service, or included in diagnostics. The converter loads on demand.
The accepted subset maps paragraphs, headings 1–3, ordinary lists with explicit starts, tables/spans, six marks and safe HTTPS links to the existing semantic AST. Allowlisted inline CSS supplies marks. Fonts, size, color and layout decoration are replaced by the product design; the preview explains that normalization. Unknown tags/attributes, active content, images/files, unbound gap metadata, stylesheets, conditional Word markup, tracked changes, ambiguous CSS/list rules, nested/malformed tables and unsafe URLs cause whole-paste refusal. Parsed source locations must cover the input: HTML tree repair cannot silently discard unknown source tags. This is deliberately narrower than arbitrary Word clipboard HTML.
Input is at most 256 KiB UTF-8, with valid Unicode and no NUL. Parse inspection allows at most 6,144 source nodes and 48 tree levels before semantic conversion; existing content limits and the narrower field profile apply to both the candidate and the complete replacement. No mark implies an answer key or gap binding.
Preview renders only the validated resulting semantic document, with accessible cancel/apply controls and focus restoration. The original document/selection is captured before opening it. Cancellation and a late cancelled conversion do not change content. If the document changes before confirmation, the user must paste again. Applying uses one validated transaction with history boundaries on both sides; undo restores the previous content and answer bindings remain governed by W-06b. Existing autosave/server rules apply after the confirmed edit.
Generated from docs/plan/19-content-contract.md. Edit that file in the repository; changes made on this page are overwritten.