Plan 20 Data Model Part 1 - SekiroKenjii/Quizzivy GitHub Wiki
Part 1 of 2 of 20 — Data Model · Next
Full DDL for every table in §13, expanded from the sketches. Every foreign key
has a decided ON DELETE, every index has a reason, and every deviation from the
§13.3 sketch is flagged with [D-nn] and justified in §12 of this document.
Governed by the Neon postgres-best-practices skill (schema-design, indexing)
and the PostgreSQL 18 docs (§13.1). PG18-specific constructs cite their docs
section. Verified behaviour is recorded in 00-overview.md §1.
Target: PostgreSQL 18. uuidv7() and virtual generated columns have no
fallback on 16/17; this schema does not degrade, it fails to create.
Roles cannot be created by the migration role, so they are provisioned by the
compose initdb script locally and by a documented runbook step in production:
CREATE ROLE quizzivy_migrate LOGIN PASSWORD :'migrate_pw';
CREATE ROLE quizzivy_app LOGIN PASSWORD :'app_pw';
CREATE DATABASE quizzivy OWNER quizzivy_migrate;
GRANT CONNECT ON DATABASE quizzivy TO quizzivy_app;quizzivy_migrate owns the schema and runs goose. quizzivy_app is what the
API connects as and never owns anything (§13.5). pg_trgm is a trusted extension
in PG13+, so quizzivy_migrate can install it without superuser.
CREATE SCHEMA app;
CREATE EXTENSION IF NOT EXISTS pg_trgm; -- questions_prompt_trgm_idx [D-11]
CREATE EXTENSION IF NOT EXISTS unaccent; -- Vietnamese search [D-11]
-- unaccent() is STABLE in PG18 -- BOTH the one-argument and the two-argument
-- form; verified on 18.6. Postgres therefore refuses it in an index expression
-- ("functions in index expression must be marked IMMUTABLE"). This wrapper pins
-- the dictionary and asserts immutability, which is the standard workaround.
--
-- The assertion is a deliberate white lie: if the unaccent dictionary file ever
-- changes, every index built on this function must be REINDEXed. That is an
-- accepted trade-off and is why the dictionary is named explicitly.
CREATE FUNCTION app.immutable_unaccent(text) RETURNS text
LANGUAGE sql IMMUTABLE PARALLEL SAFE STRICT AS
$fn$ SELECT public.unaccent('public.unaccent'::regdictionary, $1) $fn$;
-- Documentation only: this has been the default since PG15. Kept so the
-- intent in spec §13.2 is visible in the schema history.
REVOKE CREATE ON SCHEMA public FROM PUBLIC;Both extensions are trusted in PG13+, so quizzivy_migrate can install them
without superuser.
Enums where the set is genuinely closed (§13.2). Adding a value is easy;
removing one is not, so anything that might grow is text with a CHECK, or a
lookup table.
CREATE TYPE app.user_role AS ENUM ('admin','student');
CREATE TYPE app.test_status AS ENUM ('draft','published','archived');
CREATE TYPE app.question_type AS ENUM ('single_choice','multiple_choice',
'true_false','fill_blank','short_answer');
CREATE TYPE app.attempt_status AS ENUM ('in_progress','submitted','timed_out',
'graded','voided');
CREATE TYPE app.media_kind AS ENUM ('image','audio');
CREATE TYPE app.join_source AS ENUM ('admin','join_code');
CREATE TYPE app.integrity_action AS ENUM ('warn','flag','auto_submit'); -- [D-15]integrity_action is new — §7's IntegrityPolicy.onLimitExceeded is a closed
three-value set and belongs in the type system, not a CHECK.
attempt_events.kind is deliberately not an enum. §10.1's list looks closed,
but an append-only telemetry log reliably gains kinds, and each new one would be
a migration plus a deploy-ordering problem for a value that is never joined on.
Constrained by length only.
updated_at via trigger, never application code (§13.2). One function, reused.
CREATE FUNCTION app.set_updated_at() RETURNS trigger
LANGUAGE plpgsql AS $fn$
BEGIN
NEW.updated_at := now();
RETURN NEW;
END;
$fn$;Applied to users, classes, questions, tests, assignments,
attempt_answers — every table with an updated_at column. Version and event
tables are immutable or append-only and have none.
CREATE TABLE app.users (
id uuid PRIMARY KEY DEFAULT uuidv7(),
email text NOT NULL CHECK (length(email) BETWEEN 3 AND 254),
full_name text NOT NULL CHECK (length(full_name) BETWEEN 1 AND 200),
role app.user_role NOT NULL DEFAULT 'student',
password_hash text, -- NULL = Google-only account
must_change_password boolean NOT NULL DEFAULT false,
disabled_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT users_must_change_needs_password
CHECK (NOT must_change_password OR password_hash IS NOT NULL) -- [D-16]
);
CREATE UNIQUE INDEX users_email_lower_key ON app.users (lower(email));
CREATE INDEX users_role_active_idx ON app.users (role) WHERE disabled_at IS NULL;
CREATE TRIGGER users_set_updated_at BEFORE UPDATE ON app.users
FOR EACH ROW EXECUTE FUNCTION app.set_updated_at();-
textwith a lengthCHECKovervarchar(n), per the schema-design reference:varchar(n)gives no performance benefit and makes future changes harder. 254 is the RFC 5321 address limit. -
users_email_lower_keyis the §13.3 sketch, unchanged. Case-insensitive uniqueness must be an expression index — a plainUNIQUE (email)would let[email protected]and[email protected]coexist and break §5.1's linking rule. -
users_must_change_needs_passwordencodes §5.4's "Google-only users never hit/change-password" as a constraint rather than a convention. -
users_role_active_idxserves §8's active-student count. Tiny table; it exists so the dashboard query is not a repeated seq scan, not because it is hot.
CREATE TABLE app.user_identities (
id uuid PRIMARY KEY DEFAULT uuidv7(),
user_id uuid NOT NULL REFERENCES app.users(id) ON DELETE CASCADE,
provider text NOT NULL CHECK (provider IN ('google')),
provider_user_id text NOT NULL, -- Google `sub`, immutable
email_at_link text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (provider, provider_user_id),
UNIQUE (user_id, provider) -- [D-08]
);-
ON DELETE CASCADE: an identity is a true owned child of a user — meaningless without it, and never referenced elsewhere. -
UNIQUE (provider, provider_user_id)is §5.3 step 4's "identity exists → log in" lookup and prevents one Google account reaching two users. -
UNIQUE (user_id, provider)is new. §15'sDELETE /auth/google/linkand §7'slinkedProvidersboth assume at most one Google identity per user; without it "unlink Google" is ambiguous. -
The §13.3 sketch's
CREATE INDEX ON (user_id)is dropped — it is now the redundant prefix ofUNIQUE (user_id, provider). The indexing reference is explicit: avoid redundant single-column indexes when a composite exists. -
providerstaystext + CHECKrather than an enum: a second provider is a plausible v2 change andALTER TYPEin a deploy is worse thanALTER TABLE.
§13.5 specifies the columns in prose only. Full DDL:
CREATE TABLE app.refresh_tokens (
id uuid PRIMARY KEY DEFAULT uuidv7(),
user_id uuid NOT NULL REFERENCES app.users(id) ON DELETE CASCADE,
family_id uuid NOT NULL,
token_hash bytea NOT NULL UNIQUE CHECK (length(token_hash) = 32),
issued_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL,
revoked_at timestamptz,
replaced_by uuid REFERENCES app.refresh_tokens(id) ON DELETE SET NULL,
user_agent text,
ip inet, -- [D-10]
CHECK (expires_at > issued_at)
);
CREATE INDEX refresh_tokens_family_idx ON app.refresh_tokens (family_id);
CREATE INDEX refresh_tokens_user_live_idx
ON app.refresh_tokens (user_id) WHERE revoked_at IS NULL;
CREATE INDEX refresh_tokens_expiry_idx
ON app.refresh_tokens (expires_at) WHERE revoked_at IS NULL;-
token_hash byteawith a length check — SHA-256 is exactly 32 bytes, so a wrong-length value is a bug caught at write time. Lookup is by hash and the comparison is constant-time in Go, per §13.5. -
family_idis the rotation chain. §5.2's reuse detection isUPDATE … SET revoked_at = now() WHERE family_id = $1, which is whyrefresh_tokens_family_idxexists and is not optional. -
replaced_byON DELETE SET NULL: rows are pruned by expiry, and a pruned predecessor must not cascade-delete a live successor. -
refresh_tokens_expiry_idxis the cleanup job's index. Partial onrevoked_at IS NULLbecause revoked rows are deleted by family, not by expiry. -
ip inetnottext:inetvalidates, normalizes IPv6, and supports subnet containment if abuse investigation ever needs it. Same foraudit_log.ip.
CREATE TABLE app.classes (
id uuid PRIMARY KEY DEFAULT uuidv7(),
name text NOT NULL CHECK (length(name) BETWEEN 1 AND 120),
description text,
self_join_enabled boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TRIGGER classes_set_updated_at BEFORE UPDATE ON app.classes
FOR EACH ROW EXECUTE FUNCTION app.set_updated_at();No index beyond the PK. There will be single-digit classes; §8's list is a seq scan and that is the correct plan.
R4 adds schedule_label and room (T-R4.18, D-39, §41).
CREATE TABLE app.class_join_codes (
id uuid PRIMARY KEY DEFAULT uuidv7(),
class_id uuid NOT NULL REFERENCES app.classes(id) ON DELETE CASCADE,
code_hash bytea NOT NULL UNIQUE CHECK (length(code_hash) = 32),
code_hint text NOT NULL CHECK (length(code_hint) = 4),
expires_at timestamptz NOT NULL,
max_uses integer CHECK (max_uses IS NULL OR max_uses > 0),
uses_count integer NOT NULL DEFAULT 0 CHECK (uses_count >= 0),
revoked_at timestamptz,
created_by uuid NOT NULL REFERENCES app.users(id) ON DELETE RESTRICT,
created_at timestamptz NOT NULL DEFAULT now(),
CHECK (expires_at > created_at), -- [D-09]
CHECK (max_uses IS NULL OR uses_count <= max_uses) -- [D-09]
);
CREATE UNIQUE INDEX class_join_codes_one_active
ON app.class_join_codes (class_id) WHERE revoked_at IS NULL;-
code_hashis the only lookup path, through itsUNIQUEindex. A typed code is looked up ascode_hash = ANY(candidates), and revocation, expiry and uses are judged on the row found (§33). The plaintext code is never stored (§13.3). From00078a new code is also sealed under a key the database never holds (D-27), so a dump does not hand it over; a legacy row's SHA-256 still does, in 2^20 guesses, until the start-up rotation of v0.10.0 replaces it (§33); a revoked or expired one stays. -
The exhaustion check is in the database, not only in the handler. §6.5
treats the code as a bearer secret; a
uses_count > max_usesrow is a silent policy failure and this makes it impossible. -
class_join_codes_one_activeis §13.3's, unchanged. Note what it does not say: an expired code still satisfiesrevoked_at IS NULLand so still occupies the active slot. That matches §6.1 — only rotation revokes — and means rotation must revoke before inserting, inside one transaction. -
created_by ON DELETE RESTRICT: the audit trail for who issued a bearer secret must not be erasable by deleting a user. - No separate
(class_id)index. The partial unique covers the only hot lookup (the class's active code) and per §13.6 B-tree skip scan is left passive rather than adding prefix indexes preemptively.
CREATE TABLE app.class_members (
class_id uuid NOT NULL REFERENCES app.classes(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES app.users(id) ON DELETE CASCADE,
joined_via app.join_source NOT NULL,
joined_at timestamptz NOT NULL DEFAULT now(),
added_by uuid REFERENCES app.users(id) ON DELETE SET NULL, -- [D-10]
join_code_id uuid REFERENCES app.class_join_codes(id) ON DELETE RESTRICT, -- [D-10]
PRIMARY KEY (class_id, user_id),
CONSTRAINT class_members_source_consistent
CHECK ((joined_via = 'admin') = (join_code_id IS NULL))
);
CREATE INDEX class_members_user_idx ON app.class_members (user_id);-
join_code_idis new and cheap. §6.4 showsjoined_viaso the teacher can spot unexpected enrolments; recording which code someone came through means that after a rotation the teacher can tell "joined via the code I leaked" from "joined via the current one".ON DELETE RESTRICTbecause codes are revoked, never deleted — andSET NULLhere would violate the consistencyCHECK. -
class_members_user_idxis required, not optional: §9's/app/classesand the assignment target resolution both query byuser_id, which the PK cannot serve.
CREATE TABLE app.audit_log (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
actor_user_id uuid REFERENCES app.users(id) ON DELETE SET NULL,
action text NOT NULL CHECK (action <> ''),
entity text NOT NULL CHECK (entity <> ''),
entity_id uuid,
occurred_at timestamptz NOT NULL DEFAULT now(),
ip inet,
user_agent text,
diff jsonb
);
CREATE INDEX audit_log_entity_idx
ON app.audit_log (entity, entity_id, occurred_at DESC);
CREATE INDEX audit_log_actor_idx
ON app.audit_log (actor_user_id, occurred_at DESC);-
bigint IDENTITYnot uuid, for the same reason §13.3 gives forattempt_events: a high-volume append-only log read by a narrow key is better served by a narrow sequential key.GENERATED ALWAYS AS IDENTITYoverserialper the schema-design reference. -
actor_user_id ON DELETE SET NULL— an audit row must survive the actor. The only nullable-actor case besides deletion is a system action. - No
(occurred_at DESC)index yet. §8 has no global audit screen, and §13.6 says to leave skip scan passive rather than adding indexes preemptively. Add it with the screen that needs it.
Required entries per §13.4: class enrolment, join-code generation and rotation,
attempt reset/void/extend, password reset, media deletion, test publish. Each is
written with the OLD/NEW CTE pattern in 00-overview.md §4.4.
CREATE TABLE app.media_assets (
id uuid PRIMARY KEY DEFAULT uuidv7(),
kind app.media_kind NOT NULL,
storage_key text NOT NULL UNIQUE,
mime_type text NOT NULL CHECK (mime_type IN
('audio/mpeg','audio/mp4','audio/aac',
'image/png','image/jpeg','image/webp')),
bytes bigint NOT NULL CHECK (bytes > 0 AND bytes <= 10485760),
duration_ms integer CHECK (duration_ms IS NULL
OR (duration_ms > 0 AND duration_ms <= 300000)),
original_filename text NOT NULL,
checksum_sha256 bytea NOT NULL CHECK (length(checksum_sha256) = 32),
uploaded_by uuid NOT NULL REFERENCES app.users(id) ON DELETE RESTRICT,
created_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
CONSTRAINT media_assets_id_kind_key UNIQUE (id, kind), -- [D-05]
CONSTRAINT media_assets_audio_has_duration
CHECK ((kind = 'audio') = (duration_ms IS NOT NULL)),
CONSTRAINT media_assets_kind_matches_mime
CHECK ((kind = 'audio') = (mime_type LIKE 'audio/%'))
);
CREATE INDEX media_assets_kind_created_idx
ON app.media_assets (kind, created_at DESC) WHERE deleted_at IS NULL;
CREATE INDEX media_assets_checksum_idx
ON app.media_assets (checksum_sha256) WHERE deleted_at IS NULL; -- [D-06]-
§11.1's limits are constraints, not just handler code. 10 MB = 10485760
bytes, 5 minutes = 300000 ms. The handler validates first with a friendly
Vietnamese message; the
CHECKis what makes "we validate server-side" true even if a code path is added later that forgets. -
mime_typeis an allowlist, matching §11.1's mp3/m4a decision exactly. Adding.ogg/.wavlater is a one-line migration — which is the right shape for a decision §17.3 flags as revisitable. -
UNIQUE (id, kind)is the composite-FK target [D-05].idis already the PK, so this adds an index that is redundant for lookups. It exists purely soquestionsandtest_version_questionscan declareFOREIGN KEY (media_asset_id, media_asset_kind) REFERENCES media_assets (id, kind), which is what lets aCHECKon those tables enforce §7's "audio policy present iffmedia.kind === 'audio'" relationally instead of in application code. Postgres requires aUNIQUEon exactly the referenced columns. -
checksum_sha256has a plain index, neverUNIQUE[D-06]. §13.3's comment says the checksum is for "dedupe identical re-uploads", but §11.1 says "Re-uploading creates a newmedia_assetsrow; it never overwrites an existing key". Those cannot both hold. Immutability wins because it is what lets a version snapshot reference an asset without copying the file. The checksum keeps its column and gets a non-unique index so the upload UI can warn ("bạn đã tải tệp này lên rồi") and so integrity can be re-verified against R2 — but it never blocks a write. -
uploaded_by ON DELETE RESTRICT: an asset referenced by a frozen test version must not lose its provenance. -
deleted_atsoft delete per §13.2 — history matters here because a published version may reference the row.DELETE /teacher/media/:id(§15) returns409whentest_version_questionsstill references it; only unreferenced assets get adeleted_at. R2 object lifecycle after soft delete is deliberately out of scope for v1 — the object stays. See40-open-items.md.
Three files on a populated table. Every new column is nullable with no default, so the v0.9.0 binary's insert, which names none of them, keeps working during the rolling deploy. None adds an index.
-- 00082_add_media_assets_library_fields.sql
ALTER TABLE app.media_assets
ADD COLUMN display_name text,
ADD COLUMN default_max_plays smallint,
ADD COLUMN replaced_by uuid,
ADD CONSTRAINT media_assets_display_name_check
CHECK (char_length(display_name) BETWEEN 1 AND 200 AND display_name ~ '\S'),
ADD CONSTRAINT media_assets_default_max_plays_check
CHECK (default_max_plays IS NULL
OR (default_max_plays BETWEEN 0 AND 3 AND kind = 'audio')),
ADD CONSTRAINT media_assets_replaced_by_fkey
FOREIGN KEY (replaced_by) REFERENCES app.media_assets(id) ON DELETE SET NULL,
ADD CONSTRAINT media_assets_replaced_by_check CHECK (replaced_by <> id);
-- 00083_add_media_assets_dimensions.sql
ALTER TABLE app.media_assets
ADD COLUMN width integer,
ADD COLUMN height integer,
ADD CONSTRAINT media_assets_dimensions_paired CHECK ((width IS NULL) = (height IS NULL)),
ADD CONSTRAINT media_assets_dimensions_positive CHECK (width > 0 AND height > 0),
ADD CONSTRAINT media_assets_dimensions_image_only CHECK (width IS NULL OR kind = 'image');
-- 00084_widen_media_assets_audio_bytes.sql
ALTER TABLE app.media_assets
ADD CONSTRAINT media_assets_bytes_by_kind
CHECK (bytes > 0 AND bytes <= CASE WHEN kind = 'audio' THEN 52428800 ELSE 10485760 END)
NOT VALID;
ALTER TABLE app.media_assets VALIDATE CONSTRAINT media_assets_bytes_by_kind;
ALTER TABLE app.media_assets DROP CONSTRAINT media_assets_bytes_check;-
display_nameis the name the library shows; NULL means the original filename, which never changes. The server stores it trimmed, somedia_assets_display_name_checkasks for 1 to 200 characters and one that is not white space. Readers selectcoalesce(display_name, original_filename). -
default_max_playsis the play limit a new question starts from: NULL is "not set" and leaves the editor's own default, 0 is unlimited, 1 to 3 a limit.media_assets_default_max_plays_checkkeeps it off an image, asquestions_audio_policy_iff_audiokeeps an audio policy off a question without audio. Nothing in the server applies it to a question: the editor reads it (T-R4.65). -
replaced_bynames the file that took this one's place. T-R4.17b writes it; T-R4.17a only reads it. The library is the rows withdeleted_at IS NULL AND replaced_by IS NULL, one predicate (media/repositories/list.go,library) under the list, its total, the facets, the usage and the quota sum.Get,Readable,ReferencesFor,LockForVersionUseandSoftDeletedo not ask it, so a replaced file keeps serving the published versions and the other owners' rows that still point at it.ON DELETE SET NULL: removing the newer row returns the older one to the library.media_assets_replaced_by_checkrefuses a file replaced by itself. No index on the column: a row is hard-deleted only by a test, and the release adds no index to a populated table. Replacement uses the existing 00082–00084 fields and constraints; T-R4.17b adds no migration or index. It locks owner-eligible groups and current questions in sorted order, then the old asset and its owner's existing quota advisory lock. After waiting it rechecks owner, library eligibility, kind, name and play limit; an owner change aborts rather than acquiring another owner's locks. It inserts with explicit locked owner and acting uploader, repoints only the retained current graph and audits the counts. Published versions, import review JSON, foreign bindings and test timestamps stay unchanged. Its repository entry accepts a concrete pool and owns the outer transaction; raw/acquired connections, wrappers and nested transactions are refused before mutation. -
widthandheightare an image's pixels, read from its header at upload (platform/probe/image.go). Both are NULL for audio, for an image whose header could not be read, and for every row stored before 00083: there is no backfill. Three checks, so a violation names its rule: both or neither, positive, images only. -
The size limit depends on the kind. 00010's unnamed column CHECK, which
PostgreSQL named
media_assets_bytes_check, held every file to 10 MiB.media_assets_bytes_by_kindallows audio 52428800 bytes (50 MiB) and an image 10485760 (DG-63). It is addedNOT VALIDand validated before the old constraint is dropped, so no moment has neither rule. Down re-adds the old CHECKNOT VALID: audio above 10 MiB may exist by then, and the constraint still binds every row written afterwards. -
questionCountis computed, not stored. A file is used by a question that is not deleted and attaches it (questions.media_asset_id), and by every member of a group whosegroup_recordingsorgroup_stimulus_assetsname it, each question once and whoever owns it. A member cannot be soft-deleted (questions_context_complete, 00035), so every member is live. Theunusedfilter and the facet of that name areNOT EXISTSover the same subquery (questionsUsing), so a tab and a card cannot disagree. A published version and a group without members add nothing: such a file is "not used" and its delete is still refused. -
The search is
app.immutable_unaccent(lower(coalesce(display_name, '') || ' ' || original_filename)) LIKE '%' || app.immutable_unaccent(lower($n)) || '%' ESCAPE '\', the term throughdb.EscapeLike. No index: the rows are one owner's, behindmedia_assets_owner_idx. - The quota is checked inside the insert's transaction, under an advisory lock keyed by the owner (§28).
Normalized, not jsonb (§13.3). Options and blanks are reordered by the builder,
edited individually, and graded against.
CREATE TABLE app.questions (
id uuid PRIMARY KEY DEFAULT uuidv7(),
type app.question_type NOT NULL,
prompt text NOT NULL CHECK (prompt <> ''),
media_asset_id uuid,
media_asset_kind app.media_kind, -- [D-05]
audio_max_plays integer CHECK (audio_max_plays IS NULL
OR audio_max_plays > 0),
audio_allow_seek boolean, -- [D-04]
audio_show_transcript_after boolean, -- [D-04]
transcript text,
points numeric(8,2) NOT NULL
CHECK (points > 0 AND points <= 999999.99),
explanation text,
sample_answer text,
tags text[] NOT NULL DEFAULT '{}',
created_by uuid NOT NULL
REFERENCES app.users(id) ON DELETE RESTRICT,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
FOREIGN KEY (media_asset_id, media_asset_kind)
REFERENCES app.media_assets (id, kind) ON DELETE RESTRICT,
CONSTRAINT questions_media_pair_complete
CHECK ((media_asset_id IS NULL) = (media_asset_kind IS NULL)),
CONSTRAINT questions_audio_policy_iff_audio
CHECK ((media_asset_kind IS NOT DISTINCT FROM 'audio'::app.media_kind)
= (audio_allow_seek IS NOT NULL
AND audio_show_transcript_after IS NOT NULL)),
CONSTRAINT questions_transcript_iff_audio
CHECK (transcript IS NULL
OR media_asset_kind IS NOT DISTINCT FROM 'audio'::app.media_kind),
CONSTRAINT questions_sample_answer_only_short_answer
CHECK (sample_answer IS NULL OR type = 'short_answer')
);
CREATE TRIGGER questions_set_updated_at BEFORE UPDATE ON app.questions
FOR EACH ROW EXECUTE FUNCTION app.set_updated_at();Indexes:
CREATE INDEX questions_tags_idx
ON app.questions USING gin (tags) WHERE deleted_at IS NULL;
CREATE INDEX questions_prompt_fts_idx
ON app.questions USING gin (to_tsvector('simple', prompt)) WHERE deleted_at IS NULL;
CREATE INDEX questions_prompt_trgm_idx
ON app.questions USING gin (app.immutable_unaccent(lower(prompt)) gin_trgm_ops)
WHERE deleted_at IS NULL;
CREATE INDEX questions_media_idx
ON app.questions (media_asset_id) WHERE media_asset_id IS NOT NULL;
-- Answers "which BANK questions use this asset". The delete-blocking check in
-- §8 is a different query, against `test_version_questions`, served by
-- `tvq_media_idx` in 00017 -- both indexes are wanted, for different questions.
CREATE INDEX questions_type_id_idx
ON app.questions (type, id DESC) WHERE deleted_at IS NULL;-
audio_max_playsneeded a second constraint, added in00014. The biconditional below coversaudio_allow_seekandaudio_show_transcript_afterand does not mentionaudio_max_plays. Leaving it out of the right-hand side is right —maxPlaysis[integer, 'null']with null meaning unlimited, so a NULL on an audio question is a value, not an absence, and requiring it would forbid "unlimited". But the other direction was missing: on a question with no media the left side is false, which the biconditional satisfies as soon as one of the two booleans is NULL, leavingaudio_max_playsunconstrained. Ashort_answerquestion withaudio_max_plays = 3satisfied every CHECK on the table.questions_max_plays_only_with_audiocloses it, one-directional likequestions_transcript_iff_audiobeside it. -
Audio policy columns are nullable [D-04]. §13.3 declares them
NOT NULL DEFAULT, which cannot express §7'saudio?: AudioPolicy— "present iffmedia.kind === 'audio'". WithNOT NULL DEFAULT false, every text question carries a meaninglessaudio_allow_seek, and the API layer has to decide when to suppress it. Nullable plus a biconditionalCHECKmakes the database the one that knows. Defaults from §11.1 (maxPlays2,allowSeekfalse,showTranscriptAfterSubmittrue, andallowPausetrue, the fourth control, added by00114in §44) move to the application, which is where they belong — they are authoring defaults, not storage defaults. -
questions_sample_answer_only_short_answerencodes §7's "short_answer; ADMIN ONLY". The column-list discipline in §11 of this document is what keeps it out of student payloads; this constraint keeps it from being set on a question type where the grading UI would never show it. -
All bank indexes are partial on
deleted_at IS NULL[D-12]. Every bank query filters deleted rows, so the predicate is free and the index stays smaller — directly from the indexing reference's partial-index guidance. -
questions_prompt_trgm_idxis an addition [D-11]. §13.3 gives onlyto_tsvector('simple', prompt).'simple'does no stemming and no diacritic folding, so in a Vietnamese-first product a search fornghewill not matchnghé, andphat amwill not matchphát âm.pg_trgmalone does not fix this — an earlier draft of this document claimed it did, and that was wrong. Verified on 18.6:'nghé' ILIKE '%nghe%'is false, andsimilarity('nghé','nghe')is only 0.43. Trigram matching is case-insensitive but not accent-insensitive.So the index folds accents explicitly, through
app.immutable_unaccent. Queries must use the identical expression or the planner will not match the index:WHERE app.immutable_unaccent(lower(prompt)) LIKE '%' || app.immutable_unaccent(lower($1)) || '%'
Verified that the wrapper folds Vietnamese correctly, including the
Đ/đstroke that plain Unicode decomposition misses:Đường→duong,tiếng Việt→tieng viet.Both indexes are kept:
@@against the tsvector for word queries, trigram for substring and accent-insensitive matching.to_tsvector(regconfig, text)with a literal config isIMMUTABLE, so that expression index is legal as written; the one-argument form isSTABLEand would be rejected. -
A stored
tsvectorgenerated column would be the indexing reference's preferred shape, but it buys little here (prompts are short, the table is small) and it would foreclose addingunaccent, which is notIMMUTABLEwithout a wrapper. Expression index for now; revisit only if search gets slow. -
questions_type_id_idxserves §8's type filter plus keyset pagination in one index (uuidv7()PKs makeid DESCa valid time order, §13.8). Tag filtering goes through the GIN index; combining the two is a bitmap AND, which is correct.
CREATE TABLE app.question_options (
id uuid PRIMARY KEY DEFAULT uuidv7(),
question_id uuid NOT NULL REFERENCES app.questions(id) ON DELETE CASCADE,
ordinal smallint NOT NULL CHECK (ordinal >= 0),
text text NOT NULL CHECK (text <> ''),
is_correct boolean NOT NULL DEFAULT false,
CONSTRAINT question_options_ordinal_key UNIQUE (question_id, ordinal)
DEFERRABLE INITIALLY IMMEDIATE -- [D-13]
);-
ON DELETE CASCADE: an option is a true owned child (§13.2). -
No separate
(question_id)index — theUNIQUE (question_id, ordinal)prefix serves every lookup, and adding one would be the redundancy the indexing reference warns about. -
DEFERRABLE INITIALLY IMMEDIATE[D-13]. The builder reorders options by drag-and-drop (§8).Corrected against 18.6 in T-2.5. This paragraph used to say that rewriting ordinals 0,1,2 → 1,2,0 in one
UPDATEtransiently violates uniqueness. It does not: Postgres checks a unique constraint at end of statement, so a set-based permutation is accepted with no deferral at all. What genuinely needs deferral is the multi-statement reorder — the shape a real one takes, since the client sends a list of(id, ordinal)pairs and the server writes them one row at a time, colliding with rows that have not moved yet. D-13 is right; this justification for it was not.server/internal/db/reorder_test.gopins both halves so the claim cannot rot again.Deferrable lets the reorder transaction issue
SET CONSTRAINTS app.question_options_ordinal_key DEFERREDand write the new ordinals directly, instead of the two-phase negative-offset trick. Applied to every ordinal-unique on a draft-editable table:question_options,question_blanks,test_sections,test_section_questions. Not applied to thetest_version_*tables, which are written once and never reordered.Used by a question save from the H-05 change on.
updateQuestionkeeps an option's and a blank's id: a row the write names by an id the question holds is updated in place, with its new ordinal written underSET CONSTRAINTS app.question_options_ordinal_key DEFERRED(blanks:app.question_blanks_ordinal_key) one statement at a time, then checked withIMMEDIATE; a row sent without an id, or with one the question does not hold, is inserted; a row not sent is deleted.question_blanks_gap_keyis not deferrable, so a kept blank whosegap_idchanges has it set toNULLfirst, which lets two blanks swap gaps. An AI check's held finding names an option or blank by id and so still matches after the teacher saves the question.
§13.3 says only "question_blanks and question_blank_answers mirror this".
CREATE TABLE app.question_blanks (
id uuid PRIMARY KEY DEFAULT uuidv7(),
question_id uuid NOT NULL REFERENCES app.questions(id) ON DELETE CASCADE,
ordinal smallint NOT NULL CHECK (ordinal >= 1),
case_sensitive boolean NOT NULL DEFAULT false,
CONSTRAINT question_blanks_ordinal_key UNIQUE (question_id, ordinal)
DEFERRABLE INITIALLY IMMEDIATE
);
CREATE TABLE app.question_blank_answers (
id uuid PRIMARY KEY DEFAULT uuidv7(),
blank_id uuid NOT NULL REFERENCES app.question_blanks(id) ON DELETE CASCADE,
answer text NOT NULL CHECK (answer <> ''),
UNIQUE (blank_id, answer)
);-
ordinal >= 1, not>= 0. Blanks are addressed from the prompt Markdown by a 1-indexed placeholder ({{1}},{{2}}— the default in40-open-items.md), so a 0-ordinal blank would be unreachable. §7'sblanks[].ordinalis silent on the base; this pins it. -
UNIQUE (blank_id, answer)is exact, notlower(answer)—case_sensitiveblanks legitimately wantCatandcatas distinct accepted answers. Case-insensitive matching happens at grading time againstcase_sensitive. - Publish validation (§8) enforces "≥1 accepted answer per blank" and that the
prompt's placeholder set equals the blank ordinal set. Neither is expressible
as a
CHECK— both span tables — so both live in T-2.10 with tests.
§13.3 jumps from tests straight to the version tables, leaving the draft
structure the builder autosaves into (§8, debounced 1.5s) undefined. Three
draft tables are added [D-14].
CREATE TABLE app.tests (
id uuid PRIMARY KEY DEFAULT uuidv7(),
title text NOT NULL CHECK (length(title) BETWEEN 1 AND 200),
description text,
status app.test_status NOT NULL DEFAULT 'draft',
current_version integer NOT NULL DEFAULT 0 CHECK (current_version >= 0),
created_by uuid NOT NULL REFERENCES app.users(id) ON DELETE RESTRICT,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
CONSTRAINT tests_published_has_version
CHECK (status = 'draft' OR current_version > 0)
);
CREATE INDEX tests_status_id_idx
ON app.tests (status, id DESC) WHERE deleted_at IS NULL;
CREATE TRIGGER tests_set_updated_at BEFORE UPDATE ON app.tests
FOR EACH ROW EXECUTE FUNCTION app.set_updated_at();tests_published_has_version makes §7's currentVersion honest: a test cannot
be published or archived with no snapshot behind it.
CREATE TABLE app.test_sections (
id uuid PRIMARY KEY DEFAULT uuidv7(),
test_id uuid NOT NULL REFERENCES app.tests(id) ON DELETE CASCADE,
ordinal smallint NOT NULL CHECK (ordinal >= 0),
title text NOT NULL CHECK (title <> ''),
instructions text,
CONSTRAINT test_sections_ordinal_key UNIQUE (test_id, ordinal)
DEFERRABLE INITIALLY IMMEDIATE
);
CREATE TABLE app.test_section_questions (
id uuid PRIMARY KEY DEFAULT uuidv7(),
test_section_id uuid NOT NULL REFERENCES app.test_sections(id) ON DELETE CASCADE,
ordinal smallint NOT NULL CHECK (ordinal >= 0),
question_id uuid NOT NULL REFERENCES app.questions(id) ON DELETE RESTRICT,
CONSTRAINT test_section_questions_ordinal_key UNIQUE (test_section_id, ordinal)
DEFERRABLE INITIALLY IMMEDIATE,
CONSTRAINT test_section_questions_no_dupes UNIQUE (test_section_id, question_id)
);
CREATE INDEX test_section_questions_question_idx
ON app.test_section_questions (question_id);-
question_id ON DELETE RESTRICT, notCASCADE: a draft references a bank question, it does not own it. Questions are soft-deleted anyway (§13.2), so the restriction fires only on a hard purge — which is exactly when someone should be told a draft still uses it. -
test_section_questions_question_idxexists because of thatRESTRICT. The schema-design reference is explicit: Postgres does not index FK columns, and without one, a parent delete scans and locks the child table. It also serves §8's "where used" on the question bank.
Immutable snapshot root. No updated_at, no soft delete — a published version
is a historical fact.
CREATE TABLE app.test_versions (
id uuid PRIMARY KEY DEFAULT uuidv7(),
test_id uuid NOT NULL REFERENCES app.tests(id) ON DELETE RESTRICT,
version integer NOT NULL CHECK (version > 0),
total_points numeric(8,2) NOT NULL CHECK (total_points > 0),
published_at timestamptz NOT NULL DEFAULT now(),
published_by uuid NOT NULL REFERENCES app.users(id) ON DELETE RESTRICT,
UNIQUE (test_id, version),
CONSTRAINT test_versions_id_test_key UNIQUE (id, test_id) -- [D-17]
);
CREATE TABLE app.test_version_sections (
id uuid PRIMARY KEY DEFAULT uuidv7(),
test_version_id uuid NOT NULL
REFERENCES app.test_versions(id) ON DELETE CASCADE,
ordinal smallint NOT NULL CHECK (ordinal >= 0),
title text NOT NULL,
instructions text,
UNIQUE (test_version_id, ordinal)
);-
test_id ON DELETE RESTRICTontest_versions, against §13.2's "CASCADEonly for true owned children". A version is owned by a test, but attempts reference versions withRESTRICT, so cascading here would only ever produce a constraint violation two levels down with a confusing message.RESTRICTfails at the right place. Tests are soft-deleted regardless. -
UNIQUE (id, test_id)[D-17] is the composite-FK target that letsassignmentsprove itstest_version_idactually belongs to itstest_id(§9 below). Same technique asmedia_assets. -
total_pointsis stored, not derived. §7 calls it "server-computed"; computing it once at publish and freezing it means the score denominator on a two-year-old attempt cannot drift.
CREATE TABLE app.test_version_questions (
id uuid PRIMARY KEY DEFAULT uuidv7(),
test_version_section_id uuid NOT NULL
REFERENCES app.test_version_sections(id) ON DELETE CASCADE,
ordinal smallint NOT NULL CHECK (ordinal >= 0),
source_question_id uuid
REFERENCES app.questions(id) ON DELETE SET NULL, -- [D-07]
type app.question_type NOT NULL,
prompt text NOT NULL,
media_asset_id uuid,
media_asset_kind app.media_kind,
audio_max_plays integer CHECK (audio_max_plays IS NULL
OR audio_max_plays > 0),
audio_allow_seek boolean,
audio_show_transcript_after boolean,
transcript text,
points numeric(8,2) NOT NULL CHECK (points > 0),
explanation text,
sample_answer text,
UNIQUE (test_version_section_id, ordinal),
FOREIGN KEY (media_asset_id, media_asset_kind)
REFERENCES app.media_assets (id, kind) ON DELETE RESTRICT,
CONSTRAINT tvq_media_pair_complete
CHECK ((media_asset_id IS NULL) = (media_asset_kind IS NULL)),
CONSTRAINT tvq_audio_policy_iff_audio
CHECK ((media_asset_kind IS NOT DISTINCT FROM 'audio'::app.media_kind)
= (audio_allow_seek IS NOT NULL
AND audio_show_transcript_after IS NOT NULL))
);
CREATE INDEX tvq_media_idx
ON app.test_version_questions (media_asset_id) WHERE media_asset_id IS NOT NULL;
CREATE INDEX tvq_source_idx
ON app.test_version_questions (source_question_id)
WHERE source_question_id IS NOT NULL;CREATE TABLE app.test_version_options (
id uuid PRIMARY KEY DEFAULT uuidv7(),
test_version_question_id uuid NOT NULL
REFERENCES app.test_version_questions(id) ON DELETE CASCADE,
ordinal smallint NOT NULL CHECK (ordinal >= 0),
text text NOT NULL,
is_correct boolean NOT NULL DEFAULT false,
UNIQUE (test_version_question_id, ordinal)
);
CREATE TABLE app.test_version_blanks (
id uuid PRIMARY KEY DEFAULT uuidv7(),
test_version_question_id uuid NOT NULL
REFERENCES app.test_version_questions(id) ON DELETE CASCADE,
ordinal smallint NOT NULL CHECK (ordinal >= 1),
case_sensitive boolean NOT NULL DEFAULT false,
UNIQUE (test_version_question_id, ordinal)
);
CREATE TABLE app.test_version_blank_answers (
id uuid PRIMARY KEY DEFAULT uuidv7(),
test_version_blank_id uuid NOT NULL
REFERENCES app.test_version_blanks(id) ON DELETE CASCADE,
answer text NOT NULL,
UNIQUE (test_version_blank_id, answer)
);-
tvq_media_idxis load-bearing, not decorative. It is the index behind §15'sDELETE /teacher/media/:id → 409 if referenced by a published versionand behind §8's "Delete blocked if referenced". Without it that check is a full scan of every version question on every delete attempt. -
source_question_id ON DELETE SET NULL[D-07], deviating from theRESTRICTdefault in §13.2. §13.3 wantssource_question_idto "preserve the bank link without coupling to it" —RESTRICTis coupling: it would let a three-year-old frozen version veto a bank cleanup forever. The column is nullable and purely informational (it powers "which bank question did this come from"); losing it degrades a link, not the attempt. - No
is_correctoraccepted_answersever leaves these tables toward a student. See §11. - The snapshot is normalized rather than
jsonb, per §13.3 and Thuong's decision.00-overview.md§5 records the alternative that was considered.
§13.3 references app.assignments(id) but never defines the table. Full DDL,
with §7's nested objects flattened — three booleans and four policy fields do
not earn separate tables, and flattening keeps the monitor query a single row
read.
CREATE TABLE app.assignments (
id uuid PRIMARY KEY DEFAULT uuidv7(),
test_id uuid NOT NULL,
test_version_id uuid NOT NULL,
opens_at timestamptz NOT NULL,
closes_at timestamptz NOT NULL,
closed_at timestamptz, -- [D-18]
duration_minutes integer NOT NULL CHECK (duration_minutes BETWEEN 1 AND 600),
max_attempts smallint NOT NULL DEFAULT 1 CHECK (max_attempts > 0),
shuffle_questions boolean NOT NULL DEFAULT false,
shuffle_options boolean NOT NULL DEFAULT false,
review_show_score boolean NOT NULL DEFAULT true,
review_show_correct_answers boolean NOT NULL DEFAULT false,
review_show_explanations boolean NOT NULL DEFAULT false,
integrity_require_fullscreen boolean NOT NULL DEFAULT false,
integrity_block_copy_paste boolean NOT NULL DEFAULT true,
integrity_max_focus_loss integer NOT NULL DEFAULT 0
CHECK (integrity_max_focus_loss >= 0),
integrity_on_limit_exceeded app.integrity_action NOT NULL DEFAULT 'flag',
integrity_min_away_ms integer NOT NULL DEFAULT 3000
CHECK (integrity_min_away_ms >= 0),
created_by uuid NOT NULL REFERENCES app.users(id) ON DELETE RESTRICT,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
FOREIGN KEY (test_version_id, test_id)
REFERENCES app.test_versions (id, test_id) ON DELETE RESTRICT, -- [D-17]
CHECK (closes_at > opens_at),
CHECK (closed_at IS NULL OR closed_at >= opens_at)
);
CREATE INDEX assignments_window_idx ON app.assignments (opens_at, closes_at);
CREATE INDEX assignments_version_idx ON app.assignments (test_version_id);
CREATE TRIGGER assignments_set_updated_at BEFORE UPDATE ON app.assignments
FOR EACH ROW EXECUTE FUNCTION app.set_updated_at();-
The integrity defaults are §10.3 verbatim —
requireFullscreen: false,blockCopyPaste: true,maxFocusLoss: 0,onLimitExceeded: 'flag'. They live in the DDL so a new assignment is conservative even if a handler forgets.integrity_min_away_msdefaults to §10.1's 3000. -
There is no
statuscolumn [D-18]. §7 types it, but it is a pure function ofopens_at,closes_atandclosed_at, and storing it would require a scheduler to flip rows at two timestamps per assignment — a cron job, a missed tick, and a stale-status bug class, all for a value the API can compute in the projection.closed_atexists so the admin can close early, which is the one thing the window cannot express. -
The composite FK [D-17] proves
test_version_idbelongs totest_id. Without it, an assignment could reference version 3 of test A while claiming test B, and every downstream join would silently produce the wrong paper. -
test_version_idis immutable once an attempt exists. This is enforced in the application (T-3.2), not the schema: re-pointing an assignment with zero attempts to a corrected version is a legitimate workflow, and a constraint strong enough to stop the dangerous case would also forbid the safe one. Existing attempts always carry their owntest_version_idregardless (§10). -
assignments_window_idxserves §9's due/upcoming/completed partitioning.
§7's targets: { classIds, studentIds } — two link tables, not an array column.
Arrays would make "which assignments is this student eligible for" a GIN
containment query against a growing list instead of an index scan, and would
lose referential integrity to a deleted class.
CREATE TABLE app.assignment_classes (
assignment_id uuid NOT NULL REFERENCES app.assignments(id) ON DELETE CASCADE,
class_id uuid NOT NULL REFERENCES app.classes(id) ON DELETE RESTRICT,
PRIMARY KEY (assignment_id, class_id)
);
CREATE INDEX assignment_classes_class_idx ON app.assignment_classes (class_id);
CREATE TABLE app.assignment_students (
assignment_id uuid NOT NULL REFERENCES app.assignments(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES app.users(id) ON DELETE RESTRICT,
PRIMARY KEY (assignment_id, user_id)
);
CREATE INDEX assignment_students_user_idx ON app.assignment_students (user_id);-
assignment_id CASCADE(owned child), targetRESTRICT(referenced entity). Deleting a class that is still targeted should fail loudly, not silently un-assign a test. - Both reverse indexes are required:
/app/assignments(§9) resolves "assignments for this student" from both directions in one query. - Removing a student from a class does not revoke an in-flight attempt. §6.4 says attempts are retained. Eligibility is evaluated at attempt creation; after that the attempt stands on its own.
The hot path. Everything here is read on every autosave.
CREATE TABLE app.attempts (
id uuid PRIMARY KEY DEFAULT uuidv7(),
assignment_id uuid NOT NULL REFERENCES app.assignments(id) ON DELETE RESTRICT,
test_version_id uuid NOT NULL REFERENCES app.test_versions(id) ON DELETE RESTRICT,
student_id uuid NOT NULL REFERENCES app.users(id) ON DELETE RESTRICT,
attempt_no smallint NOT NULL CHECK (attempt_no > 0),
status app.attempt_status NOT NULL DEFAULT 'in_progress',
session_id uuid NOT NULL, -- takeover detection
shuffle_seed bigint NOT NULL, -- [D-02]
beacon_token_hash bytea NOT NULL CHECK (length(beacon_token_hash) = 32), -- [D-03]
started_at timestamptz NOT NULL DEFAULT now(),
deadline_at timestamptz NOT NULL, -- authoritative
submitted_at timestamptz,
graded_at timestamptz,
score_earned numeric(8,2) CHECK (score_earned IS NULL OR score_earned >= 0),
score_total numeric(8,2) CHECK (score_total IS NULL OR score_total > 0),
focus_loss_count integer NOT NULL DEFAULT 0 CHECK (focus_loss_count >= 0),
flagged boolean NOT NULL DEFAULT false,
void_reason text,
UNIQUE (assignment_id, student_id, attempt_no),
CHECK (deadline_at > started_at),
CONSTRAINT attempts_live_not_submitted
CHECK (status <> 'in_progress' OR submitted_at IS NULL),
CONSTRAINT attempts_graded_has_timestamp
CHECK (status <> 'graded' OR graded_at IS NOT NULL),
CONSTRAINT attempts_void_has_reason
CHECK ((status = 'voided') = (void_reason IS NOT NULL))
);Indexes:
CREATE UNIQUE INDEX attempts_one_live
ON app.attempts (assignment_id, student_id) WHERE status = 'in_progress';
CREATE INDEX attempts_assignment_status_idx ON app.attempts (assignment_id, status);
CREATE INDEX attempts_student_started_idx ON app.attempts (student_id, started_at DESC);
CREATE INDEX attempts_version_idx ON app.attempts (test_version_id);
CREATE INDEX attempts_grading_queue_idx
ON app.attempts (submitted_at) WHERE status = 'submitted';
CREATE INDEX attempts_flagged_idx
ON app.attempts (id) WHERE flagged;-
shuffle_seedis new and non-optional [D-02]. §7 hasshuffleQuestions/shuffleOptionson the assignment, and §14's E2E 2 requires answers to survive a mid-test reload. If the order is regenerated per request, a reload reshuffles and every stored answer binds to the wrong option — silent, and it corrupts the grade rather than crashing. The seed is drawn once at attempt creation and the ordering is a pure function of(seed, question_id), so client, server, and the grading pass all derive the same order. -
beacon_token_hashis new [D-03].navigator.sendBeacon(§10.6) cannot set anAuthorizationheader, and the 15-minute access token (§5.2) is normally expired by thepagehideof a 60-minute test. See00-overview.md§4.3. Stored hashed for the same reason refresh tokens are. -
All three FKs are
RESTRICT. An attempt is evidence. Deleting an assignment, a version, or a student must fail rather than erase a graded record. §6.4 already says removing a class member retains attempts. -
attempts_one_liveis §13.3's, unchanged, and is what makesPOST /app/assignments/:id/attempts(§15) safely idempotent under a double-tap: the second insert loses the race and the handler resumes instead. -
attempts_grading_queue_idxandattempts_flagged_idxare the two dashboard counts §8 needs and §15 has no endpoint for. Both are partial and therefore near-empty most of the time — the indexing reference's exact use case. - No
extended_bycolumn: deadline extensions rewritedeadline_atand the history lives inaudit_log(§13.4), which is where §8's "confirm + reason" already goes.
CREATE TABLE app.attempt_answers (
attempt_id uuid NOT NULL REFERENCES app.attempts(id) ON DELETE CASCADE,
question_id uuid NOT NULL
REFERENCES app.test_version_questions(id) ON DELETE RESTRICT,
payload jsonb NOT NULL CHECK (jsonb_typeof(payload) = 'object'),
requires_manual boolean NOT NULL DEFAULT false, -- [D-19]
auto_score numeric(8,2) CHECK (auto_score IS NULL OR auto_score >= 0),
manual_score numeric(8,2) CHECK (manual_score IS NULL OR manual_score >= 0),
final_score numeric(8,2)
GENERATED ALWAYS AS (coalesce(manual_score, auto_score)) VIRTUAL,
grader_comment text,
graded_by uuid REFERENCES app.users(id) ON DELETE SET NULL, -- [D-19]
graded_at timestamptz, -- [D-19]
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (attempt_id, question_id),
CONSTRAINT attempt_answers_manual_grade_paired
CHECK ((manual_score IS NULL) = (graded_at IS NULL))
);
CREATE INDEX attempt_answers_question_idx ON app.attempt_answers (question_id);
CREATE INDEX attempt_answers_pending_idx ON app.attempt_answers (attempt_id)
WHERE requires_manual AND manual_score IS NULL;
CREATE TRIGGER attempt_answers_set_updated_at BEFORE UPDATE ON app.attempt_answers
FOR EACH ROW EXECUTE FUNCTION app.set_updated_at();-
final_scoreis a PG18 virtual generated column, exactly as §13.3 specifies — computed on read, no storage, never stale (ddl-generated-columns). VIRTUAL is the PG18 default; it is written explicitly anyway so the intent survives a reader who does not know that.The constraints this imposes, verified against 18.6 rather than recalled:
Operation on a virtual generated column PG18.6 CREATE INDEX❌ "indexes on virtual generated columns are not supported" UNIQUE❌ "unique constraints on virtual generated columns are not supported" PRIMARY KEY❌ "primary keys on virtual generated columns are not supported" CREATE STATISTICS❌ "statistics creation on virtual generated columns is not supported" SET NOT NULL✅ allowed CHECKreferencing it✅ allowed sum()/ aggregates✅ allowed Logical replication ❌ stored only None of the rejections hurt here —
final_scoreis only ever summed within one attempt (≤ ~100 rows). But the missing index is whypendingManualcannot be derived from it, and whyrequires_manualexists. Do not addORDER BY final_scoreto a cross-attempt query without reading this table. -
requires_manualis new [D-19]. §7'sscore.pendingManualneeds an indexable predicate, andfinal_score IS NULLis not one. Set at answer creation from the question type (short_answer⇒ true) so the grading queue is a partial-index scan rather than a join back totest_version_questions. -
graded_by/graded_atare new [D-19]. §8's grading screen records points and a comment; who graded and when is the minimum for a two-person future (§1.1's third role) and costs two columns. -
payload jsonbis correct here and not a violation of §13.3's "do not stuff options into jsonb". A student answer is a closed variant type (§7'sAnswer) written and read as a unit, never joined, never filtered. The zod schema is the contract; theCHECKonly ensures it is an object, not a bare scalar. -
question_id ON DELETE RESTRICT, referencingtest_version_questions— an answer belongs to the frozen version, never to the mutable bank. This is the structural half of §7's versioning invariant. -
attempt_answers_question_idxsupports theRESTRICTand the per-question analytics the normalized snapshot exists to enable.
CREATE TABLE app.attempt_audio_plays (
attempt_id uuid NOT NULL REFERENCES app.attempts(id) ON DELETE CASCADE,
question_id uuid NOT NULL
REFERENCES app.test_version_questions(id) ON DELETE RESTRICT,
plays integer NOT NULL DEFAULT 0 CHECK (plays >= 0),
last_played_at timestamptz,
PRIMARY KEY (attempt_id, question_id)
);
CREATE INDEX attempt_audio_plays_question_idx
ON app.attempt_audio_plays (question_id);Unchanged from §13.3 apart from the FK index. plays has no upper bound
CHECK against audio_max_plays — deliberately. §11.4 is explicit that
over-limit plays are reported to the teacher, never enforced retroactively, so a
constraint that rejected the write would turn a reporting signal into a 500 on a
fire-and-forget endpoint.
The increment is INSERT … ON CONFLICT (attempt_id, question_id) DO UPDATE SET plays = attempt_audio_plays.plays + 1, last_played_at = now() RETURNING plays —
one statement, atomic, returns the authoritative count §11.4 requires.
CREATE TABLE app.attempt_events (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
attempt_id uuid NOT NULL REFERENCES app.attempts(id) ON DELETE CASCADE,
session_id uuid NOT NULL, -- [D-01]
kind text NOT NULL CHECK (kind <> '' AND length(kind) <= 40),
occurred_at timestamptz NOT NULL, -- client time, offset-corrected
received_at timestamptz NOT NULL DEFAULT now(),
client_seq integer NOT NULL CHECK (client_seq >= 0),
question_id uuid REFERENCES app.test_version_questions(id) ON DELETE SET NULL,
meta jsonb,
UNIQUE (attempt_id, session_id, client_seq) -- [D-01]
);
CREATE INDEX attempt_events_timeline_idx
ON app.attempt_events (attempt_id, occurred_at);
CREATE INDEX attempt_events_question_idx
ON app.attempt_events (question_id) WHERE question_id IS NOT NULL;[D-01] is the most important deviation in this document.
§13.3 declares UNIQUE (attempt_id, client_seq). §10.6 says clientSeq is a
monotonic counter buffered in memory and sessionStorage. sessionStorage does
not survive a tab close, a crash, or a device change — all three of which §10.1
explicitly expects and names (resume, session_takeover). On any of them the
counter restarts at 1, every event of the resumed session collides with an
existing row, and the insert fails.
The failure is silent — §10.6 mandates fire-and-forget — and it destroys exactly
the portion of the timeline the teacher most wants: what happened after the
student came back. Adding session_id to the key scopes the sequence to the
session that generated it, which is what clientSeq always meant.
The server writes three kinds itself, each with a NULL client_seq: start
(once, under an attempt's first session, at started_at), resume and
session_takeover. start gives a first session a row, so a flush that names
it after another device took over is known to Flush (D-025). The liveness
query excludes all three; its predicate is stricter than the partial index's,
which excludes the last two, so the index still serves it and no migration
changes.
Inserts additionally use ON CONFLICT (attempt_id, session_id, client_seq) DO NOTHING, so a retried sendBeacon (which cannot report success, and so is
retried on the next flush) cannot fail a whole batch on one duplicate row.
-
kindistext, not an enum — see §2. Length-bounded so a malformed client cannot write an essay into an indexed-adjacent column. -
question_idFK withON DELETE SET NULL: version questions are never deleted, so this never fires; it is there so "the question on screen" (§10.4) is a real reference rather than a loose uuid. -
bigint IDENTITYPK per §13.3 — narrow sequential key for an append-only log read only byattempt_id. NoUPDATEorDELETEis ever issued; §11 enforces that with grants rather than convention.
GRANT USAGE ON SCHEMA app TO quizzivy_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA app TO quizzivy_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA app TO quizzivy_app;
-- Custom types need their own USAGE; schema USAGE is not enough.
GRANT USAGE ON TYPE app.user_role TO quizzivy_app; -- and the other six
-- Append-only (§13.4). audit_log exists by Phase 1; attempt_events gets the
-- same REVOKE in the migration that creates it (Phase 3).
REVOKE UPDATE, DELETE ON app.audit_log FROM quizzivy_app;
ALTER DEFAULT PRIVILEGES FOR ROLE quizzivy_migrate IN SCHEMA app
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO quizzivy_app;
ALTER DEFAULT PRIVILEGES FOR ROLE quizzivy_migrate IN SCHEMA app
GRANT USAGE, SELECT ON SEQUENCES TO quizzivy_app;Note the ordering: the blanket GRANT … ON ALL TABLES runs first and the two
REVOKEs narrow it afterwards. Reversing them silently re-grants.
§13.3 asserts that no UPDATE or DELETE is ever issued against
attempt_events. This makes that a privilege rather than a promise. Same for
audit_log: an audit trail the application can rewrite is not an audit trail.
The app role never owns anything and cannot run DDL, per §13.5.
It revokes TEMPORARY on the database from PUBLIC, so quizzivy_app can
create nothing in pg_temp, the schema a SECURITY DEFINER function with a
loose search_path is attacked from (PR #193 closed one such hole); the
application creates no temporary object. The database's owner keeps the
privilege, so the migrate role's own temporary tables (pg18_test.go) still
work.
sample_answer, transcript, is_correct, and accepted blank answers must
never reach a student response. Three layers, because one is not enough:
-
No
SELECT *in application code (§13.8). Every student-facing query names its columns. Thetest_version_optionsprojection for a student isid, ordinal, text—is_correctis not in the list and cannot be accidentally serialized by adding a struct field. -
Separate Go types.
StudentQuestionandAdminQuestionare distinct structs in distinct packages, not one struct withjson:"-"tags. A forgotten tag is invisible; a missing field is a compile error. -
A test that asserts the keys are absent from
GET /app/attempts/:id, required by §13.5 and listed as E2E 9 in §14. It walks the response JSON recursively and fails onisCorrect,sampleAnswer,acceptedAnswers, ortranscriptat any depth — not just at the top level, which is where a nested-object regression would hide.
transcript is the one exception and only on GET /app/attempts/:id/result,
gated on audio_show_transcript_after (§11.3). It is served from the result
endpoint and never from the attempt endpoint, so the take-test payload has no
code path that could leak it.
Every departure from the §13.3 sketch, with the reason in one line. Anything not listed here matches the spec.
| # | Deviation | Why |
|---|---|---|
| D-01 |
attempt_events: add session_id, key becomes UNIQUE (attempt_id, session_id, client_seq), insert ON CONFLICT DO NOTHING
|
sessionStorage-scoped clientSeq restarts at 1 on resume/takeover, silently discarding the resumed session's whole timeline (§10) |
| D-02 |
attempts: add shuffle_seed bigint NOT NULL
|
Shuffle must be reproducible across reload and grading; §7 stores the flags but no seed, so a reload rebinds answers to the wrong options |
| D-03 |
attempts: add beacon_token_hash bytea NOT NULL
|
sendBeacon cannot send Authorization, and the access token has usually expired by pagehide (§10.6) |
| D-04 |
questions / test_version_questions: audio policy columns nullable + biconditional CHECK
|
§13.3's NOT NULL DEFAULT cannot express §7's audio?: AudioPolicy ("present iff media.kind === 'audio'") |
| D-05 |
media_assets: add UNIQUE (id, kind); referencing tables carry media_asset_kind and a composite FK |
Makes "audio policy ⇒ audio asset" a database constraint instead of an application convention |
| D-06 |
media_assets.checksum_sha256: plain index, never UNIQUE
|
§13.3's "dedupe" comment contradicts §11.1's immutability rule; immutability wins, checksum becomes a warning + integrity check |
| D-07 |
test_version_questions.source_question_id: ON DELETE SET NULL, nullable |
RESTRICT would let a frozen version veto bank cleanup forever; §13.3 wants the link "without coupling to it" |
| D-08 |
user_identities: add UNIQUE (user_id, provider), drop the standalone (user_id) index |
§15's unlink endpoint assumes one Google identity per user; the composite makes the single-column index redundant |
| D-09 |
class_join_codes: add CHECK (expires_at > created_at) and CHECK (uses_count <= max_uses)
|
§6.5 treats the code as a bearer secret; exhaustion must not be enforceable only in a handler |
| D-10 | Add class_members.added_by + join_code_id; ip columns are inet not text
|
§6.4 wants unexpected enrolments spottable — after a rotation, which code someone used is the useful fact |
| D-11 |
questions: add gin (app.immutable_unaccent(lower(prompt)) gin_trgm_ops) alongside the tsvector index; requires pg_trgm + unaccent + an IMMUTABLE wrapper |
'simple' does no diacritic folding, and pg_trgm alone does not either — 'nghé' ILIKE '%nghe%' is false. Vietnamese-first search needs explicit accent folding (§12) |
| D-12 | All bank/test/media indexes partial on deleted_at IS NULL
|
Every query filters deleted rows, so the predicate is free and the index stays smaller |
| D-13 | Ordinal uniques on draft-editable tables are DEFERRABLE INITIALLY IMMEDIATE
|
Drag-to-reorder (§8) transiently violates uniqueness mid-transaction |
| D-14 | Add test_sections + test_section_questions
|
§13.3 defines no draft structure, but §8's builder autosaves one every 1.5s |
| D-15 | Add app.integrity_action enum |
§7's onLimitExceeded is a closed three-value set (§13.2) |
| D-16 |
users: CHECK (NOT must_change_password OR password_hash IS NOT NULL)
|
Encodes §5.4's "Google-only users never hit this" |
| D-17 |
test_versions: add UNIQUE (id, test_id); assignments uses a composite FK |
Prevents an assignment referencing a version of a different test |
| D-18 |
assignments: no status column; add closed_at
|
§7's status is a pure function of the window; storing it needs a scheduler and invents a stale-state bug class |
| D-19 |
attempt_answers: add requires_manual, graded_by, graded_at
|
final_score is VIRTUAL and unindexable, so pendingManual needs a real-column predicate |
| D-20 | Add maintenance_windows, read-only for the app role; written only through cmd/maintenance, whose extensions are audited as System |
§13 has no maintenance state; the API must be able to answer 503 without being able to schedule, move or cancel a window itself (T-R1.12) |
| D-21 | Reference data the app cannot run without is written by a migration, not seed/: the permission catalogue (permissions), the four built-in roles and their grants (role_permissions) |
§13.7 keeps seed data out of migrations, but a production database with no roles cannot sign anyone in; plan 70 §3 (T-R2.1) |
| D-22 |
tests, questions, question_groups and media_assets gain owner_id beside created_by / uploaded_by; users gains a nullable created_by
|
Ownership is who a row belongs to and provenance is who made it. R5's ownership transfer and R7's co-editing move the first and must not rewrite the second (T-R2.9). Expand half (00060 to 00063): each owner column is NOT NULL NOT VALID, with a fill trigger for the v0.7.0 binary. Contract half (00106, T-R4.49, §43): the constraints validated and the triggers and their functions dropped |
| D-23 |
classes.teacher_id is backfilled from the oldest active Admin, with no classes.created_by
|
app.classes never recorded a creator, and the oldest active Admin is who v0.7.0 shows as every class's teacher, so no teacher changes; a class's creator from R2 on is its class.created audit row (Thuong, 2026-09-28; T-R2.9). Expand half (00065): classes_teacher_id_not_null NOT VALID and the trigger classes_fill_teacher. Contract half (00106, §43): validated, and trigger and function dropped |
| D-24 | Add the view app.student_like_roles, the one strict-student predicate: the built-in Student, or a custom role holding nothing but learning.take_tests; the other built-in roles are excluded by key |
The Admin stores only its "Take tests" cell, so a predicate on grants alone would make it a student target; every student read and write, the legacy-role sync and the anonymiser use this one definition (plan 70 §4.3, T-R2.1) |
| D-25 |
users.role_id references app.roles and replaces the app.user_role enum. Expand half (00056): added nullable, backfilled and NOT NULL NOT VALID, with users_sync_legacy_role keeping role in step. Contract half (00105, T-R4.49, §42): the constraint validated, and the trigger, role, its index and the enum dropped |
§13.3's two-value enum holds no Teacher, Assistant or custom role, and v0.7.0 keeps writing role while both binaries run (plan 70 §3, T-R2.2) |
| D-26 |
users_last_admin, an AFTER UPDATE OR DELETE trigger, refuses any change that leaves no active Admin; its function is SECURITY DEFINER, owned by quizzivy_migrate, with a pinned search_path and EXECUTE revoked from PUBLIC |
The guard must hold on every path, a hand-written UPDATE included, and its row lock on app.roles needs the UPDATE privilege 00054 withholds from the app role (§30, T-R2.2) |
| D-27 |
class_join_codes gains code_ciphertext, key_id and lookup_scheme: a new code is sealed with AES-256-GCM under JOIN_CODE_KEY, and its code_hash is an HMAC-SHA256 under that key's lookup key; legacy rows keep the SHA-256 |
D5 has a teacher read a code back, which a hash cannot give; the key never reaches the database, so a dump can neither open a sealed code nor search the code space against its keyed hash (§33, T-R2.14a) |
| D-28 | Add notifications and notification_preferences. notifications.params and target are jsonb, bounded objects rather than columns, and kind is a checked dotted text, not an enum |
§13.3 has no notification. Each kind carries its own few fields and every release adds kinds, so columns would be mostly NULL and an enum a migration per kind; the contract's closed NotificationParams and the Go type per kind are the schema. A name in params is a copy, not a reference: R5's anonymisation (T-R5.17a) deletes or scrubs the notifications that name an anonymised user (§34, T-R4.10a) |
| D-29 |
media_assets gains display_name, default_max_plays, replaced_by, width and height, and its size limit depends on the kind: audio 50 MiB, an image 10 MiB |
§13.3's row is a stored file and nothing else, and §11.1 allowed 10 MB for either kind. The deck's Media page names a file, gives it a play limit, shows an image's size and replaces a file in place (DG-63, DG-09); a replaced row stays, because published versions still point at it (§6, T-R4.17a, T-R4.17b) |
| D-30 |
users gains nullable profile fields and bounded object preferences
|
The caller's private profile and account settings are required by T-R4.7; student-facing teacher names use the chosen display name without exposing private account fields |
| D-31 | Questions and frozen version questions gain nullable level and skill; the choice cap applies to authoring only | DG-62/DG-63 and T-R4.15 preserve historical question content while adding teacher metadata and filters |
| D-32 |
test_versions gains a nullable change_note, 1 to 200 characters |
The test detail's version history shows what the teacher said changed when publishing (DG-66, T-R4.16a). Nullable with no default and no backfill, so the previous release's insert keeps working and a version published before it reads NULL; the command stores NULL for a blank note, so the check never meets an empty string |
| D-33 |
questions and test_version_questions gain a nullable media_alt, 1 to 1000 characters |
The deck's builder draws "Alt text: describe the image for screen readers" under a question's image and the model had no field for it (DG-111, T-R4.62a). The bound is ContentImage.alt's. It lives on the question and is frozen at publish like every other part of the question, so a later edit of the bank question never reaches a version. Nullable with no default and no backfill, so the previous release's insert keeps working and a row written before it reads NULL. No check ties it to an image in R4: the write path refuses it for any other media (a field error on mediaAlt) and the bank store keeps it only beside an image, and a database check would make an R3 write that swaps an image for audio fail during the rolling deploy. The rule becomes a database invariant one release later, as a contract step (§37) |
| D-34 |
assignments gains review_release (on_submit or after_close, default on_submit) and review_show_class_average (default false) |
The deck's Review step releases results after submitting or after the window closes, and shows the class average only on request (DG-65, T-R4.11). Constant defaults, so the previous release's insert keeps its meaning (results at once, no average) and nothing is rewritten; no fill trigger |
| D-35 |
assignments gains a nullable student_note, 1 to 500 characters once trimmed of the whitespace JavaScript's trim() removes (the set of shared/answered, not btrim's space alone) |
The Student deck's Test intro draws a note from the teacher and the Teacher deck had no field for it (DG-71, T-R4.11). Nullable with no default, so the previous release's insert keeps working; the command trims the same set and stores NULL for a note that is blank once trimmed, so the check never meets an empty string, and a note of a tab, a newline or a no-break space is refused by the check as it is dropped by the command |
| D-36 | Add assignment_student_overrides (assignment_id, student_id, closes_at, duration_minutes, extra_attempts, reason, created_by, created_at, updated_at)
|
§13.3 has no per-student accommodation, and R4 lets a teacher give one student more time, more attempts or a reopened window without touching the assignment everyone else has (T-R4.12). A separate table, not columns on assignments, because the rows are per student and optional: no row is the assignment as it is. A new table adds nothing the previous release reads or writes, so the rolling deploy needs no expand step (§38) |
| D-37 |
refresh_tokens gains a nullable geo_label, 1 to 80 characters |
The Signed-in devices list names where a device last signed in or refreshed (T-R4.9, DG-58). A label is the edge's city and country at the request, so it is data about the request and lives beside ip and user_agent on the token row, which each rotation rewrites; a column on users or a table of devices would need a second writer and a second delete path. Nullable with no default, so the previous release's insert keeps working; the command stores NULL for no label, never an empty string, so the check never meets one (§39) |
| D-38 |
00103_compose_text_nfc.sql has a Down that does nothing, where every other file's Down undoes its Up (§13, AGENTS "Migrations") |
The file changes data and no schema, and nothing records which rows were decomposed before it ran, so there is nothing to restore: NFC and NFD are the same text, a rolled-back binary reads composed text exactly as it read decomposed text, and the one row it deletes (a decomposed accepted answer whose composed twin exists) was equal to the grader's eyes, so it is not brought back. A Down that decomposed every row would be wrong, since it would also decompose what was composed before. The file says so in the Down, and up/down/up still passes because the Down leaves the schema and every row as they are (§40) |
| D-39 |
classes gains a nullable schedule_label, 1 to 120 characters, and a nullable room, 1 to 60 |
The deck's class card and the student's class say when and where a class meets, and the model had no field for either (T-R4.18). Free text the teacher words, not a structured timetable, so one column each. Nullable with no default, so the previous release's insert keeps working; the commands trim and store NULL for a blank value, so the checks never meet an empty string (§41) |
| D-40 |
word_import_drafts gains nullable open_action_count and open_confirm_count, integer and not negative |
The history lists each import with "{n} need action" and "{n} to confirm" (T-R4.21), which are derived from the review body, and a body can be 8 MiB, so the page cannot parse one per row. The counts are written with the body in the same statement (processing result, review save, adoption), from domain.Assess(body).Summary. integer and not smallint, because a valid draft can exceed 32,767 derived findings and a count must not become a new authoring limit. Nullable with no default and no backfill: NULL means a draft this binary has not assessed, which is not zero, and the previous release's insert keeps working (§26) |
| D-41 |
word_import_sources.format admits text, and characters integer (1 to 100000) is non-null exactly for a text source |
A pasted test is a fourth source format, stored as a private .txt like any original (T-R4.55, 17-word-import.md §1.41). The character count is the NFC code-point count the intake measured, so the history can say "{n} characters" without reading the object; bytes and hash cannot rebuild it. Nullable with no default, so a file insert from the previous release stays valid. 00090's Down restores the file-only CHECK NOT VALID, so retained text rows do not block it. 00091's Down drops the column, and its Up then refuses, atomically, while any text row is retained, because word_import_sources_characters_format is validated at once and no count can be rebuilt: nothing invents one (§23) |
| D-42 |
questions and test_version_questions gain a nullable audio_allow_pause, present only with audio; group_recordings and test_version_group_recordings gain allow_pause boolean NOT NULL DEFAULT true
|
"Students can pause" is a fourth audio control, on a question's audio and on a group's recording (DG-111, DG-183, T-D4.13). The question columns are nullable and read as COALESCE(…, true) because the previous binary inserts those rows during the rolling deploy and writes no value, and because questions_audio_policy_iff_audio is a biconditional over the other two policy columns, which this one is kept out of; questions_allow_pause_only_with_audio and tvq_allow_pause_only_with_audio hold the one direction the way questions_max_plays_only_with_audio does. A recording always has its whole policy, so its column is NOT NULL with a constant default, which PostgreSQL stores without a rewrite. A write that leaves the field out keeps the stored value (§44), so a client that strips the key cannot switch pausing back on |
| D-43 |
media_assets gains nullable unreferenced_at and purged_at, and the six tables that name a file get a trigger that refuses a purged one and clears the wait of a retired one |
A replaced or deleted file keeps its object until seven days after nothing names it, then the sweep removes the object and marks the row; the row stays for the audit trail and the replaced_by chain, and the trigger is what makes "nothing can name a removed file" the database's rule and not a convention every writer must keep (F-68, §45) |
| D-44 | Add ai_settings, one row (a boolean primary key that only true satisfies), written by 00118 with the plan: a 200,000-credit pool, a 20,000-credit default allowance, a warning at 80% and requests on |
The AI assistant's rules are the centre's, not a teacher's, and the app cannot run the budget without them, so the migration writes the row (D-21, D21 in plan 70 §1). The pool has one rule, pause everyone (DG-196), so there is no column for what happens when it runs out. The allowance bounds (1,000 to 10,000,000) and the warning share (1 to 99) are checks; the narrower ranges the Admin may pick (50 to 99) are the command's (§46, T-D4.31) |
| D-45 | Add ai_credit_periods (month, pool_credits, added_credits, used_credits, reserved_credits) with ai_credit_periods_within_pool CHECK (used_credits + reserved_credits <= pool_credits + added_credits)
|
D21: the pool is never overspent without an Admin's approval, so the hard stop is a constraint and not a check in a handler. month is the first day of the month in the centre's zone, enforced as a date on day 1; pool_credits is copied from ai_settings when the month's first use creates the row, so a later change of the plan never rewrites a month that has begun; added_credits is the Admin's approval and does not carry over (§46, T-D4.31) |
| D-46 | Add ai_teacher_credits (month, user_id, allowance_credits, granted_credits, custom, paused, used_credits, reserved_credits, warned_at) with ai_teacher_credits_within_allowance CHECK (used_credits + reserved_credits <= allowance_credits) and ai_teacher_credits_user_idx (user_id, month DESC)
|
The same constraint for a teacher's allowance. Three columns go beyond the task's list because the model needs them: granted_credits keeps what approved requests added to this month apart from the teacher's own allowance, so a change of the default rule and a new month treat it right; warned_at makes the warning at warn_percent once a month a fact of the row and not a read-then-write; and custom and paused belong to the person, so a month's first row copies them from the teacher's latest earlier row, which the index serves (§46, T-D4.31) |
| D-47 | Add ai_reservations (id, month, user_id, feature, credits, created_at) with a composite foreign key to ai_teacher_credits (month, user_id) and a feature slug checked by pattern, not an enum |
A call holds its ceiling until it settles or is released, and a row that outlives its call is returned by the sweep after ten minutes. The foreign key is RESTRICT on purpose: deleting a teacher's month while a call holds credits would strand them in the pool for ever. feature is a slug for the reason notifications.kind is (D-28): a feature added by a release adds no migration |
| D-48 | Add ai_usage_ledger, append-only: UPDATE and DELETE are revoked from the app role in the file that creates it |
One row per settled call with the actor, the feature, the tier, the four token counts the provider reported, the credits charged and the provider's request id, so a month's invoice reconciles with the pool. A ledger the application can rewrite proves nothing, which is why audit_log and attempt_events are the same (D-01, §11). user_id is ON DELETE SET NULL, run by the table's owner, so a row outlives its actor |
| D-49 | Add ai_credit_requests, with a partial unique index ai_credit_requests_one_pending_idx (user_id, month) WHERE status = 'pending'
|
At most one request per teacher per month is waiting (DG-200), which the index makes a fact of the data and not a race between two clicks. decided_by is ON DELETE SET NULL and is NULL when the console decided, which the audit row says. It is the one foreign key left unindexed on purpose: the table gains a row per request, and removing an Admin scans a few hundred |
| D-50 |
questions gains a nullable copied_from_id, a self-reference ON DELETE SET NULL, and a partial index on the copies |
A member copy of a bank question names the question it was made from, so that dragging an unedited copy out of its group (E1, 74e-e1-group-move.md, "After release") puts the original back and no second bank question is left. The link is informational, like test_version_questions.source_question_id (D-07): it is cleared when the copy leaves its group and becomes an independent question, and SET NULL means a deleted origin never waits on a copy. Nullable with no default and no backfill, so the v0.11.6 binary's insert, which names no such column, keeps working; a copy it wrote reads NULL and is never restored. The REFERENCES is inline in the ADD COLUMN, so PostgreSQL skips the validation scan of a column it is adding. The index is partial because almost every question is not a copy, and ON DELETE SET NULL updates the copies by equality, which implies its predicate. See §48 |
goose, SQL, forward-only, one concern per file (§13.7). Every phase task names the file it adds.
| File | Creates | Phase |
|---|---|---|
00001_create_schema_and_extensions.sql |
schema app, pg_trgm, unaccent, app.immutable_unaccent(), public revoke |
0 |
00002_create_enums.sql |
seven enum types | 0 |
00003_create_updated_at_trigger.sql |
app.set_updated_at() |
0 |
00004_create_users.sql |
users, user_identities
|
1 |
00005_create_refresh_tokens.sql |
refresh_tokens |
1 |
00006_create_classes.sql |
classes, class_join_codes
|
1 |
00007_create_class_members.sql |
class_members |
1 |
00008_create_audit_log.sql |
audit_log |
1 |
00009_grant_app_role.sql |
grants + default privileges for quizzivy_app
|
1 |
00010_create_media_assets.sql |
media_assets |
2 |
00011_create_questions.sql |
questions |
2 |
00012_create_question_options.sql |
question_options |
2 |
00013_create_question_blanks.sql |
question_blanks, question_blank_answers
|
2 |
00014_restrict_audio_max_plays_to_audio.sql |
CHECK on questions [D-04] |
2 |
00015_create_tests.sql |
tests, test_sections, test_section_questions
|
2 |
00016_create_test_versions.sql |
test_versions, test_version_sections
|
2 |
00017_create_test_version_content.sql |
test_version_questions, _options, _blanks, _blank_answers
|
2 |
00018_create_assignments.sql |
assignments |
3 |
00019_create_assignment_targets.sql |
assignment_classes, assignment_students
|
3 |
00020_create_attempts.sql |
attempts |
3 |
00021_create_attempt_answers.sql |
attempt_answers |
3 |
00022_add_assignment_draft_state.sql |
assignment publication state | 3 |
00023_create_attempt_events.sql |
attempt_events |
3 |
00024_create_attempt_audio_plays.sql |
attempt_audio_plays |
3 |
00025_widen_grading_queue_to_timed_out.sql |
grading queue index | 4 |
00026_add_classes_archived_at.sql |
class archive state | 4 |
00027_let_a_draft_be_archived.sql |
archive unpublished tests | 4 |
00028_add_attempts_teacher_note.sql |
attempt review notes | 4 |
00029_index_integrity_retention.sql |
integrity retention index | 5 |
00030_test_version_sequence.sql |
monotonic publication counter | Admin change request |
00031_allow_no_focus_loss.sql |
no-departure integrity policy | Admin change request |
00032_add_option_content.sql |
bounded inline documents on normalized bank/snapshot options | Word W-05a |
00033_add_question_content.sql |
bounded prompt/explanation documents on bank/snapshot questions | Word W-05b |
00034_add_question_gap_bindings.sql |
stable gap bindings on bank/snapshot blanks | Word W-06b |
00035_add_question_group_ownership.sql |
Independent bank/test group ownership and ordered member columns | Word W-07 |
00036_index_question_group_members.sql |
Concurrent group-member identity and ordinal indexes | Word W-07 |
00037_create_question_group_graph.sql |
Ordered units, materials, cloze targets, protected media/recording bindings | Word W-07 |
00038_index_version_question_sections.sql |
Concurrent section identity index for frozen questions | Word W-08 |
00039_create_version_group_graph.sql |
Independent immutable group/unit/material/recording snapshots | Word W-08 |
00040_create_group_audio_plays.sql |
Shared recording counters and append-only gesture receipts | Word W-09 |
00041_version_test_delivery.sql |
Immutable delivery algorithm marker | Word W-09d |
00042_create_word_import_sources.sql |
Private source intake, upload journal and immutable source sets | Word W-10a |
00043_create_word_import_runs.sql |
Fenced processing runs and append-only run events | Word W-11a |
00044_create_word_import_artifacts.sql |
Private stage artifact sets and object journal | Word W-13b |
00045_create_word_import_drafts.sql |
One revisioned review draft per import, plus a kept-aside reprocessed candidate | Word W-18 |
00046_create_word_import_commits.sql |
One append-only commit record per import linking the approved revision to its test | Word W-20 |
00047_add_word_import_run_profile.sql |
Recognition profile a run was scheduled with | Word W-14 |
00048_allow_legacy_word_sources.sql |
Legacy .doc sources |
Word W-13 |
00049_index_question_group_bank_recency.sql |
Bank group listing ordered by recency | Word W-07 |
00050_add_word_import_files_removed_at.sql |
When retention removed an import's files, and the index its sweep reads | Word D-08 |
00051_grant_word_import_draft_delete.sql |
Lets the retention sweep delete a review draft when it removes an import's files | Word D-08 |
00052_allow_pdf_import_sources.sql |
PDF sources | Word D-10 |
00053_create_maintenance_windows.sql |
maintenance_windows, read-only for the app role |
R1 (T-R1.12) |
00054_create_roles_and_permissions.sql |
permissions, roles, role_permissions, the built-in rows and their guard triggers; read-only for the app role |
R2 (T-R2.1), D-21 |
00055_create_student_like_roles_view.sql |
student_like_roles, the strict student predicate |
R2 (T-R2.1), D-24 |
00056_add_users_role_id.sql |
users.role_id, backfilled, NOT NULL NOT VALID, with the legacy-role sync trigger |
R2 (T-R2.2), D-25 |
00057_index_users_role_id.sql |
users_role_id_active_idx, built CONCURRENTLY (no transaction) |
R2 (T-R2.2) |
00058_add_users_session_epoch.sql |
users.session_epoch |
R2 (T-R2.2) |
00059_add_users_last_admin_guard.sql |
the users_last_admin trigger, the schema's first SECURITY DEFINER function |
R2 (T-R2.2), D-26 |
00060_add_tests_owner.sql |
tests.owner_id, backfilled from created_by, NOT NULL NOT VALID, with app.fill_owner_from_created_by() and its fill trigger |
R2 (T-R2.9), D-22 |
00061_add_questions_owner.sql |
questions.owner_id, the same way |
R2 (T-R2.9), D-22 |
00062_add_question_groups_owner.sql |
question_groups.owner_id, the same way |
R2 (T-R2.9), D-22 |
00063_add_media_assets_owner.sql |
media_assets.owner_id from uploaded_by, with app.fill_owner_from_uploaded_by()
|
R2 (T-R2.9), D-22 |
00064_add_users_created_by.sql |
users.created_by, nullable, ON DELETE SET NULL
|
R2 (T-R2.9), D-22 |
00065_add_classes_teacher.sql |
classes.teacher_id from the oldest active Admin, NOT NULL NOT VALID, with classes_fill_teacher
|
R2 (T-R2.9), D-23 |
00066_add_tests_owner_fkey.sql |
tests_owner_id_fkey, ON DELETE RESTRICT, apart from its column |
R2 (T-R2.9) |
00067_add_questions_owner_fkey.sql |
questions_owner_id_fkey, ON DELETE RESTRICT, apart from its column |
R2 (T-R2.9) |
00068_add_question_groups_owner_fkey.sql |
question_groups_owner_id_fkey, ON DELETE RESTRICT, apart from its column |
R2 (T-R2.9) |
00069_add_media_assets_owner_fkey.sql |
media_assets_owner_id_fkey, ON DELETE RESTRICT, apart from its column |
R2 (T-R2.9) |
00070_add_classes_teacher_fkey.sql |
classes_teacher_id_fkey, ON DELETE RESTRICT, apart from its column |
R2 (T-R2.9) |
00071_index_classes_teacher.sql |
classes_teacher_idx, built CONCURRENTLY (no transaction) |
R2 (T-R2.10) |
00072_index_tests_owner.sql |
tests_owner_idx, built CONCURRENTLY (no transaction) |
R2 (T-R2.10) |
00073_index_questions_owner.sql |
questions_owner_bank_idx, built CONCURRENTLY (no transaction) |
R2 (T-R2.10) |
00074_index_question_groups_owner.sql |
question_groups_owner_bank_idx, built CONCURRENTLY (no transaction) |
R2 (T-R2.10) |
00075_index_media_assets_owner.sql |
media_assets_owner_idx, built CONCURRENTLY (no transaction) |
R2 (T-R2.10) |
00076_index_users_created_by.sql |
users_created_by_idx, built CONCURRENTLY (no transaction) |
R2 (T-R2.10) |
00077_index_assignments_creator.sql |
assignments_creator_idx, built CONCURRENTLY (no transaction) |
R2 (T-R2.10) |
00078_add_join_code_encryption.sql |
class_join_codes.code_ciphertext, key_id, lookup_scheme and their four checks; Down refuses a live scheme-2 code |
R2 (T-R2.14a), D5, D-27 |
00079_revoke_temporary_from_public.sql |
revokes TEMPORARY on the database from PUBLIC; a no-op with a notice where the migration role does not own the database |
fix for #194 |
00080_create_notifications.sql |
notifications, its four checks, UNIQUE (user_id, dedupe_key), three indexes and the updated_at trigger |
R4 (T-R4.10a), D-28 |
00081_create_notification_preferences.sql |
notification_preferences, notification_preferences_event_check and the updated_at trigger |
R4 (T-R4.10a), D-28 |
00082_add_media_assets_library_fields.sql |
media_assets.display_name, default_max_plays, replaced_by and their four constraints |
R4 (T-R4.17a), D-29 |
00083_add_media_assets_dimensions.sql |
media_assets.width, height and their three checks |
R4 (T-R4.17a), D-29 |
00084_widen_media_assets_audio_bytes.sql |
media_assets_bytes_by_kind in place of media_assets_bytes_check; Down re-adds the old rule NOT VALID
|
R4 (T-R4.17a), D-29, DG-63 |
00085_add_users_profile_fields.sql |
users.display_name, phone, avatar_key, locale, time_zone and four checks |
R4 (T-R4.7), D-30 |
00086_add_users_preferences.sql |
users.preferences and object/8192-byte checks |
R4 (T-R4.7), D-30 |
00087_add_questions_level_skill.sql |
Nullable bank level and skill, with closed CHECK sets |
R4 (T-R4.15), D-31 |
00088_add_test_version_questions_level_skill.sql |
Nullable frozen level and skill, with closed CHECK sets |
R4 (T-R4.15), D-31 |
00089_add_word_import_draft_counts.sql |
word_import_drafts.open_action_count and open_confirm_count, each nullable and checked not negative, no default |
R4 (T-R4.21), D-40 |
00090_allow_text_import_sources.sql |
word_import_sources_format_check admits text; Down restores the file-only check NOT VALID
|
R4 (T-R4.55), D-41 |
00091_add_word_import_sources_characters.sql |
word_import_sources.characters, word_import_sources_characters_range (1 to 100000) and word_import_sources_characters_format (non-null exactly when format = 'text') |
R4 (T-R4.55), D-41 |
00092_add_test_versions_change_note.sql |
test_versions.change_note and test_versions_change_note_check (1 to 200 characters), added with the column |
R4 (T-R4.16a), D-32 |
00097_add_questions_media_alt.sql |
questions.media_alt and questions_media_alt_check (1 to 1000 characters), added with the column |
R4 (T-R4.62a), D-33 |
00098_add_test_version_questions_media_alt.sql |
test_version_questions.media_alt and test_version_questions_media_alt_check (1 to 1000 characters), added with the column |
R4 (T-R4.62a), D-33 |
00099_add_assignment_review_options.sql |
assignments.review_release, assignments_review_release_check and review_show_class_average, constant defaults |
R4 (T-R4.11), D-34 |
00100_add_assignments_student_note.sql |
assignments.student_note and assignments_student_note_check (1 to 500 characters once trimmed of the shared/answered whitespace set), added with the column |
R4 (T-R4.11), D-35 |
00101_create_assignment_student_overrides.sql |
assignment_student_overrides, its four checks, assignment_student_overrides_student_idx and the updated_at trigger |
R4 (T-R4.12), D-36 |
00102_add_refresh_tokens_geo_label.sql |
refresh_tokens.geo_label and refresh_tokens_geo_label_check (1 to 80 characters), added with the column |
R4 (T-R4.9), D-37 |
00103_compose_text_nfc.sql |
Data only: composes to NFC the plain columns a person types, deletes a decomposed accepted answer whose composed twin exists, and keeps updated_at by disabling the eight named set_updated_at triggers for the transaction. The Down does nothing |
R4 (F-37), D-38 |
00104_add_classes_schedule_room.sql |
classes.schedule_label and classes.room, each with its check (1 to 120 and 1 to 60 characters), added with the column; 00103 is reserved for F-37, which merges first, and the second of the two renumbers if the order inverts |
R4 (T-R4.18), D-39 |
00105_drop_users_legacy_role.sql |
Validates users_role_id_not_null; drops users_sync_legacy_role, users_role_active_idx, users.role and app.user_role; lock_timeout 5 s |
R4 (T-R4.49, T-R3.1), D-25, §42 |
00106_validate_ownership.sql |
Validates the five owner constraints; drops the five fill triggers and their three functions; lock_timeout 5 s |
R4 (T-R4.49, T-R3.2), D-22, D-23, §43 |
00111_add_media_assets_retention.sql |
media_assets.unreferenced_at, media_assets.purged_at and media_assets_purged_check (a purged file was unreferenced first), nullable with no default |
R4 (F-68), D-43, §45 |
00112_index_media_assets_retired.sql |
media_assets_retired_idx (owner_id) WHERE purged_at IS NULL AND (deleted_at IS NOT NULL OR replaced_by IS NOT NULL), CONCURRENTLY
|
R4 (F-68), D-43, §45 |
00113_guard_purged_media_bindings.sql |
app.media_asset_binding_guard() and its BEFORE INSERT OR UPDATE OF media_asset_id trigger on questions, group_recordings, group_stimulus_assets, test_version_questions, test_version_group_recordings and test_version_group_assets
|
R4 (F-68), D-43, §45 |
00114_add_questions_audio_allow_pause.sql |
questions.audio_allow_pause (nullable, no default) and questions_allow_pause_only_with_audio, added with the column |
D4 (T-D4.13), D-42 |
00115_add_test_version_questions_audio_allow_pause.sql |
test_version_questions.audio_allow_pause (nullable, no default) and tvq_allow_pause_only_with_audio, added with the column |
D4 (T-D4.13), D-42 |
00116_add_group_recordings_allow_pause.sql |
group_recordings.allow_pause boolean NOT NULL DEFAULT true |
D4 (T-D4.13), D-42 |
00117_add_test_version_group_recordings_allow_pause.sql |
test_version_group_recordings.allow_pause boolean NOT NULL DEFAULT true |
D4 (T-D4.13), D-42 |
00118_create_ai_settings.sql |
ai_settings, its five checks and the updated_at trigger, and the one row the plan sets |
D4 (T-D4.31), D-44, §46 |
00119_create_ai_credit_periods.sql |
ai_credit_periods with ai_credit_periods_within_pool
|
D4 (T-D4.31), D-45, §46 |
00120_create_ai_teacher_credits.sql |
ai_teacher_credits with ai_teacher_credits_within_allowance, ai_teacher_credits_user_idx and the updated_at trigger |
D4 (T-D4.31), D-46, §46 |
00121_create_ai_reservations.sql |
ai_reservations, its composite foreign key and its two indexes |
D4 (T-D4.31), D-47, §46 |
00122_create_ai_usage_ledger.sql |
ai_usage_ledger, its indexes, and REVOKE UPDATE, DELETE from the app role |
D4 (T-D4.31), D-48, §46 |
00123_create_ai_credit_requests.sql |
ai_credit_requests, ai_credit_requests_one_pending_idx and two more indexes |
D4 (T-D4.31), D-49, §46 |
00124_add_ai_permissions.sql |
ai.use (Content, ordinal 6) and ai.credits.manage (System, ordinal 4) in app.permissions, both in_matrix; the System group's four hidden ordinals move up one, in two statements because (group_key, ordinal) is unique and not deferrable; ai.use granted to the built-in Teacher role |
D4 (T-D4.32), DG-199, §46 |
00125_add_word_imports_read_with_ai.sql |
word_imports.read_with_ai boolean NOT NULL DEFAULT false; a constant default, so the v0.10.x binary keeps inserting |
D4 (T-D4.33), §47 |
00126_add_questions_copied_from.sql |
questions.copied_from_id and questions_copied_from_id_fkey (REFERENCES questions (id) ON DELETE SET NULL), added with the column, nullable with no default |
E1 hotfix v0.11.7, D-50, §48 |
00127_index_questions_copied_from.sql |
questions_copied_from_idx (copied_from_id) WHERE copied_from_id IS NOT NULL, CONCURRENTLY
|
E1 hotfix v0.11.7, D-50, §48 |
Notes on migration mechanics (§13.7):
-
D4's four files were built as
00107–00110and are00114–00117. Production holds00111–00113(F-68) before D4 ships, and goose refuses a migration numbered below the database's version, so the develop sync of 2026-10-12 renumbered them after00113, in order. The next D4 migration (T-D4.31) takes00118. -
Every file has a
-- +goose Downthat is actually correct. CI runsupthendownthenupagainst a cleanpostgres:18(T-0.17), so an unreversible migration is a red build on the day it is written. -
No
CREATE INDEX CONCURRENTLYin00001–00022. They create empty tables, where a plainCREATE INDEXis instant and transactional.CONCURRENTLYbecomes mandatory once an index is added to a populated table (00029,00036,00038,00057,00071–00077), and each such file gets-- +goose NO TRANSACTION, because CREATE INDEX CONCURRENTLY cannot run inside a transaction block and leaves anINVALIDindex if it fails. -
NOT NULL … NOT VALIDonly where a populated table gains a column. Every column of a new table saysNOT NULLinline atCREATE TABLE, where it is free. R2 is the first to need the PG18 construct (§13.6):00056,00060–00063and00065backfill a new column, addNOT NULL … NOT VALID, and leave validation to v0.9.1 (§30, §31). T-0.16 proved it before it was needed. -
00009runs in Phase 1, not last. It was originally scheduled for Phase 3 on the reasoning thatGRANT … ON ALL TABLESmust follow the tables. That is backwards: it leftquizzivy_appunable to read anything from the first table until the end of Phase 3, and the first integration test to connect as the app role failed withpermission denied for schema app.ALTER DEFAULT PRIVILEGESresolves it — applying to objects created after it, so one early migration covers every table Phases 2–5 add. Custom types need their ownUSAGE; schemaUSAGEis not enough to referenceapp.user_rolein a query. -
Seed data lives in
seed/, never in a migration (§13.7). The exception is reference data the app cannot run without (D-21).
- No
SELECT *anywhere. Enforced by review and by the separate student/admin Go types described in §11. -
ORDER BY id DESCon every list endpoint, paged by OFFSET since O-20 (the keyset rule §13.8 states was overridden for the admin lists on 2026-09-03; the indexes below serve the order either way). Because PKs areuuidv7(),id DESCis a valid recency order and most lists need nocreated_atindex at all. The three that filter first —questions(by type),tests(by status),media_assets(by kind) — have(filter, id DESC)composite indexes above. -
EXPLAIN (ANALYZE, BUFFERS)before merging, on the three screens where N+1 is the default failure mode: the assignment monitor, attempt grading, and the integrity timeline. Each has one query for the parent and one for the children, never one per row. -
A sequential scan on a small table is the correct plan. At ~50 students and
a few thousand attempts, most of these tables fit in a page or two and the
planner will rightly ignore the indexes. The indexes above exist for the shapes
that stay selective as data grows, not to force index scans today.
15-phase-5.mdmeasures latency at seeded volume rather than asserting plan shapes.
00029_index_integrity_retention.sql adds (received_at, id) concurrently to
support bounded owner-role deletion strictly before the UTC thirteen-month
cutoff, with assignment closure also older than that cutoff. Down drops the index concurrently. No application privileges change.
audit_log remains append-only and retained. Manual anonymization keeps user
IDs and historical relationships while removing structured identity and login
credentials. See ../setup/operations.md for execution and privacy limits.
00030_test_version_sequence.sql adds tests.last_published_version. Existing
rows are backfilled from both the default and the greatest historical version.
A before-insert/update trigger keeps the counter at least the default and never
allows it to decrease; tests_version_sequence_check pins that relationship.
Publication increments this counter; choosing an older default or deleting an
unused version never reuses its number. Down removes the trigger, function and
column without deleting snapshots.
00031_allow_no_focus_loss.sql expands the assignment CHECK to accept -1
(no departures allowed), preserving 0 as unlimited and positive counts.
Down maps -1 to one allowed departure because the old schema cannot express
none, then reinstates the original constraint. This rollback changes that policy
and must be coordinated with the old application.
Permanent deletion locks the parent and relies on real foreign keys to serialize against new references. Archived tests may remove only unused versions; archived classes may remove owned membership/join-code rows but not referenced assignments. Draft/closed assignments require no attempts. Disabled student accounts require no assigned work, attempts or audit actor references. Audit entries are appended in the same transaction; existing audit and attempt events are never updated or deleted by the application. Restoring a version creates independent bank rows, including frozen grading keys, and takes the draft-use question locks before inserting the replacement outline.
00032_add_option_content.sql adds nullable content jsonb to
app.question_options and app.test_version_options. Each stores one bounded
OptionContent document alongside the existing literal/plain-projection text.
The CHECK accepts SQL NULL or an object with explicit semantic_v1 format;
the shared domain validator checks closed shape, marked text/break vocabulary,
Unicode/budgets and exact text projection before a write and again at publish.
There are no asset or gap nodes, so this slice adds no media bindings or FK locks.
Options, ordinals and correctness stay normalized; there is no whole-question or
whole-version JSON blob. No index is needed: content is loaded with its existing
parent/ordinal queries and existing search behavior is unchanged.
Question UPDATE takes the parent row lock before replacing children. Under that lock, a legacy write omitting content retains it only for a matching current option ID and unchanged text; a mismatch aborts the entire write. Explicit JSON null removes formatting and is stored as SQL NULL. Bank duplication copies the validated document; publication freezes it, restoration copies the snapshot into fresh bank option identities. Student/teacher reads use the snapshot document, never a live bank join. Existing rows remain SQL NULL without backfill.
Down drops the two columns and preserves relational text/keys but loses rich formatting. Do not use it after rollout; retain this schema and compatible readers when disabling the pilot authoring flag. App-role privileges and append-only logs are unchanged. Up/down/up is tested only on a disposable database.
00033_add_question_content.sql adds nullable prompt_content and
explanation_content JSONB to app.questions and app.test_version_questions.
CHECK constraints accept SQL NULL or an object with explicit semantic_v1 format;
rich prompts cannot be attached to fill_blank questions before gap binding exists.
The domain checks raw JSON, the reference-free prose profile, aggregate budgets
and exact text projection at write and publication. Relational media, options,
blanks and grading keys remain on their current tables. No references, new
privileges or indexes are introduced. Existing prompt indexes search the exact
plain projection for new prose and the historical Markdown for legacy rows.
Question updates lock the parent before comparing omitted content's companion text. If a legacy write changes a rich field's text without supplying a document or explicit null, the whole update rolls back. This prevents formatting loss; it is not a replacement for the later authoring revision protocol. Student paper and preview queries select only prompt content. Result extras select explanation content inside the same review-policy CASE as explanation text.
Down removes the four columns and loses formatting; use it only for disposable or pre-rollout data. After rich writes, retain this migration and capable readers when disabling new authoring. No historical snapshots are backfilled.
00034_add_question_gap_bindings.sql adds nullable gap_id text on
app.question_blanks and app.test_version_blanks. Each CHECK uses the content
identity alphabet (ASCII alphanumeric then alphanumeric/underscore/hyphen, 1–64).
UNIQUE (question_id, gap_id) and (test_version_question_id, gap_id) protect
non-null bindings while permitting existing null legacy rows. The leading parent
key also supports per-question graph reads; no global gap lookup/index is needed.
The parent prompt CHECK retains object/semantic format validation and permits
fill-blank; exact AST/metadata bijections are checked by the shared domain validator.
No new grants, roles, background job or historical backfill. The parent question lock still serializes updates; snapshot writes and graph copies are transactional. Copies allocate fresh gap IDs and remap AST and blank metadata together. Down restores the old parent restriction before dropping the columns, so rich fill-blank rows block rollback atomically. After these writes, keep readers and migration 34.
This additive storage foundation remains unavailable through application writes until group lifecycle commands and W-08/09 readers/snapshots are integrated. Existing sections and bank questions retain their interpretation; no backfill changes old versions or attempts.
00035_add_question_group_ownership.sql introduces question_groups. A null
owner_section_id identifies an independent bank group; otherwise the group
belongs exclusively to that draft section. The section FK uses RESTRICT so old
section deletion cannot strand its owned questions. Bank groups can be archived;
test-owned groups follow their enclosing draft lifecycle. Revision and updated
at are held on the aggregate. New nullable question columns carry group ownership,
member ordinal and option order as one complete tuple; owned questions cannot be
soft-deleted separately. The group FK is RESTRICT: lifecycle commands must remove
the complete graph explicitly, never orphan children into the standalone bank.
00036_index_question_group_members.sql builds two unique indexes CONCURRENTLY
outside a transaction on the existing questions table. The identity pair supports
same-group foreign keys; the ordinal index supports ordered group reads and later
becomes a deferrable uniqueness constraint. Legacy null ownership is not unique.
00037_create_question_group_graph.sql attaches these indexes as constraints and
adds the owned graph. Failed concurrent index creation must be inspected for an
invalid index and repaired before retry; migration re-runs must not silently
accept an invalid existing index with IF NOT EXISTS. See PostgreSQL 18
CREATE INDEX and
ALTER TABLE.
| Table | Ownership, references and indexes |
|---|---|
question_groups |
Section RESTRICT; creator RESTRICT; section lookup and active bank recency indexes; (id, owner_section_id) prevents mounting bank/foreign groups into a section |
test_section_units |
Ordered standalone-question/group union, exclusive via CHECK; section CASCADE; referenced question/group RESTRICT; deferrable section ordinal; unique group mounting and no repeated standalone question in a section |
group_stimuli |
Group CASCADE; bounded ordered materials; JSONB content with explicit known format; (id, group_id) supports same-group links |
group_gap_bindings |
Material CASCADE; member composite FK prevents cross-group targets; stable (question_id, gap_id) blank FK; choice/blank shape CHECK; scoped material-gap primary key; unique response targets; reverse question/blank index |
group_recordings |
Group CASCADE; composite media kind FK RESTRICT; one audio asset binding per group; immutable binding identity and playback/transcript policy; reverse media index |
group_stimulus_assets |
Material CASCADE; media kind FK RESTRICT; audio iff explicit recording; composite recording FK prevents using another group's allowance or another file; reverse media/recording indexes |
Gap-to-member/blank and material-to-recording foreign keys are deferred NO ACTION: an atomic replacement may replace normalized answer rows while preserving stable gap identities, but commit cannot leave dangling bindings. AST/mirror equality, question interaction compatibility, complete member/material/recording coverage, aggregate budgets and media authorization still require the domain validator and repository checks; JSONB format checks alone do not validate semantic content.
A group update must lock the containing test when present, then the group and owned questions, before its media reference locks; lists of question/media IDs are locked in stable order. Creation/copy/deletion and bank insertion must use the same locks on both sides. Existing bank and outline paths must explicitly reject or hide owned children until they can carry complete context. No partial group insert is allowed. Legacy flat section membership remains readable; new ordered units are enabled only with group-aware draft/snapshot readers.
Down refuses to remove group storage after any group/unit write. With no such writes, the graph Down restores the pre-attachment indexes before returning to migration 36, so down-one/up works as well as a full reset. That rollback rebuild holds the ordinary DDL lock inside the transaction; it is a pre-enablement path, not a supported downgrade after group content exists. After enabling writes, keep a reader-capable binary and schema floor; disable new writes instead of dropping context. The guarded rollback and fresh up/down/up are tested in isolated Docker databases. No privilege changes touch append-only audit or attempt events.
The W-07c internal repository materializes graphs atomically and resolves owned questions through a transaction-bound adapter. Legacy standalone APIs scope reads and writes to null ownership. First group mounting locks the test revision, locks legacy standalone members by ID, preserves their section ordinals and appends the group. Creation then locks all media IDs in stable order before writing members and bindings. Readback holds the group shared lock and verifies AST/relational asset equality. Group-aware update/delete must take its exclusive counterpart.
Media soft deletion includes material, recording and owned-member references, including archived bank groups, under the existing asset row lock. The race tests observe actual PostgreSQL blocking and exercise both winner orders. These internal operations remain unexposed until complete draft/snapshot readers and lifecycle commands are present; new-unit readers must not fall back to legacy flat membership.
W-07d extends the internal repository with revision-checked updates and lifecycle operations. Update locks the enclosing test first, the group FOR UPDATE, then owned questions by ID and incoming media by ID. It clears old material bindings, replaces members under deferred ordinal uniqueness, reconstructs validated bindings and increments the group revision in the same audited transaction. Question type and fixed/shuffle policy change in one statement so neither intermediate state violates the compatibility CHECK. Readback revalidates the complete graph before commit; any failure rolls everything back.
Bank archival/restoration advances the same revision. Permanent deletion requires an archived bank group and removes materials, recordings and owned questions before the root; audit rows remain. Section removal instead requires both current revisions, removes its unit and compacts remaining unit ordinals under their deferrable constraint. Copies resolve one coherent source revision, remap all editable identities and create a new graph; no editable source FK is retained.
Part 1 of 2 of 20 — Data Model · Next
Generated from docs/plan/20-data-model.md. Edit that file in the repository; changes made on this page are overwritten.