Plan 20 Data Model Part 1 - SekiroKenjii/Quizzivy GitHub Wiki

Part 1 of 2 of 20 — Data Model · Next

20 — Data Model

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.


1. Provisioning (outside migrations)

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.


2. Foundations

00001_create_schema_and_extensions.sql

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.

00002_create_enums.sql

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.

00003_create_updated_at_trigger.sql

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.


3. Identity

00004_create_users.sql

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();
  • text with a length CHECK over varchar(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_key is the §13.3 sketch, unchanged. Case-insensitive uniqueness must be an expression index — a plain UNIQUE (email) would let [email protected] and [email protected] coexist and break §5.1's linking rule.
  • users_must_change_needs_password encodes §5.4's "Google-only users never hit /change-password" as a constraint rather than a convention.
  • users_role_active_idx serves §8's active-student count. Tiny table; it exists so the dashboard query is not a repeated seq scan, not because it is hot.

00004 (cont.) — user_identities

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's DELETE /auth/google/link and §7's linkedProviders both 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 of UNIQUE (user_id, provider). The indexing reference is explicit: avoid redundant single-column indexes when a composite exists.
  • provider stays text + CHECK rather than an enum: a second provider is a plausible v2 change and ALTER TYPE in a deploy is worse than ALTER TABLE.

00005_create_refresh_tokens.sql

§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 bytea with 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_id is the rotation chain. §5.2's reuse detection is UPDATE … SET revoked_at = now() WHERE family_id = $1, which is why refresh_tokens_family_idx exists and is not optional.
  • replaced_by ON DELETE SET NULL: rows are pruned by expiry, and a pruned predecessor must not cascade-delete a live successor.
  • refresh_tokens_expiry_idx is the cleanup job's index. Partial on revoked_at IS NULL because revoked rows are deleted by family, not by expiry.
  • ip inet not text: inet validates, normalizes IPv6, and supports subnet containment if abuse investigation ever needs it. Same for audit_log.ip.

4. Classes and join codes (§6)

00006_create_classes.sql

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).

00006 (cont.) — class_join_codes

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_hash is the only lookup path, through its UNIQUE index. A typed code is looked up as code_hash = ANY(candidates), and revocation, expiry and uses are judged on the row found (§33). The plaintext code is never stored (§13.3). From 00078 a 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_uses row is a silent policy failure and this makes it impossible.
  • class_join_codes_one_active is §13.3's, unchanged. Note what it does not say: an expired code still satisfies revoked_at IS NULL and 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.

00007_create_class_members.sql

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_id is new and cheap. §6.4 shows joined_via so 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 RESTRICT because codes are revoked, never deleted — and SET NULL here would violate the consistency CHECK.
  • class_members_user_idx is required, not optional: §9's /app/classes and the assignment target resolution both query by user_id, which the PK cannot serve.

5. Audit (§13.4)

00008_create_audit_log.sql

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 IDENTITY not uuid, for the same reason §13.3 gives for attempt_events: a high-volume append-only log read by a narrow key is better served by a narrow sequential key. GENERATED ALWAYS AS IDENTITY over serial per 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.


6. Media (§11)

00010_create_media_assets.sql

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 CHECK is what makes "we validate server-side" true even if a code path is added later that forgets.
  • mime_type is an allowlist, matching §11.1's mp3/m4a decision exactly. Adding .ogg/.wav later 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]. id is already the PK, so this adds an index that is redundant for lookups. It exists purely so questions and test_version_questions can declare FOREIGN KEY (media_asset_id, media_asset_kind) REFERENCES media_assets (id, kind), which is what lets a CHECK on those tables enforce §7's "audio policy present iff media.kind === 'audio'" relationally instead of in application code. Postgres requires a UNIQUE on exactly the referenced columns.
  • checksum_sha256 has a plain index, never UNIQUE [D-06]. §13.3's comment says the checksum is for "dedupe identical re-uploads", but §11.1 says "Re-uploading creates a new media_assets row; 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_at soft delete per §13.2 — history matters here because a published version may reference the row. DELETE /teacher/media/:id (§15) returns 409 when test_version_questions still references it; only unreferenced assets get a deleted_at. R2 object lifecycle after soft delete is deliberately out of scope for v1 — the object stays. See 40-open-items.md.

00082–00084 — the library's fields (T-R4.17a, D-29)

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_name is the name the library shows; NULL means the original filename, which never changes. The server stores it trimmed, so media_assets_display_name_check asks for 1 to 200 characters and one that is not white space. Readers select coalesce(display_name, original_filename).
  • default_max_plays is 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_check keeps it off an image, as questions_audio_policy_iff_audio keeps 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_by names the file that took this one's place. T-R4.17b writes it; T-R4.17a only reads it. The library is the rows with deleted_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, LockForVersionUse and SoftDelete do 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_check refuses 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.
  • width and height are 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_kind allows audio 52428800 bytes (50 MiB) and an image 10485760 (DG-63). It is added NOT VALID and validated before the old constraint is dropped, so no moment has neither rule. Down re-adds the old CHECK NOT VALID: audio above 10 MiB may exist by then, and the constraint still binds every row written afterwards.
  • questionCount is 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 whose group_recordings or group_stimulus_assets name it, each question once and whoever owns it. A member cannot be soft-deleted (questions_context_complete, 00035), so every member is live. The unused filter and the facet of that name are NOT EXISTS over 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 through db.EscapeLike. No index: the rows are one owner's, behind media_assets_owner_idx.
  • The quota is checked inside the insert's transaction, under an advisory lock keyed by the owner (§28).

