| name | explainable-data-modeling |
| description | Schema design for Bouts feedback data — model for future questions not just current queries, normalize evidence refs, build audit trails, know when to use JSONB vs columns, and evolve schemas safely without breaking existing records. |
Explainable Data Modeling
Review Checklist
- Every JSONB column has a documented evolution contract: If a JSONB column exists, there must be a comment or migration note explaining what keys are allowed and what triggers a migration to proper columns. Verify:
\d+ table_name in psql shows column comments.
- No raw text is stored where a structured ref would serve: Any field storing a reference to a specific event (line number, tool call, timestamp) must be a structured object or FK, not a string. Verify:
grep -r "evidence\|ref" --include="*.sql" — no TEXT columns for refs.
- Audit trail tables exist for every mutable domain object: Rubrics, submissions, scores, and ranks must each have a corresponding
*_change_log or *_history table. Verify each exists.
- Operational data and analytical data live in separate tables: The primary
judge_outputs table is not the source for analytics queries. Verify: analytical queries run against *_history or *_snapshots tables, not the live operational tables.
- JSONB
scoring_bands has a max depth of 1: JSONB blobs that are arrays of flat objects are acceptable. JSONB blobs that contain nested objects within objects are a modeling failure — query time becomes a nightmare.
- Every FK has an explicit ON DELETE behavior: Default (RESTRICT) is sometimes correct, but it must be intentional. Verify: every FK in the schema has either
ON DELETE CASCADE, ON DELETE SET NULL, or is documented as intentionally RESTRICT.
- Adding a nullable column to an existing table is a valid migration: Test: write a migration that adds a
NULLABLE column to judge_outputs and verify no existing rows are affected. The migration must complete in < 1s on 100k rows.
- Non-nullable column additions use a two-step migration: First add nullable with default, backfill, then add NOT NULL constraint. Verify this pattern is used in any migration that adds a required field to a table with existing rows.
- Index strategy is documented per table: Every table with > 10k expected rows must have its index strategy documented. Common access patterns (by bout_id, by user_id, by status) must have covering indexes.
- No SELECT * in production queries: All queries must list columns explicitly.
SELECT * breaks when columns are added. Verify: grep the codebase for from('table').select('*') — each must be reviewed.
- Evidence ref normalization is consistent across all tables:
judge_evidence_refs, integrity_adjustment_refs, and any future ref table must use the same ref_type, ref_id, ref_location schema. Verify no divergence.
- Schema change log exists: The
migrations/ directory is the canonical schema change log. Every migration file is named YYYYMMDD_description.sql. Verify: no two files share a date prefix without a numeric disambiguator.
Principle: Model for Future Questions, Not Just Current Queries
The most expensive schema migrations happen when an analytical question emerges that the schema was never designed to answer.
Examples of future questions Bouts will need to answer:
- "Which judge model has the highest score variance across dimensions?" (Need: per-dimension scores normalized, with judge_model FK, not buried in JSONB)
- "Do users who score high on correctness but low on efficiency improve on retry?" (Need: per-lane scores across attempts, linkable via user_id and attempt_number)
- "Which rubric version produces the widest score distribution?" (Need: rubric_version_id on every evaluation_run)
- "What % of contradictions occur in the efficiency lane?" (Need: contradiction records with lane_key, not just a boolean flag)
Design rule: Before finalizing any table, write 5 hypothetical analytics queries against it. If any require JOIN-explosions through JSONB fields or string parsing, the schema needs to be more normalized.
SQL: Core Feedback Data Model (Full Normalized Schema)
CREATE TYPE evidence_ref_type AS ENUM (
'transcript_line',
'tool_call',
'diff_hunk',
'test_result',
'code_artifact',
'error_event'
);
CREATE TABLE evidence_refs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
ref_type evidence_ref_type NOT NULL,
ref_id TEXT NOT NULL,
ref_location TEXT,
excerpt TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CHECK (length(excerpt) <= 300)
);
CREATE TABLE dimension_score_evidence (
dimension_score_id UUID NOT NULL REFERENCES judge_dimension_scores(id) ON DELETE CASCADE,
evidence_ref_id UUID NOT NULL REFERENCES evidence_refs(id) ON DELETE RESTRICT,
PRIMARY KEY (dimension_score_id, evidence_ref_id)
);
CREATE TABLE integrity_adjustment_evidence (
integrity_adjustment_id UUID NOT NULL REFERENCES judge_integrity_adjustments(id) ON DELETE CASCADE,
evidence_ref_id UUID NOT NULL REFERENCES evidence_refs(id) ON DELETE RESTRICT,
PRIMARY KEY (integrity_adjustment_id, evidence_ref_id)
);
CREATE TABLE rubric_change_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
rubric_id UUID NOT NULL REFERENCES rubrics(id),
changed_by UUID NOT NULL REFERENCES users(id),
change_type TEXT NOT NULL,
field_name TEXT,
before_value JSONB,
after_value JSONB,
reason TEXT,
changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE score_adjustment_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
submission_id UUID NOT NULL REFERENCES submissions(id),
adjusted_by UUID REFERENCES users(id),
adjustment_type TEXT NOT NULL,
before_score NUMERIC(5,2) NOT NULL,
after_score NUMERIC(5,2) NOT NULL,
reason TEXT NOT NULL,
adjusted_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE lane_score_analytics (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
rubric_version_id UUID NOT NULL REFERENCES rubrics(id),
lane_key TEXT NOT NULL,
dimension_key TEXT NOT NULL,
judge_model TEXT NOT NULL,
bout_id UUID NOT NULL REFERENCES bouts(id),
computed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
sample_size INTEGER NOT NULL,
mean_score NUMERIC(5,2),
std_deviation NUMERIC(5,2),
p25_score NUMERIC(5,2),
p50_score NUMERIC(5,2),
p75_score NUMERIC(5,2),
UNIQUE (rubric_version_id, lane_key, dimension_key, judge_model, bout_id)
);
CREATE TABLE feedback_contradictions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
evaluation_run_id UUID NOT NULL REFERENCES evaluation_runs(id),
submission_id UUID NOT NULL REFERENCES submissions(id),
lane_key TEXT NOT NULL,
score_spread NUMERIC(5,2) NOT NULL,
judge_scores JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE synthesis_suppression_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
evaluation_run_id UUID NOT NULL REFERENCES evaluation_runs(id),
submission_id UUID NOT NULL REFERENCES submissions(id),
suppression_type TEXT NOT NULL,
lane_key TEXT,
dimension_key TEXT,
judge_model TEXT NOT NULL,
suppressed_text_hash TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_evidence_refs_type ON evidence_refs(ref_type);
CREATE INDEX idx_dse_dimension_score ON dimension_score_evidence(dimension_score_id);
CREATE INDEX idx_dse_evidence_ref ON dimension_score_evidence(evidence_ref_id);
CREATE INDEX idx_rubric_change_log_rubric_at ON rubric_change_log(rubric_id, changed_at DESC);
CREATE INDEX idx_lane_score_analytics_rubric ON lane_score_analytics(rubric_version_id, lane_key, judge_model);
CREATE INDEX idx_contradictions_bout ON feedback_contradictions(submission_id, lane_key);
CREATE INDEX idx_suppression_log_type_date ON synthesis_suppression_log(suppression_type, created_at DESC);
When to Use JSONB vs Proper Columns
This is the highest-leverage modeling decision. Getting it wrong creates unmaintainable queries and invisible data quality issues.
Use JSONB when:
- The structure is genuinely variable and you can't enumerate the keys at schema design time
- The data is always read/written as a unit (never queried field-by-field)
- The maximum nesting depth is 1 (array of flat objects is OK)
- You don't need to filter, sort, or aggregate on individual fields
Use proper columns when:
- You need to filter, sort, or aggregate on the field (
WHERE status = 'high')
- The field is part of a unique constraint or foreign key
- The field is included in an index
- Multiple tables need to reference the same data
- You need SQL-level constraints (CHECK, NOT NULL, range bounds)
The JSONB depth test: If you ever write data->'key1'->'key2'->'key3' in a query, you've gone too deep. Normalize one level up.
Schema Evolution: Adding Fields Without Breaking Existing Records
The Two-Step Migration Pattern
ALTER TABLE judge_outputs
ADD COLUMN specificity_score NUMERIC(4,2) DEFAULT NULL;
COMMENT ON COLUMN judge_outputs.specificity_score IS
'Added 2026-04-01: computed by anti-generic detector. NULL for records pre-dating this feature.';
Worked Example: Adding specificity_score to judge_outputs
const JudgeOutputSchema = z.object({
specificity_score: z.number().min(0).max(100).nullable(),
});
import { createAdminClient } from '@/lib/supabase/admin';
import { computeSpecificityScore } from '@/lib/synthesis/generic-detector';
async function backfillSpecificityScores() {
const supabase = createAdminClient();
const BATCH_SIZE = 100;
let offset = 0;
let processed = 0;
while (true) {
const { data: rows } = await supabase
.from('judge_outputs')
.select('id, positive_signal, primary_weakness')
.is('specificity_score', null)
.limit(BATCH_SIZE)
.range(offset, offset + BATCH_SIZE - 1);
if (!rows || rows.length === 0) break;
for (const row of rows) {
const score = computeSpecificityScore(
`${row.positive_signal} ${row.primary_weakness}`
);
await supabase
.from('judge_outputs')
.update({ specificity_score: score })
.eq('id', row.id);
processed++;
}
offset += BATCH_SIZE;
console.log(`Backfilled ${processed} rows...`);
await new Promise((r) => setTimeout(r, 50));
}
console.log(`Backfill complete: ${processed} rows updated`);
}
backfillSpecificityScores().catch(console.error);
Anti-Patterns
Anti-Pattern 1: Growing JSONB blob that replaces proper columns
CREATE TABLE judge_outputs (
id UUID PRIMARY KEY,
data JSONB NOT NULL
);
CREATE TABLE judge_outputs (id UUID PRIMARY KEY, overall_score INTEGER, ...);
CREATE TABLE judge_lane_scores (id UUID, judge_output_id UUID REFERENCES judge_outputs(id), ...);
CREATE TABLE judge_dimension_scores (id UUID, lane_score_id UUID REFERENCES judge_lane_scores(id), ...);
Anti-Pattern 2: Storing computed values without the source data
CREATE TABLE evaluation_results (
submission_id UUID,
final_score INTEGER,
created_at TIMESTAMPTZ
);
Anti-Pattern 3: Adding NOT NULL column to existing table in one step
ALTER TABLE judge_outputs
ADD COLUMN specificity_score NUMERIC(4,2) NOT NULL DEFAULT 0;
Common Failures to Catch in Review
| Failure | Symptom | Fix |
|---|
| Evidence stored as plain TEXT instead of structured ref | Can't render "click to line 45" link; can't filter by ref type | Convert all evidence TEXT fields to proper evidence_refs table rows; use typed evidence_ref_type |
| JSONB blob with 3+ nesting levels | Query requires data->'a'->'b'->'c'; no index possible; slow and fragile | Flatten to 1 level max; move repeated nested structures to proper columns or child tables |
| Analytical query runs against operational table | judge_outputs table has 2M rows; analytics query scans full table every page load | Pre-aggregate into lane_score_analytics; run analytics queries against snapshot tables |
| Mutable object has no audit log | Score was changed; no record of before/after; can't investigate a user complaint | Add score_adjustment_log insert whenever submission.adjusted_score changes |
| FK with no ON DELETE behavior specified | Deleting a rubric cascades silently or leaves orphaned lane_scores | Every FK must declare: CASCADE for child records, RESTRICT for important parent refs, SET NULL for optional links |
| NOT NULL column added to existing table in single migration | Migration blocks table for full backfill duration; production outage during deploy | Two-step: nullable first, backfill async, NOT NULL later |
Suppression events logged at logger.debug level only | Can't audit what content was suppressed; support can't explain why a user's feedback is thin | Write to synthesis_suppression_log table, not just logs |
scoring_bands JSONB array grows to contain nested objects | bands[0].criteria.positive_indicators[0].weight — unqueryable depth | Enforce max depth 1 on all JSONB; JSONB should contain only scalar values or flat object arrays |
| Column added without comment | New field with no documentation; next developer doesn't know what null means | Every new column must have a COMMENT ON COLUMN explaining nullability and when it was added |
| Analytics snapshots not refreshed after rubric version change | Analytics shows data from rubric v1 after v3 is active; misleading comparisons | Trigger analytics snapshot refresh after every rubric version activation |
Query Design Patterns: Explainable Retrieval
Modeling for explainability isn't just about schema structure — it's about how you query. These patterns ensure the data you surface is always traceable.
import { createClient } from '@/lib/supabase/server';
export async function loadSubmissionBreakdown(submissionId: string) {
const supabase = createClient();
const { data, error } = await supabase
.from('judge_outputs')
.select(`
id,
overall_score,
integrity_adjusted_score,
positive_signal,
primary_weakness,
confidence,
status,
judge_model,
schema_version,
lane_scores:judge_lane_scores(
id,
lane_key,
lane_score,
dimension_scores:judge_dimension_scores(
id,
dimension_key,
score,
reasoning,
band_label,
evidence:dimension_score_evidence(
evidence_refs(
id,
ref_type,
ref_id,
ref_location,
excerpt
)
)
)
),
flags:judge_flags(flag_code, severity, description, auto_disqualify),
adjustments:judge_integrity_adjustments(flag_code, description, deduction),
telemetry:judge_telemetry(tool_call_count, step_count, error_rate, wall_time_seconds)
`)
.eq('submission_id', submissionId)
.neq('status', 'failed')
.order('judge_model', { ascending: true });
if (error) throw new Error(`loadSubmissionBreakdown failed: ${error.message}`);
return data ?? [];
}
export async function loadRankHistoryForUser(
userId: string,
boutId: string
): Promise<Array<{
rankPosition: number;
totalParticipants: number;
score: number;
snapshotAt: string;
isFinal: boolean;
}>> {
const supabase = createClient();
const { data, error } = await supabase
.from('rank_history')
.select(`
rank_position,
total_participants,
score,
snapshot_at,
is_final,
snapshots:rank_snapshots(snapshot_type, trigger_type)
`)
.eq('user_id', userId)
.eq('bout_id', boutId)
.order('snapshot_at', { ascending: true });
if (error) throw new Error(`loadRankHistoryForUser failed: ${error.message}`);
return (data ?? []).map((row) => ({
rankPosition: row.rank_position,
totalParticipants: row.total_participants,
score: row.score,
snapshotAt: row.snapshot_at,
isFinal: row.is_final,
}));
}
export async function getLaneScoreDistribution(
rubricVersionId: string,
laneKey: string,
boutId: string
): Promise<{
mean: number;
p25: number;
p50: number;
p75: number;
stdDev: number;
sampleSize: number;
} | null> {
const supabase = createClient();
const { data } = await supabase
.from('lane_score_analytics')
.select('mean_score, std_deviation, p25_score, p50_score, p75_score, sample_size')
.eq('rubric_version_id', rubricVersionId)
.eq('lane_key', laneKey)
.eq('bout_id', boutId)
.order('computed_at', { ascending: false })
.limit(1)
.single();
if (!data) return null;
return {
mean: data.mean_score ?? 0,
p25: data.p25_score ?? 0,
p50: data.p50_score ?? 0,
p75: data.p75_score ?? 0,
stdDev: data.std_deviation ?? 0,
sampleSize: data.sample_size,
};
}
Changelog
- 2026-03-31: Created for Bouts premium feedback system build