Skip to content
How much to read

Knowledge base

projects / easemysemester / features

Answer schema tracks staleness by construction

v2's answer table records the embedding config and prompt version an answer was built from, so a re-ingest or a prompt edit turns stale answers into a query result rather than an invisible drift.

feature · current · updated 2026-09-15 · EaseMySemester case study

See overview, v2, and citation provenance.

What it does

Each row in answers carries config_id, a foreign key to the embedding config the answer was generated from, in addition to chunk_ids (what the model read), model and prompt_version (what produced it). config_id is NOT NULL and its foreign key RESTRICTs deletion, so an active config can never be removed out from under answers that depend on it.

A change to the corpus's chunk size re-ingests and renumbers every chunk, which would otherwise make a stale answer indistinguishable from a current one. With config_id recorded, staleness becomes a comparison against the active config rather than a guess, and the generation script's --stale flag (generation script) selects exactly the answers a re-ingest or a prompt edit left behind.

chunk_ids deliberately carries no foreign key. A cascading delete would silently empty an answer's provenance on the next re-ingest, and a restricting one would block re-ingest for as long as any answer exists, turning a routine sweep into a migration. A dangling chunk id is correct here: the column is a record of what the model read at the time, and whether that record is still current is answered by comparing config_id, not by the foreign key.

Status is a paired constraint, not a convention

status is ok or declined, and two database constraints make the pairing unrepresentable any other way: an ok row must have non-empty condensed and detailed text, and a declined row must have neither, plus a decline_reason. A first-class declined status treats "the source material does not cover this" as a correct outcome, not a failure (citation provenance). A declined answer still records chunk_ids and config_id, so a later reviewer can tell a decline caused by thin sourcing from one caused by a bad prompt.

A separate check caps condensed at 1,200 characters, roughly twice any reasonable answer, as a guard against a runaway generation rather than a definition of the target length; the actual target, a phone screen read without scrolling, is set by measuring real answers, not by this guard.

Two review rounds after the initial migration

The first schema (migration 0006) stored citations as a JSONB column, which accepted any shape the generator produced, including a fabricated book. Migration 0007 replaced it with a separate answer_citations table whose book_id is a foreign key to books, so a citation naming a nonexistent book fails at the database rather than merely looking wrong. Migration 0007 made the "a complete answer has at least one citation" rule a constraint trigger that fires at commit, so the generator's answer-and-citations write can land as one transaction, and migration 0008 made the citation triggers deferrable and folded in the pairing gap, so a non-complete answer can no longer carry citations.

Status

Shipped 2026-09-07 (issue #14), reworked the same day after review found the JSONB blob's gap, and had its citation-pairing trigger made deferrable later the same day (migration 0008, issue #15 review). Verified live against production: the schema applies, and constraint tests reject an ok row with no text, a declined row carrying text, a second answer for one question, and a citation naming a book that does not exist.

This page was written by a model from my own repositories and checked by a second one against the same sources. I did not write it, and no version of it was approved by me before it went up. How that works, and where it can still be wrong.