7. Question bank

Normalized, not jsonb (§13.3). Options and blanks are reordered by the builder, edited individually, and graded against.

00011_create_questions.sql

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_plays needed a second constraint, added in 00014. The biconditional below covers audio_allow_seek and audio_show_transcript_after and does not mention audio_max_plays. Leaving it out of the right-hand side is right — maxPlays is [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, leaving audio_max_plays unconstrained. A short_answer question with audio_max_plays = 3 satisfied every CHECK on the table. questions_max_plays_only_with_audio closes it, one-directional like questions_transcript_iff_audio beside it.

  • Audio policy columns are nullable [D-04]. §13.3 declares them NOT NULL DEFAULT, which cannot express §7's audio?: AudioPolicy — "present iff media.kind === 'audio'". With NOT NULL DEFAULT false, every text question carries a meaningless audio_allow_seek, and the API layer has to decide when to suppress it. Nullable plus a biconditional CHECK makes the database the one that knows. Defaults from §11.1 (maxPlays 2, allowSeek false, showTranscriptAfterSubmit true, and allowPause true, the fourth control, added by 00114 in §44) move to the application, which is where they belong — they are authoring defaults, not storage defaults.

  • questions_sample_answer_only_short_answer encodes §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_idx is an addition [D-11]. §13.3 gives only to_tsvector('simple', prompt). 'simple' does no stemming and no diacritic folding, so in a Vietnamese-first product a search for nghe will not match nghé, and phat am will not match phát âm.

    pg_trgm alone 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, and similarity('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 is IMMUTABLE, so that expression index is legal as written; the one-argument form is STABLE and would be rejected.

  • A stored tsvector generated 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 adding unaccent, which is not IMMUTABLE without a wrapper. Expression index for now; revisit only if search gets slow.

  • questions_type_id_idx serves §8's type filter plus keyset pagination in one index (uuidv7() PKs make id DESC a valid time order, §13.8). Tag filtering goes through the GIN index; combining the two is a bitmap AND, which is correct.

00012_create_question_options.sql

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 — the UNIQUE (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 UPDATE transiently 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.go pins both halves so the claim cannot rot again.

    Deferrable lets the reorder transaction issue SET CONSTRAINTS app.question_options_ordinal_key DEFERRED and 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 the test_version_* tables, which are written once and never reordered.

    Used by a question save from the H-05 change on. updateQuestion keeps 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 under SET CONSTRAINTS app.question_options_ordinal_key DEFERRED (blanks: app.question_blanks_ordinal_key) one statement at a time, then checked with IMMEDIATE; 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_key is not deferrable, so a kept blank whose gap_id changes has it set to NULL first, 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.

00013_create_question_blanks.sql

§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 in 40-open-items.md), so a 0-ordinal blank would be unreachable. §7's blanks[].ordinal is silent on the base; this pins it.
  • UNIQUE (blank_id, answer) is exact, not lower(answer) — case_sensitive blanks legitimately want Cat and cat as distinct accepted answers. Case-insensitive matching happens at grading time against case_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.

8. Tests, drafts, and versions

§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].

00015_create_tests.sql

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.

00015 (cont.) — draft structure [D-14]

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, not CASCADE: 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_idx exists because of that RESTRICT. 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.

00016_create_test_versions.sql

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 RESTRICT on test_versions, against §13.2's "CASCADE only for true owned children". A version is owned by a test, but attempts reference versions with RESTRICT, so cascading here would only ever produce a constraint violation two levels down with a confusing message. RESTRICT fails at the right place. Tests are soft-deleted regardless.
  • UNIQUE (id, test_id) [D-17] is the composite-FK target that lets assignments prove its test_version_id actually belongs to its test_id (§9 below). Same technique as media_assets.
  • total_points is 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.

00017_create_test_version_content.sql

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_idx is load-bearing, not decorative. It is the index behind §15's DELETE /teacher/media/:id → 409 if referenced by a published version and 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 the RESTRICT default in §13.2. §13.3 wants source_question_id to "preserve the bank link without coupling to it" — RESTRICT is 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_correct or accepted_answers ever 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.

9. Assignments

§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.

00018_create_assignments.sql

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_ms defaults to §10.1's 3000.
  • There is no status column [D-18]. §7 types it, but it is a pure function of opens_at, closes_at and closed_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_at exists so the admin can close early, which is the one thing the window cannot express.
  • The composite FK [D-17] proves test_version_id belongs to test_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_id is 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 own test_version_id regardless (§10).
  • assignments_window_idx serves §9's due/upcoming/completed partitioning.

00019_create_assignment_targets.sql

§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), target RESTRICT (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.

10. Attempts

The hot path. Everything here is read on every autosave.

00020_create_attempts.sql

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_seed is new and non-optional [D-02]. §7 has shuffleQuestions/shuffleOptions on 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_hash is new [D-03]. navigator.sendBeacon (§10.6) cannot set an Authorization header, and the 15-minute access token (§5.2) is normally expired by the pagehide of a 60-minute test. See 00-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_live is §13.3's, unchanged, and is what makes POST /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_idx and attempts_flagged_idx are 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_by column: deadline extensions rewrite deadline_at and the history lives in audit_log (§13.4), which is where §8's "confirm + reason" already goes.

00021_create_attempt_answers.sql

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_score is 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
    CHECK referencing it ✅ allowed
    sum() / aggregates ✅ allowed
    Logical replication ❌ stored only

    None of the rejections hurt here — final_score is only ever summed within one attempt (≤ ~100 rows). But the missing index is why pendingManual cannot be derived from it, and why requires_manual exists. Do not add ORDER BY final_score to a cross-attempt query without reading this table.

  • requires_manual is new [D-19]. §7's score.pendingManual needs an indexable predicate, and final_score IS NULL is 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 to test_version_questions.

  • graded_by / graded_at are 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 jsonb is 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's Answer) written and read as a unit, never joined, never filtered. The zod schema is the contract; the CHECK only ensures it is an object, not a bare scalar.

  • question_id ON DELETE RESTRICT, referencing test_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_idx supports the RESTRICT and the per-question analytics the normalized snapshot exists to enable.

00022_create_attempt_audio_plays.sql

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.

00023_create_attempt_events.sql

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.

  • kind is text, not an enum — see §2. Length-bounded so a malformed client cannot write an essay into an indexed-adjacent column.
  • question_id FK with ON 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 IDENTITY PK per §13.3 — narrow sequential key for an append-only log read only by attempt_id. No UPDATE or DELETE is ever issued; §11 enforces that with grants rather than convention.

11. Privileges and the student-payload rule

00009_grant_app_role.sql

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.

00079_revoke_temporary_from_public.sql

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.

The student-payload rule (§13.5)

sample_answer, transcript, is_correct, and accepted blank answers must never reach a student response. Three layers, because one is not enough:

  1. No SELECT * in application code (§13.8). Every student-facing query names its columns. The test_version_options projection for a student is id, ordinal, text — is_correct is not in the list and cannot be accidentally serialized by adding a struct field.
  2. Separate Go types. StudentQuestion and AdminQuestion are distinct structs in distinct packages, not one struct with json:"-" tags. A forgotten tag is invisible; a missing field is a compile error.
  3. 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 on isCorrect, sampleAnswer, acceptedAnswers, or transcript at 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.


12. Deviation register

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

13. Migration inventory

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–00110 and are 00114–00117. Production holds 00111–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 after 00113, in order. The next D4 migration (T-D4.31) takes 00118.

  • Every file has a -- +goose Down that is actually correct. CI runs up then down then up against a clean postgres:18 (T-0.17), so an unreversible migration is a red build on the day it is written.

  • No CREATE INDEX CONCURRENTLY in 00001–00022. They create empty tables, where a plain CREATE INDEX is instant and transactional. CONCURRENTLY becomes 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 an INVALID index if it fails.

  • NOT NULL … NOT VALID only where a populated table gains a column. Every column of a new table says NOT NULL inline at CREATE TABLE, where it is free. R2 is the first to need the PG18 construct (§13.6): 00056, 00060–00063 and 00065 backfill a new column, add NOT NULL … NOT VALID, and leave validation to v0.9.1 (§30, §31). T-0.16 proved it before it was needed.

  • 00009 runs in Phase 1, not last. It was originally scheduled for Phase 3 on the reasoning that GRANT … ON ALL TABLES must follow the tables. That is backwards: it left quizzivy_app unable 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 with permission denied for schema app. ALTER DEFAULT PRIVILEGES resolves it — applying to objects created after it, so one early migration covers every table Phases 2–5 add. Custom types need their own USAGE; schema USAGE is not enough to reference app.user_role in 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).

14. Query discipline (§13.8)

  • No SELECT * anywhere. Enforced by review and by the separate student/admin Go types described in §11.
  • ORDER BY id DESC on 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 are uuidv7(), id DESC is a valid recency order and most lists need no created_at index 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.md measures latency at seeded volume rather than asserting plan shapes.

15. Approved retention maintenance (2026-09-22)

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.

14. Approved admin lifecycle changes (spec v0.6)

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.

16. Inline option content (W-05a, spec v0.10)

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.

17. Question prose (W-05b, spec v0.11)

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.

18. Stable gap bindings (W-06b, spec v0.13)

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.

19. Independent group ownership and graph storage (W-07b)

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.

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