diff --git a/.env.example b/.env.example
index 98d3bae..39a2d89 100644
--- a/.env.example
+++ b/.env.example
@@ -3,11 +3,19 @@
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=sb_publishable_placeholder
-# Supabase Postgres connection string — server-only. Copy it from the dashboard's Connect panel.
-# Never prefix this with NEXT_PUBLIC.
-DATABASE_URL=postgresql://postgres:[YOUR-PASSWORD]@db.[YOUR-PROJECT-REF].supabase.co:5432/postgres
+# Administrative connection used only by db:migrate — server-only.
+DATABASE_ADMIN_URL=postgresql://postgres:[ADMIN-PASSWORD]@db.[YOUR-PROJECT-REF].supabase.co:5432/postgres
-# Optional only for narration and the full nightly run; server-only.
+# Restricted application runtime credential — server-only. Provision its secret after migrations.
+# Never prefix either database credential with NEXT_PUBLIC.
+DATABASE_URL=postgresql://huddle_app:[RUNTIME-PASSWORD]@db.[YOUR-PROJECT-REF].supabase.co:5432/postgres
+
+# Optional only for narration; server-only.
ANTHROPIC_API_KEY=
+# Protected nightly and per-request refresh dispatch; server-only.
+INTERNAL_REFRESH_SECRET=replace-with-a-random-secret
+INTERNAL_REFRESH_SECRET_PREVIOUS=
+INTERNAL_REFRESH_URL=http://127.0.0.1:3000/internal/refresh
+
NODE_ENV=development
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 09bad0e..2f497f1 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -9,7 +9,8 @@ jobs:
validate:
runs-on: ubuntu-latest
env:
- DATABASE_URL: postgresql://huddle:huddle@localhost:5432/huddle
+ DATABASE_ADMIN_URL: postgresql://huddle:huddle@localhost:5432/huddle
+ DATABASE_URL: postgresql://huddle_app:huddle_app@localhost:5432/huddle
RUN_DB_TESTS: '1'
services:
postgres:
@@ -32,6 +33,7 @@ jobs:
node-version: 22
cache: npm
- run: npm ci
+ - run: psql "$DATABASE_ADMIN_URL" -c "CREATE ROLE huddle_app NOINHERIT LOGIN PASSWORD 'huddle_app'"
- run: npm run typecheck
- run: npm run lint
- run: npm run format:check
diff --git a/apps/web/app/board/entry-card.tsx b/apps/web/app/board/entry-card.tsx
index 586c395..f061a21 100644
--- a/apps/web/app/board/entry-card.tsx
+++ b/apps/web/app/board/entry-card.tsx
@@ -10,6 +10,13 @@ export function EntryCard({ row }: { row: BoardRow }) {
{formatAdditionalCauses(row.additionalCauses)} |
{row.diagnosis ?? '—'} |
{row.opener || '—'} |
+
+ {row.triageEntryId ? (
+ Open exact evidence
+ ) : (
+ '—'
+ )}
+ |
);
}
diff --git a/apps/web/app/board/evidence/[entry_id]/page.tsx b/apps/web/app/board/evidence/[entry_id]/page.tsx
new file mode 100644
index 0000000..1debbef
--- /dev/null
+++ b/apps/web/app/board/evidence/[entry_id]/page.tsx
@@ -0,0 +1,25 @@
+import { resolveGuideAccess } from '../../../../lib/guide-access';
+
+export const dynamic = 'force-dynamic';
+
+export default async function EvidencePage({ params }: { params: { entry_id: string } }) {
+ const access = await resolveGuideAccess();
+ if (!access) return Sign in with an authorized demo guide account to access this evidence.
;
+
+ const { getExactEvidenceForGuide } = await import('@huddle/db');
+ const result = await getExactEvidenceForGuide(access, params.entry_id);
+ if (!result) return Evidence was not found in this guide scope.
;
+
+ return (
+
+ Exact evidence
+
+ Board run {result.boardRunId}; finding fingerprint {result.findingFingerprint}.
+
+ {JSON.stringify(result.evidence, null, 2)}
+
+ Back to board
+
+
+ );
+}
diff --git a/apps/web/app/board/lib/triage.ts b/apps/web/app/board/lib/triage.ts
index 584f1cb..3f110c7 100644
--- a/apps/web/app/board/lib/triage.ts
+++ b/apps/web/app/board/lib/triage.ts
@@ -4,6 +4,7 @@ import {
loadHandAuthoredFixtures,
type RootCause,
} from '@huddle/core';
+import type { BoardView } from '@huddle/application';
import { items, skillPrereqs, skills } from '@huddle/core/seed';
import { allRules, rankBySeverity, runEngine } from '@huddle/signal-engine';
import { RULE_CONFIG } from '@huddle/signal-engine/config/thresholds.js';
@@ -11,6 +12,7 @@ import { RULE_CONFIG } from '@huddle/signal-engine/config/thresholds.js';
export type BoardRow = {
/** Stable domain identity; never a display name alias. */
studentId: string;
+ triageEntryId?: string;
studentFirstName: string;
rank: number;
severity: number;
@@ -39,10 +41,30 @@ export type BoardRequestDependencies = {
getGuideScopeForAuthUser(authUserId: string): Promise;
/** The sole request-time data read, after identity and scope resolve. */
getTriageBoardForGuide(scope: GuideScope, boardDate: string): Promise;
+ getBoardViewForGuide?(scope: GuideScope, boardDate: string): Promise;
};
export type AuthenticatedBoardResult =
- { status: 'authorized'; rows: BoardRow[] } | { status: 'unavailable'; rows: [] };
+ | {
+ status: 'authorized';
+ rows: BoardRow[];
+ board?: {
+ kind: BoardView['kind'];
+ requestedBoardDate: string;
+ boardDate: string | null;
+ refreshState: BoardView['refresh']['state'];
+ };
+ }
+ | { status: 'unavailable'; rows: [] };
+
+type BoardState = NonNullable['board']>;
+
+export function formatBoardStatus(board: BoardState): string {
+ if (board.kind === 'not-built') return `Board not built. Refresh state: ${board.refreshState}.`;
+ if (board.kind === 'stale')
+ return `Showing the last successful board from ${board.boardDate}; refresh for ${board.requestedBoardDate} is ${board.refreshState}.`;
+ return `Board for ${board.requestedBoardDate}. Refresh state: ${board.refreshState}.`;
+}
/**
* Request-time board boundary. No caller may supply a guide id: an authenticated Supabase identity
@@ -70,14 +92,21 @@ export async function getBoardEntriesForAuthenticatedRequest(
const { resolveGuideAccess } = await import('../../../lib/guide-access');
const access = await resolveGuideAccess();
if (!access) return { status: 'unavailable', rows: [] };
- const { getTriageBoardForGuide } = await import('@huddle/db/scoped.js');
- const rows = await getTriageBoardForGuide(access, resolvedBoardDate);
- return toBoardResult(rows);
+ const { getBoardViewForGuide } = await import('@huddle/db/scoped.js');
+ return toBoardViewResult(
+ await getBoardViewForGuide(access, resolvedBoardDate),
+ resolvedBoardDate
+ );
}
const authUserId = await dependencies.getVerifiedAuthUserId();
if (!authUserId) return { status: 'unavailable', rows: [] };
const scope = await dependencies.getGuideScopeForAuthUser(authUserId);
if (!scope) return { status: 'unavailable', rows: [] };
+ if (dependencies.getBoardViewForGuide)
+ return toBoardViewResult(
+ await dependencies.getBoardViewForGuide(scope, resolvedBoardDate),
+ resolvedBoardDate
+ );
return toBoardResult(await dependencies.getTriageBoardForGuide(scope, resolvedBoardDate));
}
@@ -92,6 +121,50 @@ function toBoardResult(rows: PersistedBoardRow[]): AuthenticatedBoardResult {
};
}
+function toBoardViewResult(view: BoardView, requestedBoardDate: string): AuthenticatedBoardResult {
+ if (view.kind === 'not-built')
+ return {
+ status: 'authorized',
+ rows: [],
+ board: {
+ kind: view.kind,
+ requestedBoardDate,
+ boardDate: null,
+ refreshState: view.refresh.state,
+ },
+ };
+ return {
+ status: 'authorized',
+ rows: view.entries.map((entry) => ({
+ studentId: entry.student.id,
+ triageEntryId: entry.triageEntryId,
+ studentFirstName: entry.student.firstName,
+ rank: entry.rank,
+ severity: entry.severity,
+ cause: entry.cause,
+ skillName:
+ entry.scope.kind === 'skill'
+ ? `${entry.scope.skill.code} — ${entry.scope.skill.name}`
+ : '—',
+ diagnosis: entry.diagnosis,
+ opener: entry.opener,
+ additionalCauses:
+ (
+ entry as typeof entry & {
+ additionalCauses?: Array<{ cause: RootCause; severity: number }>;
+ }
+ ).additionalCauses ?? [],
+ since: view.completedAt.slice(0, 10),
+ })),
+ board: {
+ kind: view.kind,
+ requestedBoardDate: view.requestedBoardDate,
+ boardDate: view.boardDate,
+ refreshState: view.refresh.state,
+ },
+ };
+}
+
/**
* Explicit synthetic test fixture only. It never runs in a browser request and has no database or
* auth fallback role; its purpose is to prove the deterministic engine survives without narration.
diff --git a/apps/web/app/import/actions.ts b/apps/web/app/import/actions.ts
new file mode 100644
index 0000000..9602bec
--- /dev/null
+++ b/apps/web/app/import/actions.ts
@@ -0,0 +1,96 @@
+'use server';
+
+import type { ImportPreview, ImportReceipt } from '@huddle/application';
+import { resolveGuideAccess } from '../../lib/guide-access';
+import { emitOperationalEvent } from '../../lib/operational-events';
+
+export type ImportActionResult =
+ | { kind: 'validated'; preview: ImportPreview }
+ | { kind: 'committed'; receipt: ImportReceipt }
+ | { kind: 'error'; message: string };
+
+function uploadedFile(formData: FormData): File | null {
+ const value = formData.get('file');
+ return value instanceof File && value.size > 0 ? value : null;
+}
+export async function validateImportAction(formData: FormData): Promise {
+ const access = await resolveGuideAccess();
+ const file = uploadedFile(formData);
+ const idempotencyKey = formData.get('idempotencyKey');
+ if (!access || !file || typeof idempotencyKey !== 'string' || idempotencyKey.length === 0)
+ return { kind: 'error', message: 'The synthetic import could not be validated.' };
+ try {
+ const { importerFor, syntheticImportFile } = await import('../../lib/operations');
+ const importer = await importerFor(access);
+ return {
+ kind: 'validated',
+ preview: await importer.validate(access, await syntheticImportFile(file, idempotencyKey)),
+ };
+ } catch {
+ return { kind: 'error', message: 'The synthetic import could not be validated.' };
+ }
+}
+export async function requestManualRefreshAction(): Promise<{
+ kind: 'queued' | 'succeeded' | 'error';
+ message: string;
+}> {
+ const access = await resolveGuideAccess();
+ if (!access) return { kind: 'error', message: 'The refresh could not be requested.' };
+ try {
+ const { chicagoBoardDate, compilerFor } = await import('../../lib/operations');
+ const compiler = compilerFor();
+ const requested = await compiler.request({
+ trigger: 'manual',
+ access,
+ boardDate: chicagoBoardDate(),
+ });
+ if (requested.state === 'succeeded')
+ return { kind: 'succeeded', message: 'The current board is already refreshed.' };
+ if (requested.state !== 'queued')
+ return { kind: 'error', message: 'The refresh could not be requested.' };
+ const result = await compiler.compile(
+ { workerId: 'server-manual-refresh', capability: 'board-refresh', syntheticOnly: true },
+ requested.requestId
+ );
+ emitOperationalEvent({
+ event: 'refresh',
+ state: result.kind,
+ correlationId: requested.requestId,
+ });
+ return result.kind === 'succeeded'
+ ? { kind: 'succeeded', message: 'Board refresh completed.' }
+ : { kind: 'error', message: 'The prior board was preserved because refresh failed.' };
+ } catch {
+ return { kind: 'error', message: 'The refresh could not be requested.' };
+ }
+}
+export async function commitImportAction(
+ validatedImportId: string,
+ formData: FormData
+): Promise {
+ const access = await resolveGuideAccess();
+ const file = uploadedFile(formData);
+ const idempotencyKey = formData.get('idempotencyKey');
+ if (!access || !file || typeof idempotencyKey !== 'string')
+ return { kind: 'error', message: 'The synthetic import could not be committed.' };
+ try {
+ const { importerFor, syntheticImportFile } = await import('../../lib/operations');
+ const importer = await importerFor(access);
+ const receipt = await importer.commit(
+ access,
+ validatedImportId,
+ await syntheticImportFile(file, idempotencyKey)
+ );
+ emitOperationalEvent({
+ event: 'import',
+ state: receipt.status,
+ correlationId: receipt.importId,
+ inputFingerprint: receipt.payloadDigest,
+ accepted: receipt.counts.accepted,
+ rejected: receipt.counts.rejected,
+ });
+ return { kind: 'committed', receipt };
+ } catch {
+ return { kind: 'error', message: 'The synthetic import could not be committed.' };
+ }
+}
diff --git a/apps/web/app/import/import-workflow.tsx b/apps/web/app/import/import-workflow.tsx
new file mode 100644
index 0000000..a4b20b2
--- /dev/null
+++ b/apps/web/app/import/import-workflow.tsx
@@ -0,0 +1,119 @@
+'use client';
+
+import { useRef, useState, useTransition } from 'react';
+import type { ImportPreview, ImportReceipt } from '@huddle/application';
+import { commitImportAction, requestManualRefreshAction, validateImportAction } from './actions';
+
+export function ImportWorkflow() {
+ const input = useRef(null);
+ const [preview, setPreview] = useState(null);
+ const [receipt, setReceipt] = useState(null);
+ const [error, setError] = useState(null);
+ const [pending, startTransition] = useTransition();
+ const [idempotencyKey] = useState(() => crypto.randomUUID());
+ const file = () => input.current?.files?.[0] ?? null;
+ const formData = () => {
+ const data = new FormData();
+ const selected = file();
+ if (selected) data.set('file', selected);
+ data.set('idempotencyKey', idempotencyKey);
+ return data;
+ };
+ const validate = () =>
+ startTransition(async () => {
+ setError(null);
+ setReceipt(null);
+ setPreview(null);
+ if (!file()) {
+ setError('Choose the included synthetic CSV first.');
+ return;
+ }
+ const result = await validateImportAction(formData());
+ if (result.kind === 'validated') setPreview(result.preview);
+ else if (result.kind === 'error') setError(result.message);
+ });
+ const commit = () =>
+ startTransition(async () => {
+ if (!preview || !file()) {
+ setError('Choose the file again before committing.');
+ return;
+ }
+ const result = await commitImportAction(preview.importId, formData());
+ if (result.kind === 'committed') setReceipt(result.receipt);
+ else if (result.kind === 'error') setError(result.message);
+ });
+ const result = receipt ?? preview;
+ return (
+
+ Import synthetic activity
+
+ Use the fixed demo CSV only. Validation never publishes activity; refresh is a separate
+ action.
+
+
+
+ Download the synthetic CSV sample
+
+
+
+ {
+ setPreview(null);
+ setReceipt(null);
+ setError(null);
+ }}
+ />
+
+ {preview && !receipt ? (
+
+ ) : null}
+ {receipt ? (
+
+ ) : null}
+ {error ? {error}
: null}
+ {result ? (
+ <>
+ {receipt ? 'Import committed' : 'Validation preview'}
+
+ - Received
+ - {result.counts.received}
+ - Accepted
+ - {result.counts.accepted}
+ - Duplicates
+ - {result.counts.duplicate}
+ - Unmapped
+ - {result.counts.unmapped}
+ - Rejected
+ - {result.counts.rejected}
+
+
+ {result.issues.map((issue) => (
+ -
+ Row {issue.rowNumber || 'file'}: {issue.safeDetail}
+
+ ))}
+
+ >
+ ) : null}
+
+ );
+}
diff --git a/apps/web/app/import/page.tsx b/apps/web/app/import/page.tsx
new file mode 100644
index 0000000..bd0c75b
--- /dev/null
+++ b/apps/web/app/import/page.tsx
@@ -0,0 +1,9 @@
+import { ImportWorkflow } from './import-workflow';
+
+export default function ImportPage() {
+ return (
+
+
+
+ );
+}
diff --git a/apps/web/app/internal/refresh/route.ts b/apps/web/app/internal/refresh/route.ts
new file mode 100644
index 0000000..c44731d
--- /dev/null
+++ b/apps/web/app/internal/refresh/route.ts
@@ -0,0 +1,97 @@
+import { createHmac, timingSafeEqual } from 'node:crypto';
+import {
+ chicagoBoardDate,
+ isNightlyDispatchEligible,
+ nightlyDispatchPayload,
+ validateNightlyDispatchOverrides,
+} from '@huddle/application';
+
+export const runtime = 'nodejs';
+const MAX_AGE_MS = 5 * 60_000;
+
+function validSignature(payload: string, timestamp: string, signature: string): boolean {
+ const age = Math.abs(Date.now() - Number(timestamp));
+ if (!Number.isFinite(age) || age > MAX_AGE_MS) return false;
+ const secrets = [
+ process.env.INTERNAL_REFRESH_SECRET,
+ process.env.INTERNAL_REFRESH_SECRET_PREVIOUS,
+ ].filter(Boolean) as string[];
+ return secrets.some((secret) => {
+ const expected = createHmac('sha256', secret).update(`${payload}.${timestamp}`).digest('hex');
+ try {
+ return timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(signature, 'hex'));
+ } catch {
+ return false;
+ }
+ });
+}
+
+/** Internal scheduler endpoint: identity/scope comes from the claimed DB row, never JSON. */
+export async function POST(request: Request) {
+ let body: Record;
+ try {
+ const value = await request.json();
+ if (value == null || typeof value !== 'object' || Array.isArray(value))
+ return Response.json({ ok: false }, { status: 404 });
+ body = value as Record;
+ } catch {
+ return Response.json({ ok: false }, { status: 404 });
+ }
+ const allowedKeys = new Set(['requestId', 'nightly', 'boardDate', 'fixtureNow']);
+ if (Object.keys(body).some((key) => !allowedKeys.has(key)))
+ return Response.json({ ok: false }, { status: 404 });
+ const requestId = typeof body.requestId === 'string' ? body.requestId : '';
+ const nightly = body.nightly === true;
+ if ((nightly && body.requestId !== undefined) || (!nightly && body.nightly !== undefined))
+ return Response.json({ ok: false }, { status: 404 });
+ const overrides = validateNightlyDispatchOverrides(
+ { boardDate: body.boardDate, fixtureNow: body.fixtureNow },
+ process.env.NODE_ENV !== 'production'
+ );
+ if (!overrides || (!nightly && (overrides.boardDate || overrides.fixtureNow)))
+ return Response.json({ ok: false }, { status: 404 });
+ const signedPayload = nightly ? nightlyDispatchPayload(overrides) : requestId;
+ const timestamp = request.headers.get('x-huddle-timestamp') ?? '';
+ const signature = request.headers.get('x-huddle-signature') ?? '';
+ if (!signedPayload || !validSignature(signedPayload, timestamp, signature))
+ return Response.json({ ok: false }, { status: 404 });
+ const { compilerFor } = await import('../../../lib/operations');
+ if (nightly) {
+ const now = overrides.fixtureNow ? new Date(overrides.fixtureNow) : new Date();
+ if (!isNightlyDispatchEligible(now))
+ return Response.json({ ok: true, eligible: false, dispatched: 0 });
+ const { enqueueNightlyRefreshes, reapExpiredRefreshes } = await import('@huddle/db');
+ await reapExpiredRefreshes();
+ const queued = await enqueueNightlyRefreshes(
+ overrides.boardDate ?? chicagoBoardDate(now),
+ 'edge-refresh-dispatch'
+ );
+ const compiler = overrides.fixtureNow ? compilerFor({ now: () => now }) : compilerFor();
+ const { emitOperationalEvent } = await import('../../../lib/operational-events');
+ let succeeded = 0;
+ for (const refresh of queued) {
+ const result = await compiler.compile(
+ { workerId: 'edge-refresh-dispatch', capability: 'board-refresh', syntheticOnly: true },
+ refresh.id
+ );
+ if (result.kind === 'succeeded') succeeded += 1;
+ emitOperationalEvent({
+ event: 'refresh',
+ state: result.kind,
+ correlationId: refresh.id,
+ failureCode: result.kind === 'failed' ? result.failureCode : undefined,
+ });
+ }
+ return Response.json({
+ ok: succeeded === queued.length,
+ eligible: true,
+ dispatched: queued.length,
+ succeeded,
+ });
+ }
+ const result = await compilerFor().compile(
+ { workerId: 'edge-refresh-dispatch', capability: 'board-refresh', syntheticOnly: true },
+ requestId
+ );
+ return Response.json({ ok: result.kind === 'succeeded' });
+}
diff --git a/apps/web/lib/operational-events.ts b/apps/web/lib/operational-events.ts
new file mode 100644
index 0000000..3cbe487
--- /dev/null
+++ b/apps/web/lib/operational-events.ts
@@ -0,0 +1,31 @@
+import 'server-only';
+
+export type OperationalEvent = {
+ event: 'import' | 'refresh';
+ state: string;
+ correlationId: string;
+ durationMs?: number;
+ failureCode?: string;
+ inputFingerprint?: string;
+ accepted?: number;
+ rejected?: number;
+ entryCount?: number;
+ degradedNarrationCount?: number;
+};
+/** Emits only bounded operational fields; callers must never pass rows, names, answers, or credentials. */
+export function emitOperationalEvent(event: OperationalEvent): void {
+ console.info(
+ JSON.stringify({
+ event: event.event,
+ state: event.state,
+ correlationId: event.correlationId,
+ durationMs: event.durationMs,
+ failureCode: event.failureCode,
+ inputFingerprint: event.inputFingerprint,
+ accepted: event.accepted,
+ rejected: event.rejected,
+ entryCount: event.entryCount,
+ degradedNarrationCount: event.degradedNarrationCount,
+ })
+ );
+}
diff --git a/apps/web/lib/operations.ts b/apps/web/lib/operations.ts
new file mode 100644
index 0000000..a5f6f29
--- /dev/null
+++ b/apps/web/lib/operations.ts
@@ -0,0 +1,283 @@
+import 'server-only';
+
+import { createHash } from 'node:crypto';
+import {
+ createBoardCompiler,
+ createImporter,
+ chicagoBoardDate,
+ chicagoBoardWindow,
+ type BoardCompiler,
+ type Importer,
+ type SyntheticImportFile,
+} from '@huddle/application';
+import type { Attempt, EvidenceBundle } from '@huddle/core';
+import { items, skillPrereqs, skills } from '@huddle/core/seed';
+import { deterministicFallback } from '@huddle/narrator';
+import { allRules, compareSignals, runEngine } from '@huddle/signal-engine';
+import { RULE_CONFIG } from '@huddle/signal-engine/config/thresholds.js';
+import {
+ claimRefresh,
+ createDbImportPersistence,
+ failRefresh,
+ getSyntheticRosterForGuide,
+ heartbeatRefresh,
+ loadCompilerSnapshot,
+ publishCompiledBoardRun,
+ requestRefresh,
+ type CompiledMastery,
+ type CompiledEntry,
+ type CompiledSignal,
+ type PoolClient,
+} from '@huddle/db';
+import type { GuideAccess } from '@huddle/application';
+
+/** Server-only composition. Browser inputs provide bytes only; roster/scope comes from GuideAccess. */
+export async function importerFor(access: GuideAccess): Promise {
+ const roster = await getSyntheticRosterForGuide({
+ guideId: access.guideId,
+ studioId: access.studioId,
+ });
+ return createImporter(
+ {
+ students: new Map(roster.map((student) => [student.pseudonymous_ref, { id: student.id }])),
+ skills: new Set(skills.map((skill) => skill.id)),
+ items: new Map(items.map((item) => [item.id, item])),
+ },
+ createDbImportPersistence()
+ );
+}
+
+async function compileExactSnapshot(
+ claim: Parameters[0]['publish']>>[0],
+ client: PoolClient
+) {
+ const window = chicagoBoardWindow(claim.boardDate);
+ const snapshot = await loadCompilerSnapshot(claim, window, client);
+ const attempts: Attempt[] = snapshot.attempts.map((row) => ({
+ id: Number(row.id),
+ // Source remains opaque: it participates only in the persisted event identity.
+ // eslint-disable-next-line no-restricted-syntax
+ activityId: createHash('sha256').update(`${row.source}:${row.source_event_id}`).digest('hex'),
+ studentId: row.student_id,
+ skillId: row.skill_id,
+ itemId: row.item_id,
+ sessionId: String(row.learning_session_id),
+ attemptIndex: Number(row.attempt_index),
+ startedAt: new Date(row.started_at),
+ submittedAt: new Date(row.submitted_at),
+ elapsedMs: row.elapsed_ms,
+ engagedMs: row.engaged_ms,
+ sessionTotalMs: row.total_elapsed_ms,
+ timingQuality: row.timing_quality,
+ timingWasWinsorized: row.timing_was_winsorized,
+ isCorrect: row.is_correct,
+ answerGiven: row.answer_given,
+ hintsUsed: Number(row.hints_used),
+ ingestedAt: new Date(row.ingested_at),
+ }));
+ const masteryByAnchor = new Map(
+ [
+ [window.end, snapshot.currentMastery],
+ [window.start, snapshot.priorMastery],
+ ].map(([anchor, rows]) => [
+ (anchor as Date).toISOString(),
+ new Map(
+ (rows as typeof snapshot.currentMastery).map((row) => [
+ `${row.student_id}:${row.skill_id}`,
+ row.is_known && row.value != null
+ ? { value: Number(row.value), isKnown: true as const }
+ : { value: null, isKnown: false as const },
+ ])
+ ),
+ ])
+ );
+ const attendance = snapshot.absences.map((row) => ({
+ id: Number(row.id),
+ studentId: row.student_id,
+ startDate: row.start_date,
+ endDate: row.end_date,
+ // Attendance provenance is copied opaquely; no downstream behavior branches on it.
+ // eslint-disable-next-line no-restricted-syntax
+ source: row.source,
+ }));
+ const engineSignals: ReturnType['signals'] = [];
+ let nextSignalId = 1;
+ for (const student of snapshot.students) {
+ const studentSignals = runEngine(
+ {
+ students: [{ id: student.id, firstName: student.first_name }],
+ skills,
+ attempts: attempts.filter((attempt) => attempt.studentId === student.id),
+ items,
+ skillPrereqs,
+ mastery: {
+ at(skillId, anchor) {
+ const values = masteryByAnchor.get(anchor.toISOString());
+ if (!values)
+ throw new Error(`Mastery anchor ${anchor.toISOString()} was not captured.`);
+ return values.get(`${student.id}:${skillId}`) ?? { value: null, isKnown: false };
+ },
+ },
+ attendance: attendance.filter((span) => span.studentId === student.id),
+ attendanceLoaded: true,
+ config: RULE_CONFIG,
+ now: window.end,
+ window,
+ },
+ allRules
+ ).signals.filter((signal) => signal.kind !== 'fine');
+ const idMap = new Map();
+ for (const signal of studentSignals) idMap.set(signal.id!, nextSignalId++);
+ for (const signal of studentSignals) {
+ signal.id = idMap.get(signal.id!)!;
+ const evidence = signal.evidence as EvidenceBundle;
+ signal.evidence = {
+ ...evidence,
+ additionalCauses: evidence.additionalCauses.map((cause) => ({
+ ...cause,
+ signalId: idMap.get(cause.signalId) ?? cause.signalId,
+ })),
+ };
+ signal.evidenceFingerprint = createHash('sha256')
+ .update(JSON.stringify(signal.evidence))
+ .digest('hex');
+ engineSignals.push(signal);
+ }
+ }
+ const fallbackBySignal = engineSignals.map((signal) => {
+ const fallback = deterministicFallback(signal.evidence as EvidenceBundle);
+ signal.evidence = fallback.bundle;
+ signal.evidenceFingerprint = createHash('sha256')
+ .update(JSON.stringify(fallback.bundle))
+ .digest('hex');
+ return fallback;
+ });
+ const byStudent = new Map();
+ engineSignals.forEach((signal, index) =>
+ byStudent.set(signal.studentId, [...(byStudent.get(signal.studentId) ?? []), index])
+ );
+ const grouped = [...byStudent.entries()].map(([studentId, indexes]) => {
+ indexes.sort((a, b) => compareSignals(engineSignals[a]!, engineSignals[b]!));
+ return { studentId, dominant: indexes[0]!, additional: indexes.slice(1) };
+ });
+ grouped.sort((a, b) => compareSignals(engineSignals[a.dominant]!, engineSignals[b.dominant]!));
+ const signals: CompiledSignal[] = engineSignals.map((signal, index) => {
+ const fallback = fallbackBySignal[index]!;
+ return {
+ studentId: signal.studentId,
+ skillId: signal.skillId,
+ kind: signal.kind,
+ ruleId: signal.ruleId,
+ ruleVersion: signal.ruleVersion,
+ severity: signal.severity,
+ confidence: signal.finalConfidence ?? signal.confidence,
+ rawConfidence: signal.rawConfidence ?? signal.confidence,
+ evidence: signal.evidence,
+ behaviorFingerprint: signal.behaviorFingerprint ?? '0'.repeat(64),
+ evidenceFingerprint:
+ signal.evidenceFingerprint ??
+ createHash('sha256').update(JSON.stringify(signal.evidence)).digest('hex'),
+ fallbackCatalogVersion: fallback.catalogVersion,
+ fallbackRenderVersion: fallback.renderVersion,
+ fallbackLanguageFingerprint: fallback.languageFingerprint,
+ fallbackNarrationFingerprint: fallback.narrationFingerprint,
+ fallbackNarrationSelection: fallback.selection,
+ fallbackDiagnosis: fallback.diagnosis,
+ fallbackOpener: fallback.opener,
+ windowStart: signal.windowStart,
+ windowEnd: signal.windowEnd,
+ computedAt: signal.computedAt,
+ };
+ });
+ const entries: CompiledEntry[] = grouped.map(({ studentId, dominant, additional }, index) => {
+ const language = fallbackBySignal[dominant]!;
+ return {
+ studentId,
+ dominant,
+ rank: index + 1,
+ additional,
+ findingFingerprint: createHash('sha256')
+ .update(
+ JSON.stringify([
+ studentId,
+ signals[dominant]!.evidenceFingerprint,
+ ...additional.map((i) => signals[i]!.evidenceFingerprint),
+ ])
+ )
+ .digest('hex'),
+ catalogVersion: language.catalogVersion,
+ renderVersion: language.renderVersion,
+ languageFingerprint: language.languageFingerprint,
+ narrationFingerprint: language.narrationFingerprint,
+ narrationSelection: language.selection,
+ diagnosis: language.diagnosis,
+ opener: language.opener,
+ };
+ });
+ const mastery: CompiledMastery[] = snapshot.currentMastery.map((row) => ({
+ studentId: row.student_id,
+ skillId: row.skill_id,
+ value: row.value == null ? null : Number(row.value),
+ attemptCount: Number(row.attempt_count),
+ isKnown: row.is_known,
+ }));
+ return { window, mastery, signals, entries };
+}
+
+export function compilerFor(options: { now?: () => Date } = {}): BoardCompiler {
+ return createBoardCompiler(
+ {
+ async request(input) {
+ const row = await requestRefresh(input);
+ if (row.state === 'queued' || row.state === 'running')
+ return {
+ state: row.state,
+ requestId: row.id,
+ requestedAt: row.requested_at.toISOString(),
+ };
+ if (row.state === 'succeeded')
+ return {
+ state: 'succeeded',
+ requestId: row.id,
+ completedAt: row.completed_at!.toISOString(),
+ boardRunId: row.resulting_board_run_id!,
+ };
+ return {
+ state: 'failed',
+ requestId: row.id,
+ completedAt: row.completed_at!.toISOString(),
+ failureCode: row.failure_code!,
+ preservedBoardRunId: row.preserved_board_run_id,
+ };
+ },
+ async claim(worker, requestId, leaseMs) {
+ const row = await claimRefresh(worker.workerId, requestId, leaseMs);
+ return (
+ row && {
+ requestId: row.id,
+ workerId: row.claimed_by_worker_id,
+ applicationScopeId: row.application_scope_id,
+ guideId: row.guide_id,
+ studioId: row.studio_id,
+ boardDate: row.board_date,
+ trigger: row.trigger,
+ leaseExpiresAt: row.lease_expires_at.toISOString(),
+ }
+ );
+ },
+ heartbeat: (worker, id, leaseMs) => heartbeatRefresh(worker.workerId, id, leaseMs),
+ publish: (claim) =>
+ publishCompiledBoardRun(claim, (client) => compileExactSnapshot(claim, client)),
+ fail: (claim, code) => failRefresh(claim.requestId, code),
+ },
+ options
+ );
+}
+export { chicagoBoardDate };
+export async function syntheticImportFile(
+ file: File,
+ idempotencyKey: string
+): Promise {
+ const content = new Uint8Array(await file.arrayBuffer());
+ return { name: file.name, mediaType: 'text/csv', sizeBytes: file.size, content, idempotencyKey };
+}
diff --git a/apps/web/next.config.js b/apps/web/next.config.js
index d5456a1..4db7937 100644
--- a/apps/web/next.config.js
+++ b/apps/web/next.config.js
@@ -1,6 +1,8 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
+ // Transport is deliberately above the 5 MiB application limit for multipart overhead.
+ experimental: { serverActions: { bodySizeLimit: '6mb' } },
};
export default nextConfig;
diff --git a/apps/web/package.json b/apps/web/package.json
index 013c254..861b487 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -13,6 +13,7 @@
"@huddle/application": "^0.1.0",
"@huddle/core": "^0.1.0",
"@huddle/db": "^0.1.0",
+ "@huddle/narrator": "^0.1.0",
"@huddle/signal-engine": "^0.1.0",
"@supabase/ssr": "^0.5.2",
"@supabase/supabase-js": "^2.111.0",
diff --git a/apps/web/public/synthetic-huddle-sample.csv b/apps/web/public/synthetic-huddle-sample.csv
new file mode 100644
index 0000000..3b790ec
--- /dev/null
+++ b/apps/web/public/synthetic-huddle-sample.csv
@@ -0,0 +1,4 @@
+dataset_kind,source_event_id,pseudonymous_student_ref,vendor_skill,vendor_item_ref,source_session_id,session_started_at,session_ended_at,session_total_ms,vendor_attempt_count,attempt_index,started_at,submitted_at,elapsed_ms,engaged_ms,timing_quality,is_correct,answer_given,hints_used
+synthetic,demo-event-0001,synthetic-student-01,TEKS.4.2A,mcq:TEKS.4.2A-01,demo-session-0001,2026-07-28T14:00:00.000Z,2026-07-28T14:04:00.000Z,,,1,2026-07-28T14:00:00.000Z,2026-07-28T14:00:20.000Z,20000,,wallclock,false,B,0
+synthetic,demo-event-0002,synthetic-student-01,TEKS.4.2A,mcq:TEKS.4.2A-01,demo-session-0002,2026-07-28T15:00:00.000Z,2026-07-28T15:03:00.000Z,,,1,2026-07-28T15:00:00.000Z,2026-07-28T15:00:15.000Z,15000,,wallclock,false,B,1
+synthetic,demo-event-0003,synthetic-student-01,TEKS.4.2A,mcq:TEKS.4.2A-01,demo-session-0003,2026-07-28T16:00:00.000Z,2026-07-28T16:03:00.000Z,,,1,2026-07-28T16:00:00.000Z,2026-07-28T16:00:18.000Z,18000,,wallclock,false,B,0
diff --git a/apps/web/test/board-without-narrator.test.ts b/apps/web/test/board-without-narrator.test.ts
index 79e5a90..3df614a 100644
--- a/apps/web/test/board-without-narrator.test.ts
+++ b/apps/web/test/board-without-narrator.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
-import { getSyntheticFixtureBoardEntriesForTest } from '../app/board/lib/triage';
+import { formatBoardStatus, getSyntheticFixtureBoardEntriesForTest } from '../app/board/lib/triage';
import { dynamic } from '../app/board/page';
import { formatAdditionalCauses } from '../app/board/entry-card';
@@ -40,4 +40,31 @@ describe('board rendering mode', () => {
it('reads nightly-backed data on every request', () => {
expect(dynamic).toBe('force-dynamic');
});
+
+ it('presents freshness and refresh state before empty-board meaning', () => {
+ expect(
+ formatBoardStatus({
+ kind: 'stale',
+ requestedBoardDate: '2026-07-29',
+ boardDate: '2026-07-28',
+ refreshState: 'failed',
+ })
+ ).toContain('last successful board from 2026-07-28');
+ expect(
+ formatBoardStatus({
+ kind: 'successful-empty',
+ requestedBoardDate: '2026-07-29',
+ boardDate: '2026-07-29',
+ refreshState: 'running',
+ })
+ ).toContain('Refresh state: running');
+ expect(
+ formatBoardStatus({
+ kind: 'not-built',
+ requestedBoardDate: '2026-07-29',
+ boardDate: null,
+ refreshState: 'queued',
+ })
+ ).toBe('Board not built. Refresh state: queued.');
+ });
});
diff --git a/apps/web/test/import-actions-fail-closed.test.ts b/apps/web/test/import-actions-fail-closed.test.ts
new file mode 100644
index 0000000..9e0a42b
--- /dev/null
+++ b/apps/web/test/import-actions-fail-closed.test.ts
@@ -0,0 +1,28 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+vi.mock('../lib/guide-access', () => ({
+ resolveGuideAccess: vi.fn(async () => null),
+}));
+vi.mock('../lib/operational-events', () => ({
+ emitOperationalEvent: vi.fn(),
+}));
+
+describe('import actions fail-closed boundary', () => {
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ vi.resetModules();
+ });
+
+ it('does not import the database boundary before rejecting an unavailable guide scope', async () => {
+ vi.stubEnv('DATABASE_URL', '');
+ const { validateImportAction } = await import('../app/import/actions');
+ const formData = new FormData();
+ formData.set('file', new File(['dataset_kind\nsynthetic'], 'sample.csv', { type: 'text/csv' }));
+ formData.set('idempotencyKey', 'test-key');
+
+ await expect(validateImportAction(formData)).resolves.toEqual({
+ kind: 'error',
+ message: 'The synthetic import could not be validated.',
+ });
+ });
+});
diff --git a/apps/web/test/quick-demo-access.test.ts b/apps/web/test/quick-demo-access.test.ts
index 022088e..574a564 100644
--- a/apps/web/test/quick-demo-access.test.ts
+++ b/apps/web/test/quick-demo-access.test.ts
@@ -101,7 +101,8 @@ describe('quick-demo server credential boundary', () => {
expect(browserClient).not.toMatch(/return client;|\.from\(/i);
expect(loginForm).not.toMatch(/@huddle\/db|DATABASE_URL|SERVICE_ROLE/i);
expect(serverDataLayer).toContain("import('@huddle/db/scoped.js')");
- expect(envExample).toMatch(/Supabase Postgres connection string — server-only/i);
+ expect(envExample).toMatch(/Administrative connection used only by db:migrate/i);
+ expect(envExample).toMatch(/DATABASE_URL=postgresql:\/\/huddle_app:/);
expect(envExample).not.toMatch(/^NEXT_PUBLIC_.*(?:SERVICE|DATABASE)/im);
});
});
diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json
index 5f3f0d8..e69349f 100644
--- a/apps/web/tsconfig.json
+++ b/apps/web/tsconfig.json
@@ -30,6 +30,9 @@
},
{
"path": "../../packages/signal-engine"
+ },
+ {
+ "path": "../../packages/narrator"
}
],
"exclude": ["node_modules"]
diff --git a/db/migrate.ts b/db/migrate.ts
index 31c1b0c..9d70421 100644
--- a/db/migrate.ts
+++ b/db/migrate.ts
@@ -1,6 +1,6 @@
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
-import { runMigrations, pool } from '@huddle/db/client.js';
+import { runMigrations } from '@huddle/db/client.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -8,7 +8,6 @@ async function main() {
const migrationsDir = join(__dirname, 'migrations');
const applied = await runMigrations(migrationsDir);
console.log(`Applied migrations: ${applied.join(', ')}`);
- await pool.end();
}
main().catch((err) => {
diff --git a/db/migrations/008_import_receipt_session.sql b/db/migrations/008_import_receipt_session.sql
new file mode 100644
index 0000000..67c6c35
--- /dev/null
+++ b/db/migrations/008_import_receipt_session.sql
@@ -0,0 +1,169 @@
+-- Import receipts and attempt/session lineage. Legacy activity is intentionally retained
+-- under a non-displayable name; target activity is recreated only through the importer.
+ALTER TABLE guide_auth_scope
+ ADD COLUMN id bigint GENERATED BY DEFAULT AS IDENTITY;
+-- The importer resolves only this fixed synthetic roster token, never a name supplied by CSV.
+ALTER TABLE student
+ ADD COLUMN pseudonymous_ref text;
+CREATE UNIQUE INDEX student_synthetic_ref ON student (guide_id, pseudonymous_ref)
+ WHERE pseudonymous_ref IS NOT NULL;
+ALTER TABLE guide_auth_scope
+ ADD CONSTRAINT guide_auth_scope_id_unique UNIQUE (id),
+ ADD CONSTRAINT guide_auth_scope_scope_unique UNIQUE (id, guide_id, studio_id),
+ ADD CONSTRAINT guide_auth_scope_auth_unique UNIQUE (id, auth_user_id);
+
+ALTER TABLE attempt RENAME TO legacy_attempt;
+ALTER TABLE legacy_attempt RENAME CONSTRAINT attempt_pkey TO legacy_attempt_pkey;
+ALTER TABLE legacy_attempt RENAME CONSTRAINT attempt_ordering TO legacy_attempt_ordering;
+ALTER TABLE legacy_attempt RENAME CONSTRAINT attempt_index_positive TO legacy_attempt_index_positive;
+ALTER TABLE legacy_attempt RENAME CONSTRAINT engaged_within_elapsed TO legacy_attempt_engaged_within_elapsed;
+ALTER TABLE legacy_attempt RENAME CONSTRAINT session_total_for_session_only TO legacy_attempt_session_total_for_session_only;
+ALTER SEQUENCE attempt_id_seq RENAME TO legacy_attempt_id_seq;
+ALTER INDEX attempt_source_dedupe RENAME TO legacy_attempt_source_dedupe;
+ALTER INDEX attempt_student_skill_time RENAME TO legacy_attempt_student_skill_time;
+
+CREATE TYPE import_status AS ENUM (
+ 'validating','validated','committing','succeeded','succeeded_with_rejections','failed'
+);
+CREATE TYPE ingest_origin AS ENUM ('guide_csv','fixture','simulator');
+CREATE TYPE ingest_failure_kind AS ENUM ('unmapped_skill', 'malformed', 'unsupported');
+
+CREATE TABLE import_run (
+ id bigserial PRIMARY KEY,
+ application_scope_id bigint NOT NULL,
+ guide_id uuid NOT NULL REFERENCES guide(id),
+ studio_id text NOT NULL,
+ requested_by_auth_user_id uuid,
+ requested_by_worker_id text,
+ ingest_origin ingest_origin NOT NULL,
+ adapter_id text NOT NULL,
+ adapter_version text NOT NULL,
+ source text NOT NULL,
+ original_file_name text NOT NULL CHECK (length(original_file_name) <= 255),
+ media_type text NOT NULL CHECK (media_type = 'text/csv'),
+ idempotency_key text NOT NULL CHECK (length(idempotency_key) BETWEEN 1 AND 256),
+ payload_digest text NOT NULL CHECK (payload_digest ~ '^[0-9a-f]{64}$'),
+ is_synthetic boolean NOT NULL CHECK (is_synthetic IS TRUE),
+ status import_status NOT NULL,
+ received_at timestamptz NOT NULL,
+ validated_at timestamptz,
+ committed_at timestamptz,
+ received_count int NOT NULL DEFAULT 0,
+ accepted_count int NOT NULL DEFAULT 0,
+ duplicate_count int NOT NULL DEFAULT 0,
+ unmapped_count int NOT NULL DEFAULT 0,
+ rejected_count int NOT NULL DEFAULT 0,
+ failure_code text,
+ UNIQUE (guide_id, adapter_id, idempotency_key),
+ UNIQUE (id, application_scope_id),
+ FOREIGN KEY (application_scope_id, guide_id, studio_id)
+ REFERENCES guide_auth_scope(id, guide_id, studio_id),
+ FOREIGN KEY (application_scope_id, requested_by_auth_user_id)
+ REFERENCES guide_auth_scope(id, auth_user_id),
+ CHECK (received_count >= 0 AND accepted_count >= 0 AND duplicate_count >= 0
+ AND unmapped_count >= 0 AND rejected_count >= 0),
+ CHECK (
+ (status = 'validating' AND validated_at IS NULL AND committed_at IS NULL)
+ OR (status = 'validated' AND validated_at IS NOT NULL AND committed_at IS NULL)
+ OR (status = 'committing' AND validated_at IS NOT NULL AND committed_at IS NULL)
+ OR (status IN ('succeeded','succeeded_with_rejections')
+ AND validated_at IS NOT NULL AND committed_at IS NOT NULL)
+ OR status = 'failed'
+ ),
+ CHECK (
+ (ingest_origin = 'guide_csv' AND requested_by_auth_user_id IS NOT NULL
+ AND requested_by_worker_id IS NULL AND adapter_id = 'synthetic-csv-v1')
+ OR (ingest_origin IN ('fixture','simulator') AND requested_by_auth_user_id IS NULL
+ AND requested_by_worker_id IS NOT NULL)
+ )
+);
+CREATE TABLE import_issue (
+ import_run_id bigint NOT NULL REFERENCES import_run(id),
+ row_number int NOT NULL CHECK (row_number >= 0),
+ source_event_id text CHECK (source_event_id IS NULL OR source_event_id ~ '^[A-Za-z0-9:_-]{1,128}$'),
+ outcome text NOT NULL CHECK (outcome IN ('duplicate','unmapped','rejected')),
+ code text NOT NULL CHECK (length(code) <= 80),
+ field text CHECK (field IS NULL OR length(field) <= 80),
+ safe_detail text NOT NULL CHECK (length(safe_detail) <= 240),
+ PRIMARY KEY (import_run_id, row_number, code)
+);
+
+CREATE TABLE learning_session (
+ id bigserial PRIMARY KEY,
+ import_run_id bigint NOT NULL,
+ application_scope_id bigint NOT NULL,
+ student_id uuid NOT NULL REFERENCES student(id),
+ source text NOT NULL,
+ source_session_id text NOT NULL CHECK (source_session_id ~ '^[A-Za-z0-9:_-]{1,128}$'),
+ started_at timestamptz NOT NULL,
+ ended_at timestamptz NOT NULL,
+ total_elapsed_ms int,
+ vendor_attempt_count int CHECK (vendor_attempt_count > 0),
+ timing_quality timing_quality NOT NULL,
+ ingested_at timestamptz NOT NULL,
+ UNIQUE (application_scope_id, student_id, source, source_session_id),
+ UNIQUE (id, application_scope_id, student_id),
+ FOREIGN KEY (import_run_id, application_scope_id)
+ REFERENCES import_run(id, application_scope_id),
+ CHECK (ended_at >= started_at),
+ CHECK (total_elapsed_ms IS NULL OR total_elapsed_ms >= 0),
+ CHECK (timing_quality IN ('session_only', 'none')),
+ CHECK (
+ (timing_quality = 'session_only' AND total_elapsed_ms IS NOT NULL AND vendor_attempt_count IS NOT NULL)
+ OR (timing_quality = 'none' AND total_elapsed_ms IS NULL AND vendor_attempt_count IS NULL)
+ )
+);
+
+CREATE TABLE attempt (
+ id bigserial PRIMARY KEY,
+ import_run_id bigint NOT NULL,
+ application_scope_id bigint NOT NULL,
+ student_id uuid NOT NULL REFERENCES student(id),
+ skill_id text NOT NULL REFERENCES skill(id),
+ item_id text NOT NULL REFERENCES item(id),
+ learning_session_id bigint NOT NULL,
+ attempt_index int NOT NULL CHECK (attempt_index >= 1),
+ started_at timestamptz NOT NULL,
+ submitted_at timestamptz NOT NULL,
+ elapsed_ms int,
+ engaged_ms int,
+ timing_quality timing_quality NOT NULL,
+ timing_was_winsorized boolean NOT NULL,
+ is_correct boolean NOT NULL,
+ answer_given jsonb,
+ hints_used int NOT NULL DEFAULT 0 CHECK (hints_used >= 0),
+ source text NOT NULL,
+ source_event_id text NOT NULL CHECK (source_event_id ~ '^[A-Za-z0-9:_-]{1,128}$'),
+ ingested_at timestamptz NOT NULL,
+ UNIQUE (application_scope_id, student_id, source, source_event_id),
+ FOREIGN KEY (import_run_id, application_scope_id)
+ REFERENCES import_run(id, application_scope_id),
+ FOREIGN KEY (learning_session_id, application_scope_id, student_id)
+ REFERENCES learning_session(id, application_scope_id, student_id),
+ CHECK (submitted_at >= started_at),
+ CHECK (elapsed_ms IS NULL OR elapsed_ms >= 0),
+ CHECK (engaged_ms IS NULL OR engaged_ms >= 0),
+ CHECK (engaged_ms IS NULL OR elapsed_ms IS NULL OR engaged_ms <= elapsed_ms),
+ CHECK (
+ (timing_quality = 'engaged' AND engaged_ms IS NOT NULL AND elapsed_ms IS NOT NULL)
+ OR (timing_quality = 'wallclock' AND elapsed_ms IS NOT NULL AND engaged_ms IS NULL)
+ OR (timing_quality IN ('session_only','none') AND elapsed_ms IS NULL AND engaged_ms IS NULL)
+ )
+);
+CREATE INDEX attempt_student_skill_time ON attempt (student_id, skill_id, submitted_at DESC);
+
+-- Only allow-listed normalized fields are retained for unmapped synthetic rows; never raw CSV.
+CREATE TABLE unmapped_activity (
+ id bigserial PRIMARY KEY,
+ import_run_id bigint NOT NULL,
+ application_scope_id bigint NOT NULL,
+ source text NOT NULL,
+ source_event_id text NOT NULL CHECK (source_event_id ~ '^[A-Za-z0-9:_-]{1,128}$'),
+ vendor_skill text NOT NULL CHECK (vendor_skill ~ '^[A-Za-z0-9._:-]{1,128}$'),
+ pseudonymous_student_ref text NOT NULL CHECK (pseudonymous_student_ref ~ '^[A-Za-z0-9:_-]{1,128}$'),
+ received_at timestamptz NOT NULL,
+ failure_kind ingest_failure_kind NOT NULL,
+ UNIQUE (application_scope_id, pseudonymous_student_ref, source, source_event_id),
+ FOREIGN KEY (import_run_id, application_scope_id)
+ REFERENCES import_run(id, application_scope_id)
+);
diff --git a/db/migrations/009_board_run_head.sql b/db/migrations/009_board_run_head.sql
new file mode 100644
index 0000000..5fac7c9
--- /dev/null
+++ b/db/migrations/009_board_run_head.sql
@@ -0,0 +1,240 @@
+-- Immutable refresh/run publication. No candidate becomes visible before its complete
+-- transaction promotes one board_head row.
+ALTER TABLE mastery_snapshot RENAME TO legacy_mastery_snapshot;
+ALTER TABLE signal RENAME TO legacy_signal;
+ALTER TABLE triage_entry RENAME TO legacy_triage_entry;
+ALTER SEQUENCE signal_id_seq RENAME TO legacy_signal_id_seq;
+ALTER SEQUENCE triage_entry_id_seq RENAME TO legacy_triage_entry_id_seq;
+
+CREATE TYPE refresh_state AS ENUM ('queued','running','succeeded','failed');
+CREATE TYPE refresh_trigger AS ENUM ('nightly','manual');
+CREATE TYPE refresh_failure_code AS ENUM (
+ 'input-unavailable','compile-failed','persistence-failed','deadline-missed','worker-timeout'
+);
+CREATE TYPE scope_kind AS ENUM ('skill', 'cross_skill');
+CREATE TYPE narration_mode AS ENUM ('generated','deterministic_fallback');
+CREATE TYPE narration_status AS ENUM ('complete','degraded');
+CREATE TYPE narration_degraded_reason AS ENUM (
+ 'pending','model-unavailable','timeout','provider-error','selection-invalid',
+ 'grounding-rejected','stale-result'
+);
+
+CREATE TABLE board_refresh_request (
+ id bigserial PRIMARY KEY,
+ application_scope_id bigint NOT NULL,
+ guide_id uuid NOT NULL REFERENCES guide(id),
+ studio_id text NOT NULL,
+ board_date date NOT NULL,
+ trigger refresh_trigger NOT NULL,
+ requested_by_auth_user_id uuid,
+ requested_by_worker_id text,
+ claimed_by_worker_id text,
+ state refresh_state NOT NULL,
+ requested_at timestamptz NOT NULL,
+ started_at timestamptz,
+ heartbeat_at timestamptz,
+ lease_expires_at timestamptz,
+ completed_at timestamptz,
+ resulting_board_run_id bigint,
+ preserved_board_run_id bigint,
+ failure_code refresh_failure_code,
+ UNIQUE (id, application_scope_id, guide_id, studio_id, board_date, trigger),
+ FOREIGN KEY (application_scope_id, guide_id, studio_id)
+ REFERENCES guide_auth_scope(id, guide_id, studio_id),
+ FOREIGN KEY (application_scope_id, requested_by_auth_user_id)
+ REFERENCES guide_auth_scope(id, auth_user_id),
+ CHECK (
+ (trigger = 'manual' AND requested_by_auth_user_id IS NOT NULL AND requested_by_worker_id IS NULL)
+ OR (trigger = 'nightly' AND requested_by_auth_user_id IS NULL AND requested_by_worker_id IS NOT NULL)
+ ),
+ CHECK (
+ (state = 'queued' AND claimed_by_worker_id IS NULL AND started_at IS NULL
+ AND completed_at IS NULL AND resulting_board_run_id IS NULL AND failure_code IS NULL)
+ OR (state = 'running' AND started_at IS NOT NULL AND completed_at IS NULL AND resulting_board_run_id IS NULL
+ AND claimed_by_worker_id IS NOT NULL AND heartbeat_at IS NOT NULL
+ AND lease_expires_at IS NOT NULL AND failure_code IS NULL)
+ OR (state = 'succeeded' AND started_at IS NOT NULL AND completed_at IS NOT NULL AND resulting_board_run_id IS NOT NULL AND failure_code IS NULL)
+ OR (state = 'failed' AND started_at IS NOT NULL AND completed_at IS NOT NULL AND resulting_board_run_id IS NULL AND failure_code IS NOT NULL)
+ ),
+ CHECK (state = 'failed' OR preserved_board_run_id IS NULL)
+);
+CREATE UNIQUE INDEX one_active_refresh ON board_refresh_request (application_scope_id, board_date)
+ WHERE state IN ('queued','running');
+CREATE UNIQUE INDEX one_nightly_refresh ON board_refresh_request (application_scope_id, board_date, trigger)
+ WHERE trigger = 'nightly';
+CREATE INDEX refresh_reaper_lease ON board_refresh_request (lease_expires_at) WHERE state = 'running';
+
+CREATE TABLE board_run (
+ id bigserial PRIMARY KEY,
+ refresh_request_id bigint NOT NULL UNIQUE,
+ application_scope_id bigint NOT NULL,
+ guide_id uuid NOT NULL REFERENCES guide(id),
+ studio_id text NOT NULL,
+ board_date date NOT NULL,
+ trigger refresh_trigger NOT NULL,
+ timezone text NOT NULL CHECK (timezone = 'America/Chicago'),
+ as_of timestamptz NOT NULL,
+ window_start timestamptz NOT NULL,
+ window_end timestamptz NOT NULL,
+ input_receipt_set_fingerprint text NOT NULL CHECK (input_receipt_set_fingerprint ~ '^[0-9a-f]{64}$'),
+ behavior_fingerprint text NOT NULL CHECK (behavior_fingerprint ~ '^[0-9a-f]{64}$'),
+ completed_at timestamptz NOT NULL,
+ supersedes_board_run_id bigint,
+ CHECK (window_end = as_of),
+ UNIQUE (id, application_scope_id),
+ UNIQUE (id, application_scope_id, guide_id, studio_id),
+ UNIQUE (id, application_scope_id, guide_id, studio_id, board_date),
+ FOREIGN KEY (application_scope_id, guide_id, studio_id)
+ REFERENCES guide_auth_scope(id, guide_id, studio_id),
+ FOREIGN KEY (refresh_request_id, application_scope_id, guide_id, studio_id, board_date, trigger)
+ REFERENCES board_refresh_request(id, application_scope_id, guide_id, studio_id, board_date, trigger),
+ FOREIGN KEY (supersedes_board_run_id, application_scope_id, guide_id, studio_id)
+ REFERENCES board_run(id, application_scope_id, guide_id, studio_id)
+);
+ALTER TABLE board_refresh_request
+ ADD FOREIGN KEY (resulting_board_run_id, application_scope_id, guide_id, studio_id, board_date)
+ REFERENCES board_run(id, application_scope_id, guide_id, studio_id, board_date),
+ ADD FOREIGN KEY (preserved_board_run_id, application_scope_id, guide_id, studio_id)
+ REFERENCES board_run(id, application_scope_id, guide_id, studio_id);
+
+CREATE TABLE board_run_import (
+ board_run_id bigint NOT NULL,
+ application_scope_id bigint NOT NULL,
+ import_run_id bigint NOT NULL,
+ PRIMARY KEY (board_run_id, import_run_id),
+ FOREIGN KEY (board_run_id, application_scope_id) REFERENCES board_run(id, application_scope_id),
+ FOREIGN KEY (import_run_id, application_scope_id) REFERENCES import_run(id, application_scope_id)
+);
+CREATE TABLE board_head (
+ application_scope_id bigint NOT NULL,
+ guide_id uuid NOT NULL REFERENCES guide(id),
+ studio_id text NOT NULL,
+ board_date date NOT NULL,
+ current_board_run_id bigint NOT NULL,
+ updated_at timestamptz NOT NULL,
+ PRIMARY KEY (application_scope_id, board_date),
+ FOREIGN KEY (application_scope_id, guide_id, studio_id)
+ REFERENCES guide_auth_scope(id, guide_id, studio_id),
+ FOREIGN KEY (current_board_run_id, application_scope_id, guide_id, studio_id, board_date)
+ REFERENCES board_run(id, application_scope_id, guide_id, studio_id, board_date)
+);
+
+CREATE TABLE mastery_snapshot (
+ board_run_id bigint NOT NULL REFERENCES board_run(id),
+ as_of timestamptz NOT NULL,
+ student_id uuid NOT NULL REFERENCES student(id),
+ skill_id text NOT NULL REFERENCES skill(id),
+ value real,
+ attempt_count bigint NOT NULL,
+ is_known boolean NOT NULL,
+ PRIMARY KEY (board_run_id, student_id, skill_id)
+);
+CREATE TABLE signal (
+ id bigserial PRIMARY KEY,
+ board_run_id bigint NOT NULL REFERENCES board_run(id),
+ student_id uuid NOT NULL REFERENCES student(id),
+ scope_kind scope_kind NOT NULL,
+ skill_id text REFERENCES skill(id),
+ kind root_cause NOT NULL,
+ rule_id text NOT NULL,
+ rule_version text NOT NULL,
+ behavior_fingerprint text NOT NULL CHECK (behavior_fingerprint ~ '^[0-9a-f]{64}$'),
+ intensity real NOT NULL CHECK (intensity BETWEEN 0 AND 1),
+ severity real NOT NULL CHECK (severity BETWEEN 0 AND 1),
+ raw_confidence real NOT NULL CHECK (raw_confidence BETWEEN 0 AND 1),
+ final_confidence real NOT NULL CHECK (final_confidence BETWEEN 0 AND 1),
+ confidence_breakdown jsonb NOT NULL,
+ evidence jsonb NOT NULL,
+ evidence_fingerprint text NOT NULL CHECK (evidence_fingerprint ~ '^[0-9a-f]{64}$'),
+ fallback_catalog_version text NOT NULL,
+ fallback_render_version text NOT NULL,
+ fallback_language_fingerprint text NOT NULL CHECK (fallback_language_fingerprint ~ '^[0-9a-f]{64}$'),
+ fallback_narration_fingerprint text NOT NULL CHECK (fallback_narration_fingerprint ~ '^[0-9a-f]{64}$'),
+ fallback_narration_selection jsonb NOT NULL,
+ fallback_diagnosis text NOT NULL CHECK (length(btrim(fallback_diagnosis)) > 0),
+ fallback_opener text NOT NULL CHECK (length(btrim(fallback_opener)) > 0),
+ window_start timestamptz NOT NULL,
+ window_end timestamptz NOT NULL,
+ computed_at timestamptz NOT NULL,
+ CHECK ((scope_kind = 'skill') = (skill_id IS NOT NULL)),
+ UNIQUE (id, board_run_id)
+);
+CREATE UNIQUE INDEX signal_behavior_identity ON signal (
+ board_run_id, student_id, scope_kind, coalesce(skill_id, ''), rule_id,
+ window_start, window_end, behavior_fingerprint
+);
+CREATE TABLE triage_entry (
+ id bigserial PRIMARY KEY,
+ board_run_id bigint NOT NULL REFERENCES board_run(id),
+ student_id uuid NOT NULL REFERENCES student(id),
+ dominant_signal_id bigint NOT NULL,
+ finding_fingerprint text NOT NULL CHECK (finding_fingerprint ~ '^[0-9a-f]{64}$'),
+ rank int NOT NULL CHECK (rank >= 1),
+ additional_causes jsonb NOT NULL DEFAULT '[]',
+ catalog_version text NOT NULL,
+ language_fingerprint text NOT NULL CHECK (language_fingerprint ~ '^[0-9a-f]{64}$'),
+ expected_narration_fingerprint text NOT NULL CHECK (expected_narration_fingerprint ~ '^[0-9a-f]{64}$'),
+ narration_fingerprint text,
+ narration_selection jsonb NOT NULL,
+ narration_mode narration_mode NOT NULL,
+ narration_status narration_status NOT NULL,
+ narration_degraded_reason narration_degraded_reason,
+ render_version text NOT NULL,
+ diagnosis text NOT NULL,
+ opener text NOT NULL,
+ language_rendered_at timestamptz NOT NULL,
+ narration_attempted_at timestamptz,
+ narration_completed_at timestamptz,
+ UNIQUE (board_run_id, student_id),
+ UNIQUE (board_run_id, rank),
+ UNIQUE (id, board_run_id),
+ UNIQUE (id, board_run_id, finding_fingerprint),
+ FOREIGN KEY (dominant_signal_id, board_run_id) REFERENCES signal(id, board_run_id),
+ CHECK (
+ (narration_mode = 'deterministic_fallback' AND narration_status = 'degraded'
+ AND narration_degraded_reason IS NOT NULL AND narration_fingerprint IS NULL)
+ OR (narration_mode = 'generated' AND narration_status = 'complete'
+ AND narration_degraded_reason IS NULL AND narration_fingerprint IS NOT NULL
+ AND narration_completed_at IS NOT NULL)
+ ),
+ CHECK (narration_fingerprint IS NULL OR narration_fingerprint = expected_narration_fingerprint),
+ CHECK (length(btrim(diagnosis)) > 0 AND length(btrim(opener)) > 0)
+);
+
+-- Runtime handles only scoped data operations; the migration credential remains separate.
+DO $$
+BEGIN
+ IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'huddle_app') THEN
+ CREATE ROLE huddle_app NOINHERIT LOGIN;
+ END IF;
+END $$;
+ALTER ROLE huddle_app NOINHERIT LOGIN;
+DO $$
+BEGIN
+ IF EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = 'auth') THEN
+ EXECUTE 'REVOKE ALL ON SCHEMA auth FROM huddle_app';
+ EXECUTE 'REVOKE ALL ON ALL TABLES IN SCHEMA auth FROM huddle_app';
+ END IF;
+END $$;
+GRANT USAGE ON SCHEMA public TO huddle_app;
+GRANT SELECT ON guide, student, skill, skill_prereq, item, absence, guide_auth_scope, import_run,
+ import_issue, learning_session, attempt, unmapped_activity, board_refresh_request, board_run,
+ board_run_import, board_head, mastery_snapshot, signal, triage_entry TO huddle_app;
+REVOKE INSERT, UPDATE, DELETE ON import_issue, learning_session, attempt, unmapped_activity,
+ board_run, board_run_import, mastery_snapshot, signal, triage_entry FROM huddle_app;
+GRANT INSERT ON import_run, board_refresh_request, board_head TO huddle_app;
+GRANT UPDATE (status, committed_at, accepted_count, duplicate_count, unmapped_count, rejected_count)
+ ON import_run TO huddle_app;
+GRANT UPDATE (state, claimed_by_worker_id, started_at, lease_expires_at, heartbeat_at, completed_at,
+ failure_code, resulting_board_run_id, preserved_board_run_id)
+ ON board_refresh_request TO huddle_app;
+GRANT UPDATE (current_board_run_id, updated_at) ON board_head TO huddle_app;
+GRANT INSERT ON import_issue, learning_session, attempt, unmapped_activity, board_run,
+ board_run_import, mastery_snapshot, signal, triage_entry TO huddle_app;
+GRANT USAGE ON SEQUENCE import_run_id_seq, learning_session_id_seq, attempt_id_seq,
+ unmapped_activity_id_seq, board_refresh_request_id_seq, board_run_id_seq, signal_id_seq,
+ triage_entry_id_seq TO huddle_app;
+GRANT EXECUTE ON FUNCTION mastery_at(timestamptz) TO huddle_app;
+REVOKE CREATE ON SCHEMA public FROM huddle_app;
+ALTER DEFAULT PRIVILEGES IN SCHEMA public REVOKE ALL ON TABLES FROM huddle_app;
+ALTER DEFAULT PRIVILEGES IN SCHEMA public REVOKE ALL ON FUNCTIONS FROM huddle_app;
diff --git a/docs/quick-demo-foundation.md b/docs/quick-demo-foundation.md
index c06cc9a..cd30639 100644
--- a/docs/quick-demo-foundation.md
+++ b/docs/quick-demo-foundation.md
@@ -1,19 +1,25 @@
-# Huddle synthetic quick-demo foundation
+# Huddle synthetic quick-demo operations
-This document describes the **access and data foundation only**. It does not claim that a Supabase
-project has been deployed, that a demo roster has been seeded, or that ingestion/refresh operations
-exist.
+This document describes the shipped **access, data, and bounded Operations foundation**. It does not
+claim that a Supabase project has been deployed, that a demo roster has been seeded, or that the full
+Evidence Desk, automatic acknowledgment, or model-backed narration workflow is complete.
## Authoritative contract owners
| Contract | Owner |
|---|---|
-| Forward-only Supabase Postgres schema | `db/migrations/` (quick-demo access: `006_quick_demo_access.sql`) |
-| All SQL and guide/studio-scoped board reads | `packages/db/src/scoped.ts` |
+| Forward-only Supabase Postgres schema and runtime grants | `db/migrations/` (`006_quick_demo_access.sql`, `008_import_receipt_session.sql`, and `009_board_run_head.sql`) |
+| Guide/studio-scoped board reads and immutable publication | `packages/db/src/scoped.ts` and `packages/db/src/boards.ts` |
| Server Supabase Auth verification | `apps/web/lib/supabase/server.ts` |
| Browser public Auth client | `apps/web/lib/supabase/browser.ts` |
| Request order: verified user → authorized scope → board | `apps/web/app/board/lib/triage.ts` |
+| Fixed synthetic CSV validation | `packages/ingest/src/synthetic-csv-v1.ts` |
+| Validate/commit import orchestration and transaction | `packages/application/src/operations-importer.ts` and `packages/db/src/imports.ts` |
+| Manual/nightly refresh, leases, and reaping | `packages/application/src/operations-refresh.ts` and `packages/db/src/refresh.ts` |
+| Protected worker dispatch and server composition | `apps/web/app/internal/refresh/route.ts` and `apps/web/lib/operations.ts` |
+| Redacted operational event shape | `apps/web/lib/operational-events.ts` |
| Deterministic detection and ranking | `packages/signal-engine/` and `scripts/check-determinism.ts` |
+| Deterministic cause-specific fallback language | `packages/narrator/src/catalog.ts` |
The request path has no guide-id parameter. It obtains a verified Supabase Auth UUID on the server,
looks up exactly one synthetic `guide_auth_scope` row, then calls the existing guide-scoped board
@@ -22,19 +28,25 @@ mapping, or no database configuration returns no board rows and no freshness/ros
## Configuration
-Copy `.env.example` to `.env` and provide only these values:
+Copy `.env.example` to `.env` and provide these values:
- `NEXT_PUBLIC_SUPABASE_URL` and `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY`: Supabase's public Auth
configuration. They may be delivered to the browser and are used only by the login form.
-- `DATABASE_URL`: the Supabase Postgres connection string from the project's **Connect** panel. This
- is server-only: never rename it with a `NEXT_PUBLIC_` prefix and never add it to browser code.
+- `DATABASE_ADMIN_URL`: an administrative Postgres connection used only by `npm run db:migrate`.
+- `DATABASE_URL`: the server-only connection for the migration-created, least-privilege
+ `huddle_app` runtime role. Set that role's password out of band after migration and use it here;
+ never expose either database URL through a `NEXT_PUBLIC_` variable or browser code.
+- `INTERNAL_REFRESH_SECRET`: the current secret used to HMAC-sign protected refresh dispatch.
+ `INTERNAL_REFRESH_SECRET_PREVIOUS` is optional during secret rotation, and
+ `INTERNAL_REFRESH_URL` defaults to the local internal route.
- `ANTHROPIC_API_KEY`: optional, server-only, and unrelated to board selection/ranking.
-Do **not** configure a `SUPABASE_SERVICE_ROLE_KEY` for this read-only demo foundation. Database
-access uses the server's `DATABASE_URL` through `@huddle/db`; browser code receives neither a
-service-role credential nor an unrestricted Supabase data client. Migration 006 also revokes the
-`anon` and `authenticated` roles' public-schema/Data API privileges, including default privileges,
-so the public Auth key cannot read application tables directly.
+Do **not** configure a `SUPABASE_SERVICE_ROLE_KEY` for this demo. Database access uses the restricted
+`huddle_app` role through `@huddle/db`; migrations use the separate administrative credential.
+Browser code receives neither credential, a service-role credential, nor an unrestricted Supabase
+data client. Migration 006 revokes the `anon` and `authenticated` roles' public-schema/Data API
+privileges, including default privileges, so the public Auth key cannot read application tables
+directly.
## Provisioning a synthetic demo guide
@@ -55,17 +67,44 @@ The UUIDs are deployment data and must not be committed. `guide.is_synthetic`,
existing data is denied by default and a non-synthetic roster is not eligible for this quick-demo
surface.
+## Bounded Operations path
+
+The authenticated `/import` workflow accepts only the bundled `synthetic-csv-v1` shape: valid UTF-8
+CSV, at most 5 MiB and 10,000 data rows, an exact fixed header, and
+`dataset_kind=synthetic`. Validation stores only a digest and bounded preview metadata. Commit
+requires the file again, re-hashes and re-parses it, then transactionally writes authoritative
+accepted, duplicate, unmapped, and rejected counts plus attempt/session receipt lineage. A reused
+idempotency key is valid only for the same payload digest.
+
+Import does not publish a board. The separate manual refresh action and nightly worker use the same
+compiler. A successful compile writes an immutable run, its exact committed import-receipt set,
+mastery, ranked engine signals, evidence, and non-empty cause-specific deterministic fallbacks before
+atomically promoting `board_head`. A failed compile records a bounded failure code and leaves the
+prior head visible. Refresh claims have a one-minute lease with heartbeat; nightly dispatch reaps
+expired claims before enqueueing eligible synthetic scopes.
+
+`npm run nightly` sends only a signed nightly request to the protected internal route. The route
+accepts the current or previous secret with a timestamp no more than five minutes old, derives scope
+from database-owned synthetic mappings, and applies the Chicago-local board date, seven-day window,
+and 07:45 deadline. Test-only historical overrides require a matching board date and fixture time and
+are rejected in production.
+
+Import and refresh emit only the bounded shape owned by `apps/web/lib/operational-events.ts`. Callers
+must not put rows, names, answers, credentials, or other student data into these events.
+
## Safety boundary and next gate
RLS is intentionally **not** enabled for this single, application-server-mediated synthetic demo.
-The migration instead denies Supabase Data API access to `anon` and `authenticated`; the app server's
-direct Postgres role is the only data path. Before introducing real student data or a multi-guide
-pilot, add and validate Supabase RLS policies, replace the one-guide-one-studio mapping with the
-required authorization model, and re-review every data path. Do not treat the present server-side
-predicate and revoked Data API grants as sufficient for that safety gate.
+The migrations instead deny Supabase Data API access to `anon` and `authenticated`; the restricted
+application-server role is the only runtime data path. Before introducing real student data or a
+multi-guide pilot, add and validate Supabase RLS policies, replace the one-guide-one-studio mapping
+with the required authorization model, and re-review every data path. Do not treat the present
+server-side predicate, restricted runtime grants, and revoked Data API grants as sufficient for that
+safety gate.
-No ingestion, refresh, notifications, or guide-workflow UI is included here. The existing signal
-engine stays pure and deterministic; auth and Postgres integration do not reach it.
+This bounded slice sends no notifications. The signal engine stays pure and deterministic; browser
+data is untrusted and neither Auth nor Postgres integration reaches the engine's model-free ranking
+path. All imported student activity remains synthetic.
## Verification
@@ -75,5 +114,10 @@ npm test
npm run check:determinism
```
-Focused coverage is in `apps/web/test/quick-demo-access.test.ts` and
-`packages/db/test/quick-demo-foundation.test.ts`.
+Focused coverage is in `apps/web/test/quick-demo-access.test.ts`,
+`apps/web/test/import-actions-fail-closed.test.ts`,
+`packages/application/test/operations-importer.test.ts`,
+`packages/application/test/operations-refresh.test.ts`,
+`packages/db/test/operations-boundaries.test.ts`,
+`packages/db/test/operations-migrations.test.ts`, `packages/ingest/test/synthetic-csv-v1.test.ts`,
+`packages/narrator/test/fallback-catalog.test.ts`, and `scripts/nightly.test.ts`.
diff --git a/package-lock.json b/package-lock.json
index 51f0146..5a246ac 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -32,6 +32,7 @@
"@huddle/application": "^0.1.0",
"@huddle/core": "^0.1.0",
"@huddle/db": "^0.1.0",
+ "@huddle/narrator": "^0.1.0",
"@huddle/signal-engine": "^0.1.0",
"@supabase/ssr": "^0.5.2",
"@supabase/supabase-js": "^2.111.0",
diff --git a/packages/application/package.json b/packages/application/package.json
index e8c5994..44401fd 100644
--- a/packages/application/package.json
+++ b/packages/application/package.json
@@ -15,7 +15,8 @@
"test": "vitest run"
},
"dependencies": {
- "@huddle/core": "^0.1.0"
+ "@huddle/core": "^0.1.0",
+ "@huddle/ingest": "^0.1.0"
},
"devDependencies": {
"@types/node": "^20.14.0",
diff --git a/packages/application/src/index.ts b/packages/application/src/index.ts
index 479d9cc..ef8d1cb 100644
--- a/packages/application/src/index.ts
+++ b/packages/application/src/index.ts
@@ -6,3 +6,22 @@ export { createEvidenceReader } from './evidence-reader-implementation.js';
export type * from './board-compiler.js';
export type * from './importer.js';
export { SYNTHETIC_IMPORT_CONFLICT_POLICY } from './importer.js';
+export { createImporter } from './operations-importer.js';
+export type { ImportPersistencePort, ImportValidationRecord } from './operations-importer.js';
+export {
+ beforeNightlyDeadline,
+ chicagoBoardDate,
+ chicagoBoardWindow,
+ createBoardCompiler,
+ createBoardReader,
+ isNightlyDispatchEligible,
+ nightlyDispatchPayload,
+ validateNightlyDispatchOverrides,
+} from './operations-refresh.js';
+export type {
+ BoardReadPort,
+ ClaimedRefresh,
+ NightlyDispatchOverrides,
+ PublicationResult,
+ RefreshPort,
+} from './operations-refresh.js';
diff --git a/packages/application/src/operations-importer.ts b/packages/application/src/operations-importer.ts
new file mode 100644
index 0000000..35476f5
--- /dev/null
+++ b/packages/application/src/operations-importer.ts
@@ -0,0 +1,121 @@
+import {
+ analyzeSyntheticCsv,
+ type SyntheticCsvAnalysis,
+ type SyntheticCsvReferences,
+} from '@huddle/ingest';
+import type { GuideAccess } from './access.js';
+import type { ImportPreview, ImportReceipt, Importer, SyntheticImportFile } from './importer.js';
+
+export interface ImportValidationRecord {
+ importId: string;
+ access: Pick;
+ payloadDigest: string;
+ adapterVersion: string;
+ fileName: string;
+ idempotencyKey: string;
+ analysis: SyntheticCsvAnalysis;
+}
+
+/** Narrow persistence capability: the DB implementation owns receipt state and transactions. */
+export interface ImportPersistencePort {
+ saveValidation(record: Omit): Promise;
+ getValidation(
+ access: ImportValidationRecord['access'],
+ importId: string
+ ): Promise | null>;
+ commit(
+ record: Omit,
+ analysis: SyntheticCsvAnalysis
+ ): Promise<{
+ importId: string;
+ committedAt: string;
+ status: ImportReceipt['status'];
+ counts: ImportReceipt['counts'];
+ issues: ImportReceipt['issues'];
+ }>;
+}
+
+export function createImporter(
+ references: SyntheticCsvReferences,
+ persistence: ImportPersistencePort
+): Importer {
+ return {
+ async validate(access, file) {
+ const analysis = analyze(file, references);
+ if (!analysis.committable) throw new Error('The CSV has a file-level validation error.');
+ const saved = await persistence.saveValidation({
+ access: scope(access),
+ payloadDigest: analysis.payloadDigest,
+ adapterVersion: analysis.adapterVersion,
+ fileName: file.name,
+ idempotencyKey: file.idempotencyKey,
+ analysis,
+ });
+ return preview(saved.importId, file.name, analysis);
+ },
+ async commit(access, validatedImportId, file) {
+ const stored = await persistence.getValidation(scope(access), validatedImportId);
+ if (!stored || !sameScope(stored.access, access))
+ throw new Error('Validated import is unavailable.');
+ if (stored.idempotencyKey !== file.idempotencyKey)
+ throw new Error('Import idempotency key does not match validation.');
+ const analysis = analyze(file, references);
+ if (!analysis.committable) throw new Error('The CSV has a file-level validation error.');
+ if (
+ analysis.payloadDigest !== stored.payloadDigest ||
+ analysis.adapterVersion !== stored.adapterVersion
+ ) {
+ throw new Error('Import bytes or adapter version changed after validation.');
+ }
+ const committed = await persistence.commit(stored, analysis);
+ return {
+ importId: committed.importId,
+ fileName: stored.fileName,
+ payloadDigest: stored.payloadDigest,
+ status: committed.status,
+ committedAt: committed.committedAt,
+ counts: committed.counts,
+ issues: committed.issues,
+ };
+ },
+ };
+}
+
+function analyze(
+ file: SyntheticImportFile,
+ references: SyntheticCsvReferences
+): SyntheticCsvAnalysis {
+ return analyzeSyntheticCsv(
+ {
+ name: file.name,
+ mediaType: file.mediaType,
+ sizeBytes: file.sizeBytes,
+ content: file.content,
+ },
+ references
+ );
+}
+function preview(
+ importId: string,
+ fileName: string,
+ analysis: SyntheticCsvAnalysis
+): ImportPreview {
+ return {
+ importId,
+ fileName,
+ payloadDigest: analysis.payloadDigest,
+ status: 'validated',
+ counts: analysis.counts,
+ issues: analysis.issues,
+ };
+}
+function scope(access: GuideAccess): ImportValidationRecord['access'] {
+ return { authUserId: access.authUserId, guideId: access.guideId, studioId: access.studioId };
+}
+function sameScope(left: ImportValidationRecord['access'], right: GuideAccess): boolean {
+ return (
+ left.authUserId === right.authUserId &&
+ left.guideId === right.guideId &&
+ left.studioId === right.studioId
+ );
+}
diff --git a/packages/application/src/operations-refresh.ts b/packages/application/src/operations-refresh.ts
new file mode 100644
index 0000000..4e0448a
--- /dev/null
+++ b/packages/application/src/operations-refresh.ts
@@ -0,0 +1,197 @@
+import type { BoardCompiler, RefreshRequestInput } from './board-compiler.js';
+import type { BoardKey, GuideAccess, SchedulerAccess } from './access.js';
+import type { BoardReader, BoardView, RefreshFailureCode, RefreshView } from './board-reader.js';
+
+export const CHICAGO_TIMEZONE = 'America/Chicago' as const;
+export interface ClaimedRefresh {
+ requestId: string;
+ workerId: string;
+ applicationScopeId: string;
+ guideId: string;
+ studioId: string;
+ boardDate: string;
+ trigger: 'manual' | 'nightly';
+ leaseExpiresAt: string;
+}
+export interface PublicationResult {
+ boardRunId: string;
+ replacedBoardRunId: string | null;
+}
+export interface RefreshPort {
+ request(input: RefreshRequestInput): Promise;
+ claim(
+ worker: SchedulerAccess,
+ requestId: string,
+ leaseMs: number
+ ): Promise;
+ heartbeat(worker: SchedulerAccess, requestId: string, leaseMs: number): Promise;
+ publish(claim: ClaimedRefresh): Promise;
+ fail(claim: ClaimedRefresh, failureCode: RefreshFailureCode): Promise;
+}
+export interface BoardReadPort {
+ read(access: GuideAccess, boardDate: string): Promise;
+}
+export interface NightlyDispatchOverrides {
+ boardDate?: string;
+ fixtureNow?: string;
+}
+
+export function createBoardCompiler(
+ port: RefreshPort,
+ options: { leaseMs?: number; now?: () => Date } = {}
+): BoardCompiler {
+ const leaseMs = options.leaseMs ?? 60_000;
+ const now = options.now ?? (() => new Date());
+ return {
+ request: (input) => port.request(input),
+ async compile(scheduler, refreshRequestId) {
+ const claim = await port.claim(scheduler, refreshRequestId, leaseMs);
+ if (!claim)
+ return { kind: 'failed', preservedBoardRunId: null, failureCode: 'input-unavailable' };
+ try {
+ if (claim.trigger === 'nightly' && !beforeNightlyDeadline(claim.boardDate, now())) {
+ const preservedBoardRunId = await port.fail(claim, 'deadline-missed');
+ return { kind: 'failed', preservedBoardRunId, failureCode: 'deadline-missed' };
+ }
+ // A port may use this bounded heartbeat between deterministic engine phases.
+ if (!(await port.heartbeat(scheduler, refreshRequestId, leaseMs))) {
+ const preservedBoardRunId = await port.fail(claim, 'worker-timeout');
+ return { kind: 'failed', preservedBoardRunId, failureCode: 'worker-timeout' };
+ }
+ const result = await port.publish(claim);
+ return { kind: 'succeeded', ...result };
+ } catch (error) {
+ const failureCode = refreshFailureCode(error) ?? 'compile-failed';
+ const preservedBoardRunId = await port.fail(claim, failureCode);
+ return { kind: 'failed', preservedBoardRunId, failureCode };
+ }
+ },
+ };
+}
+function refreshFailureCode(error: unknown): RefreshFailureCode | null {
+ if (error == null || typeof error !== 'object' || !('failureCode' in error)) return null;
+ const code = (error as { failureCode?: unknown }).failureCode;
+ return code === 'input-unavailable' ||
+ code === 'compile-failed' ||
+ code === 'persistence-failed' ||
+ code === 'deadline-missed' ||
+ code === 'worker-timeout'
+ ? code
+ : null;
+}
+export function createBoardReader(port: BoardReadPort): BoardReader {
+ return { readCurrent: (access, key) => port.read(access, key.boardDate) };
+}
+
+/** Civil board date; never derives an application date from the server's UTC/local zone. */
+export function chicagoBoardDate(now: Date = new Date()): string {
+ const values = new Intl.DateTimeFormat('en-CA', {
+ timeZone: CHICAGO_TIMEZONE,
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ })
+ .formatToParts(now)
+ .reduce>((all, part) => ({ ...all, [part.type]: part.value }), {});
+ return `${values.year}-${values.month}-${values.day}`;
+}
+/** [D-7 Chicago 00:00, D Chicago 00:00), valid through both DST changes. */
+export function chicagoBoardWindow(boardDate: string): { start: Date; end: Date } {
+ return { start: chicagoMidnight(addCivilDays(boardDate, -7)), end: chicagoMidnight(boardDate) };
+}
+export function beforeNightlyDeadline(boardDate: string, now: Date): boolean {
+ return now.getTime() <= chicagoMidnight(boardDate, 7, 45).getTime();
+}
+export function isNightlyDispatchEligible(now: Date = new Date()): boolean {
+ const boardDate = chicagoBoardDate(now);
+ const window = chicagoBoardWindow(boardDate);
+ return now >= window.end && beforeNightlyDeadline(boardDate, now);
+}
+export function validateNightlyDispatchOverrides(
+ input: { boardDate?: unknown; fixtureNow?: unknown },
+ allowOverrides: boolean
+): NightlyDispatchOverrides | null {
+ const boardDate = input.boardDate;
+ const fixtureNow = input.fixtureNow;
+ if (boardDate === undefined && fixtureNow === undefined) return {};
+ if (
+ !allowOverrides ||
+ typeof boardDate !== 'string' ||
+ typeof fixtureNow !== 'string' ||
+ !validBoardDate(boardDate) ||
+ !validFixtureNow(fixtureNow)
+ ) {
+ return null;
+ }
+ const now = new Date(fixtureNow);
+ return chicagoBoardDate(now) === boardDate ? { boardDate, fixtureNow } : null;
+}
+export function nightlyDispatchPayload(overrides: NightlyDispatchOverrides): string {
+ return overrides.boardDate && overrides.fixtureNow
+ ? JSON.stringify({
+ nightly: true,
+ boardDate: overrides.boardDate,
+ fixtureNow: overrides.fixtureNow,
+ })
+ : 'nightly';
+}
+function addCivilDays(date: string, days: number): string {
+ const parsed = new Date(`${date}T00:00:00.000Z`);
+ parsed.setUTCDate(parsed.getUTCDate() + days);
+ return parsed.toISOString().slice(0, 10);
+}
+function validBoardDate(value: string): boolean {
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
+ const parsed = new Date(`${value}T00:00:00.000Z`);
+ return !Number.isNaN(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value;
+}
+function validFixtureNow(value: string): boolean {
+ const match = value.match(
+ /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,3}))?(Z|[+-]\d{2}:\d{2})$/
+ );
+ if (!match) return false;
+ const parsed = new Date(value);
+ if (Number.isNaN(parsed.getTime())) return false;
+ const calendar = new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3])));
+ return (
+ calendar.getUTCFullYear() === Number(match[1]) &&
+ calendar.getUTCMonth() === Number(match[2]) - 1 &&
+ calendar.getUTCDate() === Number(match[3]) &&
+ Number(match[4]) <= 23 &&
+ Number(match[5]) <= 59 &&
+ Number(match[6]) <= 59
+ );
+}
+function chicagoMidnight(date: string, hour = 0, minute = 0): Date {
+ const [year, month, day] = date.split('-').map(Number) as [number, number, number];
+ const target = { year, month, day, hour, minute };
+ let candidate = new Date(Date.UTC(year, month - 1, day, hour, minute));
+ for (let iteration = 0; iteration < 4; iteration += 1) {
+ const parts = new Intl.DateTimeFormat('en-US', {
+ timeZone: CHICAGO_TIMEZONE,
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ hourCycle: 'h23',
+ })
+ .formatToParts(candidate)
+ .reduce>(
+ (all, part) => ({ ...all, [part.type]: Number(part.value) }),
+ {}
+ );
+ const observed = Date.UTC(
+ parts.year!,
+ parts.month! - 1,
+ parts.day!,
+ parts.hour!,
+ parts.minute!
+ );
+ const desired = Date.UTC(target.year, target.month - 1, target.day, target.hour, target.minute);
+ const delta = desired - observed;
+ if (delta === 0) return candidate;
+ candidate = new Date(candidate.getTime() + delta);
+ }
+ return candidate;
+}
diff --git a/packages/application/test/operations-importer.test.ts b/packages/application/test/operations-importer.test.ts
new file mode 100644
index 0000000..0f7d4f3
--- /dev/null
+++ b/packages/application/test/operations-importer.test.ts
@@ -0,0 +1,149 @@
+import { createHash } from 'node:crypto';
+import { describe, expect, it } from 'vitest';
+import {
+ createImporter,
+ type ImportPersistencePort,
+ type ImportValidationRecord,
+ type SyntheticImportFile,
+} from '../src/index.js';
+import { items, skills } from '@huddle/core/seed';
+import { SYNTHETIC_CSV_COLUMNS } from '@huddle/ingest';
+
+const access = {
+ authUserId: 'auth',
+ guideId: 'guide',
+ studioId: 'studio',
+ role: 'guide' as const,
+ syntheticOnly: true as const,
+};
+const references = {
+ students: new Map([['student', { id: 'student-id' }]]),
+ skills: new Set(skills.map((skill) => skill.id)),
+ items: new Map(items.map((item) => [item.id, item])),
+};
+function file(event = 'event-1', idempotencyKey = 'key'): SyntheticImportFile {
+ const values: Record = {
+ dataset_kind: 'synthetic',
+ source_event_id: event,
+ pseudonymous_student_ref: 'student',
+ vendor_skill: 'TEKS.4.2A',
+ vendor_item_ref: 'mcq:TEKS.4.2A-01',
+ source_session_id: 'session',
+ session_started_at: '2026-07-27T15:00:00.000Z',
+ session_ended_at: '2026-07-27T15:01:00.000Z',
+ session_total_ms: '',
+ vendor_attempt_count: '',
+ attempt_index: '1',
+ started_at: '2026-07-27T15:00:00.000Z',
+ submitted_at: '2026-07-27T15:00:10.000Z',
+ elapsed_ms: '10000',
+ engaged_ms: '',
+ timing_quality: 'wallclock',
+ is_correct: 'true',
+ answer_given: 'A',
+ hints_used: '0',
+ };
+ const content = new TextEncoder().encode(
+ [
+ SYNTHETIC_CSV_COLUMNS.join(','),
+ SYNTHETIC_CSV_COLUMNS.map((column) => values[column]).join(','),
+ ].join('\n')
+ );
+ return {
+ name: 'sample.csv',
+ mediaType: 'text/csv',
+ sizeBytes: content.byteLength,
+ content,
+ idempotencyKey,
+ };
+}
+function memoryPort(): ImportPersistencePort & { commits: number; saves: number } {
+ const records = new Map();
+ let commits = 0;
+ let saves = 0;
+ return {
+ get commits() {
+ return commits;
+ },
+ get saves() {
+ return saves;
+ },
+ async saveValidation(record) {
+ saves += 1;
+ const importId = createHash('sha256')
+ .update(`${record.idempotencyKey}:${record.payloadDigest}`)
+ .digest('hex')
+ .slice(0, 12);
+ const saved = { ...record, importId };
+ records.set(importId, saved);
+ return saved;
+ },
+ async getValidation(requestedAccess, id) {
+ const record = records.get(id);
+ if (
+ record?.access.authUserId !== requestedAccess.authUserId ||
+ record.access.guideId !== requestedAccess.guideId ||
+ record.access.studioId !== requestedAccess.studioId
+ )
+ return null;
+ return records.get(id) ?? null;
+ },
+ async commit(record, analysis) {
+ commits += 1;
+ return {
+ importId: record.importId,
+ committedAt: '2026-07-29T12:00:00.000Z',
+ status:
+ analysis.counts.rejected || analysis.counts.unmapped
+ ? 'succeeded-with-rejections'
+ : 'succeeded',
+ counts: analysis.counts,
+ issues: analysis.issues,
+ };
+ },
+ };
+}
+
+describe('application Importer workflow', () => {
+ it('keeps bytes client-supplied across validate/commit and rejects a changed digest before persistence', async () => {
+ const port = memoryPort();
+ const importer = createImporter(references, port);
+ const preview = await importer.validate(access, file());
+ await expect(
+ importer.commit(access, preview.importId, file('different-event'))
+ ).rejects.toThrow('changed after validation');
+ expect(port.commits).toBe(0);
+ });
+
+ it('commits the revalidated analysis through its injected persistence port', async () => {
+ const port = memoryPort();
+ const importer = createImporter(references, port);
+ const selected = file();
+ const preview = await importer.validate(access, selected);
+ await expect(importer.commit(access, preview.importId, selected)).resolves.toMatchObject({
+ status: 'succeeded',
+ counts: { accepted: 1 },
+ });
+ expect(port.commits).toBe(1);
+ });
+
+ it('fails closed when a second guide tries to commit a validated import', async () => {
+ const port = memoryPort();
+ const importer = createImporter(references, port);
+ const selected = file();
+ const preview = await importer.validate(access, selected);
+ await expect(
+ importer.commit({ ...access, guideId: 'other' }, preview.importId, selected)
+ ).rejects.toThrow('unavailable');
+ });
+
+ it('does not persist a validation token for a file-level fatal error', async () => {
+ const port = memoryPort();
+ const importer = createImporter(references, port);
+ const invalid = file();
+ invalid.content = new Uint8Array([0xff]);
+ invalid.sizeBytes = invalid.content.byteLength;
+ await expect(importer.validate(access, invalid)).rejects.toThrow('file-level');
+ expect(port.saves).toBe(0);
+ });
+});
diff --git a/packages/application/test/operations-refresh.test.ts b/packages/application/test/operations-refresh.test.ts
new file mode 100644
index 0000000..6ecb92b
--- /dev/null
+++ b/packages/application/test/operations-refresh.test.ts
@@ -0,0 +1,164 @@
+import { describe, expect, it } from 'vitest';
+import {
+ beforeNightlyDeadline,
+ chicagoBoardDate,
+ chicagoBoardWindow,
+ createBoardCompiler,
+ isNightlyDispatchEligible,
+ nightlyDispatchPayload,
+ validateNightlyDispatchOverrides,
+ type RefreshPort,
+} from '../src/index.js';
+
+const worker = {
+ workerId: 'worker',
+ capability: 'board-refresh' as const,
+ syntheticOnly: true as const,
+};
+function port(overrides: Partial = {}): RefreshPort & { failed: string[] } {
+ const failed: string[] = [];
+ return {
+ failed,
+ async request() {
+ return { state: 'queued', requestId: 'request', requestedAt: '2026-07-29T00:00:00.000Z' };
+ },
+ async claim() {
+ return {
+ requestId: 'request',
+ workerId: worker.workerId,
+ applicationScopeId: 'scope',
+ guideId: 'guide',
+ studioId: 'studio',
+ boardDate: '2026-07-29',
+ trigger: 'manual',
+ leaseExpiresAt: '2026-07-29T00:01:00.000Z',
+ };
+ },
+ async heartbeat() {
+ return true;
+ },
+ async publish() {
+ return { boardRunId: 'run-new', replacedBoardRunId: 'run-old' };
+ },
+ async fail(_claim, code) {
+ failed.push(code);
+ return 'run-old';
+ },
+ ...overrides,
+ };
+}
+describe('Operations refresh use case', () => {
+ it('uses Chicago civil dates and half-open windows at DST boundaries', () => {
+ expect(chicagoBoardDate(new Date('2026-07-30T04:30:00.000Z'))).toBe('2026-07-29');
+ expect(chicagoBoardWindow('2026-03-16')).toEqual({
+ start: new Date('2026-03-09T05:00:00.000Z'),
+ end: new Date('2026-03-16T05:00:00.000Z'),
+ });
+ expect(chicagoBoardWindow('2026-11-09')).toEqual({
+ start: new Date('2026-11-02T06:00:00.000Z'),
+ end: new Date('2026-11-09T06:00:00.000Z'),
+ });
+ expect(beforeNightlyDeadline('2026-07-29', new Date('2026-07-29T12:45:00.000Z'))).toBe(true);
+ expect(beforeNightlyDeadline('2026-07-29', new Date('2026-07-29T12:45:00.001Z'))).toBe(false);
+ expect(isNightlyDispatchEligible(new Date('2026-07-29T05:00:00.000Z'))).toBe(true);
+ expect(isNightlyDispatchEligible(new Date('2026-07-29T12:45:00.001Z'))).toBe(false);
+ });
+
+ it('publishes only after a DB claim and returns the replaced immutable run', async () => {
+ const compiler = createBoardCompiler(port());
+ await expect(compiler.compile(worker, 'request')).resolves.toEqual({
+ kind: 'succeeded',
+ boardRunId: 'run-new',
+ replacedBoardRunId: 'run-old',
+ });
+ });
+
+ it('accepts a matching test-only dispatch clock and binds it into the signed payload', async () => {
+ const overrides = validateNightlyDispatchOverrides(
+ {
+ boardDate: '2026-07-27',
+ fixtureNow: '2026-07-27T07:30:00-05:00',
+ },
+ true
+ );
+ expect(overrides).toEqual({
+ boardDate: '2026-07-27',
+ fixtureNow: '2026-07-27T07:30:00-05:00',
+ });
+ expect(nightlyDispatchPayload(overrides!)).toBe(
+ '{"nightly":true,"boardDate":"2026-07-27","fixtureNow":"2026-07-27T07:30:00-05:00"}'
+ );
+ expect(
+ validateNightlyDispatchOverrides(
+ {
+ boardDate: '2026-07-28',
+ fixtureNow: '2026-07-27T07:30:00-05:00',
+ },
+ true
+ )
+ ).toBeNull();
+ expect(
+ validateNightlyDispatchOverrides(
+ {
+ boardDate: '2026-07-27',
+ fixtureNow: '2026-07-27T07:30:00-05:00',
+ },
+ false
+ )
+ ).toBeNull();
+
+ const nightly = port({
+ async claim() {
+ return {
+ requestId: 'request',
+ workerId: worker.workerId,
+ applicationScopeId: 'scope',
+ guideId: 'guide',
+ studioId: 'studio',
+ boardDate: '2026-07-27',
+ trigger: 'nightly',
+ leaseExpiresAt: '2026-07-27T12:31:00.000Z',
+ };
+ },
+ });
+ await expect(
+ createBoardCompiler(nightly, {
+ now: () => new Date('2026-07-27T07:30:00-05:00'),
+ }).compile(worker, 'request')
+ ).resolves.toMatchObject({ kind: 'succeeded' });
+ });
+
+ it('preserves the old head when heartbeat or publication fails', async () => {
+ const expired = port({ heartbeat: async () => false });
+ await expect(createBoardCompiler(expired).compile(worker, 'request')).resolves.toEqual({
+ kind: 'failed',
+ preservedBoardRunId: 'run-old',
+ failureCode: 'worker-timeout',
+ });
+ expect(expired.failed).toEqual(['worker-timeout']);
+ const broken = port({
+ publish: async () => {
+ throw new Error('boom');
+ },
+ });
+ await expect(createBoardCompiler(broken).compile(worker, 'request')).resolves.toEqual({
+ kind: 'failed',
+ preservedBoardRunId: 'run-old',
+ failureCode: 'compile-failed',
+ });
+ expect(broken.failed).toEqual(['compile-failed']);
+ });
+
+ it('preserves the typed input-unavailable failure from publication', async () => {
+ const unavailable = port({
+ publish: async () => {
+ throw Object.assign(new Error('missing receipt'), { failureCode: 'input-unavailable' });
+ },
+ });
+ await expect(createBoardCompiler(unavailable).compile(worker, 'request')).resolves.toEqual({
+ kind: 'failed',
+ preservedBoardRunId: 'run-old',
+ failureCode: 'input-unavailable',
+ });
+ });
+});
diff --git a/packages/application/tsconfig.json b/packages/application/tsconfig.json
index 004204e..a0677bb 100644
--- a/packages/application/tsconfig.json
+++ b/packages/application/tsconfig.json
@@ -1,5 +1,6 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "rootDir": ".", "outDir": "./dist", "composite": true },
- "include": ["src/**/*.ts", "test/**/*.ts"]
+ "include": ["src/**/*.ts", "test/**/*.ts"],
+ "references": [{ "path": "../core" }, { "path": "../ingest" }]
}
diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts
index 32e7204..9fc6f7a 100644
--- a/packages/core/src/config.ts
+++ b/packages/core/src/config.ts
@@ -4,6 +4,7 @@ import { z } from 'zod';
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
DATABASE_URL: z.string().min(1).optional(),
+ DATABASE_ADMIN_URL: z.string().min(1).optional(),
ANTHROPIC_API_KEY: z.string().min(1).optional(),
});
diff --git a/packages/db/package.json b/packages/db/package.json
index 0965bca..6e493b2 100644
--- a/packages/db/package.json
+++ b/packages/db/package.json
@@ -50,6 +50,7 @@
"test": "vitest run"
},
"dependencies": {
+ "@huddle/application": "^0.1.0",
"@huddle/core": "^0.1.0",
"pg": "^8.12.0"
},
diff --git a/packages/db/src/boards.ts b/packages/db/src/boards.ts
index 20d8a3c..c0b2705 100644
--- a/packages/db/src/boards.ts
+++ b/packages/db/src/boards.ts
@@ -1,2 +1,319 @@
-/** Reserved DB seam: immutable board runs, entries, and board-head publication live here. */
-export type BoardPersistenceModule = 'boards';
+import { createHash } from 'node:crypto';
+import type { ClaimedRefresh, PublicationResult } from '@huddle/application';
+import type { PoolClient } from 'pg';
+import { pool } from './client.js';
+
+const sha256 = (value: unknown) => createHash('sha256').update(JSON.stringify(value)).digest('hex');
+export type CompiledSignal = {
+ studentId: string;
+ skillId: string | null;
+ kind: string;
+ ruleId: string;
+ ruleVersion: string;
+ severity: number;
+ confidence: number;
+ rawConfidence: number;
+ evidence: unknown;
+ behaviorFingerprint: string;
+ evidenceFingerprint: string;
+ fallbackCatalogVersion: string;
+ fallbackRenderVersion: string;
+ fallbackLanguageFingerprint: string;
+ fallbackNarrationFingerprint: string;
+ fallbackNarrationSelection: unknown;
+ fallbackDiagnosis: string;
+ fallbackOpener: string;
+ windowStart: Date;
+ windowEnd: Date;
+ computedAt: Date;
+};
+export type CompiledEntry = {
+ studentId: string;
+ dominant: number;
+ rank: number;
+ findingFingerprint: string;
+ additional: number[];
+ diagnosis: string;
+ opener: string;
+ catalogVersion: string;
+ renderVersion: string;
+ languageFingerprint: string;
+ narrationFingerprint: string;
+ narrationSelection: unknown;
+};
+export type CompiledMastery = {
+ studentId: string;
+ skillId: string;
+ value: number | null;
+ attemptCount: number;
+ isKnown: boolean;
+};
+export type CompiledBoardDraft = {
+ window: { start: Date; end: Date };
+ mastery: CompiledMastery[];
+ signals: CompiledSignal[];
+ entries: CompiledEntry[];
+};
+
+export async function loadCompilerSnapshot(
+ claim: ClaimedRefresh,
+ window: { start: Date; end: Date },
+ client: PoolClient
+) {
+ const [students, skills, prereqs, items, absences, attempts, currentMastery, priorMastery] =
+ await Promise.all([
+ client.query(
+ `SELECT id::text,first_name FROM student WHERE guide_id=$1::uuid AND is_synthetic IS TRUE ORDER BY id`,
+ [claim.guideId]
+ ),
+ client.query(`SELECT id,name,grade,strand FROM skill ORDER BY id`),
+ client.query(`SELECT skill_id,prereq_id,strength FROM skill_prereq`),
+ client.query(`SELECT id,skill_id,difficulty,choices,item_type,timing_profile FROM item`),
+ client.query(
+ `SELECT id,student_id::text,start_date::text,end_date::text,source
+ FROM absence
+ WHERE student_id IN (
+ SELECT id FROM student WHERE guide_id=$1::uuid AND is_synthetic IS TRUE
+ )`,
+ [claim.guideId]
+ ),
+ client.query(
+ `SELECT a.id,a.student_id::text,a.skill_id,a.item_id,a.learning_session_id::text,
+ a.attempt_index,a.started_at,a.submitted_at,a.elapsed_ms,a.engaged_ms,a.timing_quality,
+ a.timing_was_winsorized,
+ a.is_correct,a.answer_given,a.hints_used,a.source,a.source_event_id,a.ingested_at,
+ s.total_elapsed_ms
+ FROM attempt a JOIN learning_session s ON s.id=a.learning_session_id
+ JOIN import_run r ON r.id=a.import_run_id
+ JOIN student student_scope ON student_scope.id=a.student_id
+ WHERE a.application_scope_id=$1::bigint
+ AND student_scope.guide_id=$2::uuid
+ AND student_scope.is_synthetic IS TRUE
+ AND r.status IN ('succeeded','succeeded_with_rejections')
+ ORDER BY a.submitted_at,a.id`,
+ [claim.applicationScopeId, claim.guideId]
+ ),
+ client.query(
+ `SELECT m.student_id::text,m.skill_id,m.value,m.attempt_count,m.is_known
+ FROM mastery_at($1::timestamptz) m
+ JOIN student s ON s.id=m.student_id
+ WHERE s.guide_id=$2::uuid AND s.is_synthetic IS TRUE`,
+ [window.end, claim.guideId]
+ ),
+ client.query(
+ `SELECT m.student_id::text,m.skill_id,m.value,m.attempt_count,m.is_known
+ FROM mastery_at($1::timestamptz) m
+ JOIN student s ON s.id=m.student_id
+ WHERE s.guide_id=$2::uuid AND s.is_synthetic IS TRUE`,
+ [window.start, claim.guideId]
+ ),
+ ]);
+ return {
+ students: students.rows,
+ skills: skills.rows,
+ prereqs: prereqs.rows,
+ items: items.rows,
+ absences: absences.rows,
+ attempts: attempts.rows,
+ currentMastery: currentMastery.rows,
+ priorMastery: priorMastery.rows,
+ };
+}
+/** Atomic immutable publication; any insert failure rolls back before board_head changes. */
+export async function publishCompiledBoardRun(
+ claim: ClaimedRefresh,
+ compile: (client: PoolClient) => Promise
+): Promise {
+ const client = await pool.connect();
+ try {
+ await client.query('BEGIN ISOLATION LEVEL REPEATABLE READ');
+ const lease = await client.query<{ id: string }>(
+ `SELECT id::text FROM board_refresh_request
+ WHERE id=$1::bigint AND application_scope_id=$2::bigint AND guide_id=$3::uuid
+ AND studio_id=$4 AND board_date=$5::date AND trigger=$6::refresh_trigger
+ AND claimed_by_worker_id=$7
+ AND state='running' AND lease_expires_at > clock_timestamp()
+ FOR UPDATE`,
+ [
+ claim.requestId,
+ claim.applicationScopeId,
+ claim.guideId,
+ claim.studioId,
+ claim.boardDate,
+ claim.trigger,
+ claim.workerId,
+ ]
+ );
+ if (!lease.rows[0]) throw refreshFailure('worker-timeout', 'Refresh lease is unavailable.');
+ const receipts = await client.query<{
+ id: string;
+ payload_digest: string;
+ adapter_version: string;
+ }>(
+ `SELECT id::text,payload_digest,adapter_version FROM import_run WHERE application_scope_id=$1::bigint AND status IN ('succeeded','succeeded_with_rejections') ORDER BY id`,
+ [claim.applicationScopeId]
+ );
+ if (receipts.rows.length === 0)
+ throw refreshFailure('input-unavailable', 'No committed import receipts are available.');
+ const draft = await compile(client);
+ const current = await client.query<{ current_board_run_id: string }>(
+ `SELECT current_board_run_id::text FROM board_head WHERE application_scope_id=$1::bigint AND board_date=$2::date FOR UPDATE`,
+ [claim.applicationScopeId, claim.boardDate]
+ );
+ const asOf = draft.window.end;
+ const start = draft.window.start;
+ const run = await client.query<{ id: string }>(
+ `INSERT INTO board_run (refresh_request_id,application_scope_id,guide_id,studio_id,board_date,trigger,timezone,as_of,window_start,window_end,input_receipt_set_fingerprint,behavior_fingerprint,completed_at,supersedes_board_run_id) VALUES ($1::bigint,$2::bigint,$3::uuid,$4,$5::date,$6::refresh_trigger,'America/Chicago',$7,$8,$7,$9,$10,now(),$11::bigint) RETURNING id::text`,
+ [
+ claim.requestId,
+ claim.applicationScopeId,
+ claim.guideId,
+ claim.studioId,
+ claim.boardDate,
+ claim.trigger,
+ asOf,
+ start,
+ sha256(receipts.rows),
+ sha256(draft.signals.map((s) => [s.ruleId, s.ruleVersion, s.behaviorFingerprint])),
+ current.rows[0]?.current_board_run_id ?? null,
+ ]
+ );
+ for (const receipt of receipts.rows)
+ await client.query(
+ `INSERT INTO board_run_import(board_run_id,application_scope_id,import_run_id) VALUES($1::bigint,$2::bigint,$3::bigint)`,
+ [run.rows[0]!.id, claim.applicationScopeId, receipt.id]
+ );
+ for (const mastery of draft.mastery)
+ await client.query(
+ `INSERT INTO mastery_snapshot(board_run_id,as_of,student_id,skill_id,value,attempt_count,is_known)
+ VALUES($1::bigint,$2,$3::uuid,$4,$5,$6::bigint,$7)`,
+ [
+ run.rows[0]!.id,
+ asOf,
+ mastery.studentId,
+ mastery.skillId,
+ mastery.value,
+ mastery.attemptCount,
+ mastery.isKnown,
+ ]
+ );
+ const signalIds: string[] = [];
+ for (const s of draft.signals) {
+ const x = await client.query<{ id: string }>(
+ `INSERT INTO signal(board_run_id,student_id,scope_kind,skill_id,kind,rule_id,rule_version,
+ behavior_fingerprint,intensity,severity,raw_confidence,final_confidence,confidence_breakdown,
+ evidence,evidence_fingerprint,fallback_catalog_version,fallback_render_version,
+ fallback_language_fingerprint,fallback_narration_fingerprint,fallback_narration_selection,
+ fallback_diagnosis,fallback_opener,window_start,window_end,computed_at)
+ VALUES($1::bigint,$2::uuid,$3::scope_kind,$4,$5::root_cause,$6,$7,$8,$9,$9,$10,$11,
+ '{}'::jsonb,$12::jsonb,$13,$14,$15,$16,$17,$18::jsonb,$19,$20,$21,$22,$23)
+ RETURNING id::text`,
+ [
+ run.rows[0]!.id,
+ s.studentId,
+ s.skillId ? 'skill' : 'cross_skill',
+ s.skillId,
+ s.kind,
+ s.ruleId,
+ s.ruleVersion,
+ s.behaviorFingerprint,
+ s.severity,
+ s.rawConfidence,
+ s.confidence,
+ JSON.stringify(s.evidence),
+ s.evidenceFingerprint,
+ s.fallbackCatalogVersion,
+ s.fallbackRenderVersion,
+ s.fallbackLanguageFingerprint,
+ s.fallbackNarrationFingerprint,
+ JSON.stringify(s.fallbackNarrationSelection),
+ s.fallbackDiagnosis,
+ s.fallbackOpener,
+ s.windowStart,
+ s.windowEnd,
+ s.computedAt,
+ ]
+ );
+ signalIds.push(x.rows[0]!.id);
+ }
+ for (const e of draft.entries) {
+ const additional = e.additional.map((i) => {
+ const signal = draft.signals[i]!;
+ return {
+ signalId: signalIds[i],
+ cause: signal.kind,
+ severity: signal.severity,
+ finalConfidence: signal.confidence,
+ ruleId: signal.ruleId,
+ scope: signal.skillId
+ ? { kind: 'skill', skill: { code: signal.skillId, name: signal.skillId } }
+ : { kind: 'cross-skill' },
+ };
+ });
+ await client.query(
+ `INSERT INTO triage_entry(board_run_id,student_id,dominant_signal_id,finding_fingerprint,rank,
+ additional_causes,catalog_version,language_fingerprint,expected_narration_fingerprint,
+ narration_selection,narration_mode,narration_status,narration_degraded_reason,render_version,
+ diagnosis,opener,language_rendered_at)
+ VALUES($1::bigint,$2::uuid,$3::bigint,$4,$5,$6::jsonb,$7,$8,$9,$10::jsonb,
+ 'deterministic_fallback','degraded','model-unavailable',$11,$12,$13,now())`,
+ [
+ run.rows[0]!.id,
+ e.studentId,
+ signalIds[e.dominant],
+ e.findingFingerprint,
+ e.rank,
+ JSON.stringify(additional),
+ e.catalogVersion,
+ e.languageFingerprint,
+ e.narrationFingerprint,
+ JSON.stringify(e.narrationSelection),
+ e.renderVersion,
+ e.diagnosis,
+ e.opener,
+ ]
+ );
+ }
+ const completed = await client.query<{ id: string }>(
+ `UPDATE board_refresh_request
+ SET state='succeeded',completed_at=clock_timestamp(),resulting_board_run_id=$2::bigint,
+ lease_expires_at=NULL,heartbeat_at=NULL
+ WHERE id=$1::bigint AND state='running' AND lease_expires_at > clock_timestamp()
+ AND claimed_by_worker_id=$3
+ RETURNING id::text`,
+ [claim.requestId, run.rows[0]!.id, claim.workerId]
+ );
+ if (!completed.rows[0])
+ throw refreshFailure('worker-timeout', 'Refresh lease expired before publication.');
+ await client.query(
+ `INSERT INTO board_head(application_scope_id,guide_id,studio_id,board_date,current_board_run_id,updated_at) VALUES($1::bigint,$2::uuid,$3,$4::date,$5::bigint,now()) ON CONFLICT(application_scope_id,board_date) DO UPDATE SET current_board_run_id=EXCLUDED.current_board_run_id,updated_at=EXCLUDED.updated_at`,
+ [claim.applicationScopeId, claim.guideId, claim.studioId, claim.boardDate, run.rows[0]!.id]
+ );
+ await client.query('COMMIT');
+ return {
+ boardRunId: run.rows[0]!.id,
+ replacedBoardRunId: current.rows[0]?.current_board_run_id ?? null,
+ };
+ } catch (e) {
+ await client.query('ROLLBACK').catch(() => undefined);
+ throw e;
+ } finally {
+ client.release();
+ }
+}
+export async function publishEmptyBoardRun(
+ claim: ClaimedRefresh,
+ window: { start: Date; end: Date },
+ mastery: CompiledMastery[] = []
+) {
+ return publishCompiledBoardRun(claim, async () => ({
+ window,
+ mastery,
+ signals: [],
+ entries: [],
+ }));
+}
+function refreshFailure(failureCode: 'input-unavailable' | 'worker-timeout', message: string) {
+ return Object.assign(new Error(message), { failureCode });
+}
+export type { PoolClient };
diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts
index 959d98f..3cf8a58 100644
--- a/packages/db/src/client.ts
+++ b/packages/db/src/client.ts
@@ -21,12 +21,16 @@ export interface Migration {
* while keeping migration files themselves immutable.
*/
export async function runMigrations(migrationsDir: string): Promise {
+ if (!config.DATABASE_ADMIN_URL) {
+ throw new Error('DATABASE_ADMIN_URL is required to run migrations');
+ }
const dir = resolve(migrationsDir);
const files = (await readdir(dir))
.filter((file) => file.endsWith('.sql'))
.sort((a, b) => a.localeCompare(b, 'en-US'));
- const client = await pool.connect();
+ const adminPool = new Pool({ connectionString: config.DATABASE_ADMIN_URL });
+ const client = await adminPool.connect();
try {
await client.query(`
CREATE TABLE IF NOT EXISTS schema_migration (
@@ -58,6 +62,7 @@ export async function runMigrations(migrationsDir: string): Promise {
return applied;
} finally {
client.release();
+ await adminPool.end();
}
}
diff --git a/packages/db/src/imports.ts b/packages/db/src/imports.ts
index 70c3f1d..7c9a05b 100644
--- a/packages/db/src/imports.ts
+++ b/packages/db/src/imports.ts
@@ -1,2 +1,651 @@
-/** Reserved DB seam: the Operations stream owns import receipts, sessions, and persistence ports. */
-export type ImportPersistenceModule = 'imports';
+import type { ImportPersistencePort, ImportValidationRecord } from '@huddle/application';
+import type { SyntheticCsvAnalysis } from '@huddle/ingest';
+import type { QueryResultRow } from 'pg';
+import { pool } from './client.js';
+import type { DbQueryable } from './refresh.js';
+
+export interface ImportScope {
+ authUserId: string;
+ guideId: string;
+ studioId: string;
+}
+export interface ImportValidationInput {
+ access: ImportScope;
+ payloadDigest: string;
+ adapterVersion: string;
+ fileName: string;
+ idempotencyKey: string;
+ analysis: SyntheticCsvAnalysis;
+}
+export interface StoredImportValidation extends ImportValidationInput {
+ importId: string;
+ applicationScopeId: string;
+}
+export interface StoredImportReceipt {
+ importId: string;
+ committedAt: string;
+ status: 'succeeded' | 'succeeded-with-rejections';
+ counts: SyntheticCsvAnalysis['counts'];
+ issues: SyntheticCsvAnalysis['issues'];
+}
+export interface TransactionalQueryable extends DbQueryable {
+ query(
+ text: string,
+ values?: readonly unknown[]
+ ): Promise<{ rows: T[] }>;
+}
+
+export async function saveImportValidation(
+ input: ImportValidationInput,
+ client?: TransactionalQueryable
+): Promise {
+ if (!client) {
+ const connection = await pool.connect();
+ try {
+ return await saveImportValidation(input, connection);
+ } finally {
+ connection.release();
+ }
+ }
+ await client.query('BEGIN');
+ try {
+ const scope = await client.query<{ id: string }>(
+ `SELECT id::text FROM guide_auth_scope WHERE auth_user_id = $1::uuid AND guide_id = $2::uuid
+ AND studio_id = $3 AND is_synthetic IS TRUE`,
+ [input.access.authUserId, input.access.guideId, input.access.studioId]
+ );
+ if (!scope.rows[0]) throw new Error('Synthetic import scope is unavailable.');
+ const existing = await client.query<{ id: string; payload_digest: string }>(
+ `SELECT id::text, payload_digest FROM import_run
+ WHERE guide_id = $1::uuid AND adapter_id = 'synthetic-csv-v1' AND idempotency_key = $2 FOR UPDATE`,
+ [input.access.guideId, input.idempotencyKey]
+ );
+ if (existing.rows[0]) {
+ if (existing.rows[0].payload_digest !== input.payloadDigest)
+ throw new Error('Idempotency key was already used with different bytes.');
+ await client.query('COMMIT');
+ return { ...input, importId: existing.rows[0].id, applicationScopeId: scope.rows[0].id };
+ }
+ const inserted = await client.query<{ id: string }>(
+ `INSERT INTO import_run (
+ application_scope_id, guide_id, studio_id, requested_by_auth_user_id, ingest_origin,
+ adapter_id, adapter_version, source, original_file_name, media_type, idempotency_key,
+ payload_digest, is_synthetic, status, received_at, validated_at,
+ received_count, accepted_count, duplicate_count, unmapped_count, rejected_count
+ ) SELECT $1::bigint,$2::uuid,$3,$4::uuid,'guide_csv','synthetic-csv-v1',$5,
+ 'synthetic-csv-v1',$6,'text/csv',$7,$8,TRUE,'validated',now(),now(),$9,$10,$11,$12,$13
+ FROM guide_auth_scope scope
+ WHERE scope.id=$1::bigint AND scope.auth_user_id=$4::uuid AND scope.guide_id=$2::uuid
+ AND scope.studio_id=$3 AND scope.is_synthetic IS TRUE
+ RETURNING id::text`,
+ [
+ scope.rows[0].id,
+ input.access.guideId,
+ input.access.studioId,
+ input.access.authUserId,
+ input.adapterVersion,
+ input.fileName,
+ input.idempotencyKey,
+ input.payloadDigest,
+ input.analysis.counts.received,
+ input.analysis.counts.accepted,
+ input.analysis.counts.duplicate,
+ input.analysis.counts.unmapped,
+ input.analysis.counts.rejected,
+ ]
+ );
+ if (!inserted.rows[0]) throw new Error('Synthetic import scope is unavailable.');
+ for (const issue of input.analysis.issues) {
+ if (issue.outcome === 'unmapped') continue;
+ await client.query(
+ `INSERT INTO import_issue (import_run_id,row_number,source_event_id,outcome,code,field,safe_detail)
+ VALUES ($1::bigint,$2,$3,$4,$5,$6,$7) ON CONFLICT DO NOTHING`,
+ [
+ inserted.rows[0]!.id,
+ issue.rowNumber,
+ safeEventId(issue.sourceEventId),
+ issue.outcome,
+ issue.code,
+ issue.field,
+ issue.safeDetail,
+ ]
+ );
+ }
+ await client.query('COMMIT');
+ return { ...input, importId: inserted.rows[0]!.id, applicationScopeId: scope.rows[0].id };
+ } catch (error) {
+ await client.query('ROLLBACK').catch(() => undefined);
+ throw error;
+ }
+}
+
+export async function getImportValidation(
+ importId: string,
+ access: ImportScope,
+ client: DbQueryable = pool
+): Promise<{
+ importId: string;
+ payloadDigest: string;
+ adapterVersion: string;
+ fileName: string;
+ idempotencyKey: string;
+ access: ImportScope;
+} | null> {
+ const result = await client.query<{
+ id: string;
+ payload_digest: string;
+ adapter_version: string;
+ original_file_name: string;
+ idempotency_key: string;
+ auth_user_id: string;
+ guide_id: string;
+ studio_id: string;
+ }>(
+ `SELECT receipt.id::text, receipt.payload_digest, receipt.adapter_version, receipt.original_file_name,
+ receipt.idempotency_key, receipt.requested_by_auth_user_id AS auth_user_id, receipt.guide_id, receipt.studio_id
+ FROM import_run receipt
+ JOIN guide_auth_scope trusted_scope
+ ON trusted_scope.id = receipt.application_scope_id
+ AND trusted_scope.auth_user_id = $2::uuid
+ AND trusted_scope.guide_id = $3::uuid
+ AND trusted_scope.studio_id = $4
+ AND trusted_scope.is_synthetic IS TRUE
+ WHERE receipt.id = $1::bigint
+ AND receipt.requested_by_auth_user_id = $2::uuid
+ AND receipt.guide_id = $3::uuid
+ AND receipt.studio_id = $4
+ AND receipt.is_synthetic IS TRUE`,
+ [importId, access.authUserId, access.guideId, access.studioId]
+ );
+ const row = result.rows[0];
+ return row
+ ? {
+ importId: row.id,
+ payloadDigest: row.payload_digest,
+ adapterVersion: row.adapter_version,
+ fileName: row.original_file_name,
+ idempotencyKey: row.idempotency_key,
+ access: { authUserId: row.auth_user_id, guideId: row.guide_id, studioId: row.studio_id },
+ }
+ : null;
+}
+
+/** Commit has one receipt lock and one transaction: attempts/sessions/unmapped facts cannot leak partially. */
+export async function commitImport(
+ validation: StoredImportValidation,
+ analysis: SyntheticCsvAnalysis,
+ client?: TransactionalQueryable
+): Promise {
+ if (!client) {
+ const connection = await pool.connect();
+ try {
+ return await commitImport(validation, analysis, connection);
+ } finally {
+ connection.release();
+ }
+ }
+ await client.query('BEGIN');
+ try {
+ const receipt = await client.query<{ status: string; payload_digest: string }>(
+ `SELECT status::text, payload_digest FROM import_run WHERE id = $1::bigint FOR UPDATE`,
+ [validation.importId]
+ );
+ if (!receipt.rows[0] || receipt.rows[0].payload_digest !== analysis.payloadDigest)
+ throw new Error('Validated import bytes are unavailable.');
+ if (
+ receipt.rows[0].status === 'succeeded' ||
+ receipt.rows[0].status === 'succeeded_with_rejections'
+ ) {
+ const receiptView = await persistedReceipt(validation.importId, client);
+ await client.query('COMMIT');
+ return receiptView;
+ }
+ await client.query(`UPDATE import_run SET status = 'committing' WHERE id = $1::bigint`, [
+ validation.importId,
+ ]);
+ const identityLocks = new Set();
+ for (const session of analysis.sessions)
+ identityLocks.add(
+ JSON.stringify([
+ 'session',
+ validation.applicationScopeId,
+ session.studentId,
+ 'synthetic-csv-v1',
+ session.sourceSessionId,
+ ])
+ );
+ for (const attempt of analysis.acceptedAttempts)
+ identityLocks.add(
+ JSON.stringify([
+ 'event',
+ validation.applicationScopeId,
+ attempt.studentId,
+ 'synthetic-csv-v1',
+ attempt.sourceEventId,
+ ])
+ );
+ for (const unmapped of analysis.unmapped)
+ identityLocks.add(
+ JSON.stringify([
+ 'event',
+ validation.applicationScopeId,
+ unmapped.studentId,
+ 'synthetic-csv-v1',
+ unmapped.sourceEventId,
+ ])
+ );
+ for (const identity of [...identityLocks].sort((left, right) =>
+ left < right ? -1 : left > right ? 1 : 0
+ ))
+ await client.query(`SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, [identity]);
+ const sessionIds = new Map();
+ const pendingSessions = new Map();
+ const invalidSessions = new Set();
+ for (const session of analysis.sessions) {
+ const key = `${session.studentId}\u0000${session.sourceSessionId}`;
+ const found = await client.query<{ id: string; same: boolean }>(
+ `SELECT id::text, started_at=$4::timestamptz AND ended_at=$5::timestamptz
+ AND total_elapsed_ms IS NOT DISTINCT FROM $6::int
+ AND vendor_attempt_count IS NOT DISTINCT FROM $7::int
+ AND timing_quality=$8::timing_quality AS same
+ FROM learning_session WHERE application_scope_id=$1::bigint AND student_id=$2::uuid
+ AND source='synthetic-csv-v1' AND source_session_id=$3`,
+ [
+ validation.applicationScopeId,
+ session.studentId,
+ session.sourceSessionId,
+ session.startedAt,
+ session.endedAt,
+ session.totalElapsedMs,
+ session.vendorAttemptCount,
+ session.timingQuality,
+ ]
+ );
+ if (found.rows[0] && !found.rows[0].same) invalidSessions.add(key);
+ else if (found.rows[0]) sessionIds.set(key, found.rows[0].id);
+ else pendingSessions.set(key, session);
+ }
+ const counts = { ...analysis.counts, accepted: 0, unmapped: 0 };
+ const finalIssues = analysis.issues.filter((issue) => issue.outcome !== 'unmapped');
+ const recordCommitIssue = async (
+ row: { rowNumber: number; sourceEventId: string },
+ outcome: 'duplicate' | 'unmapped' | 'rejected',
+ code: string,
+ field: string,
+ detail: string
+ ) => {
+ const issue = {
+ rowNumber: row.rowNumber,
+ sourceEventId: safeEventId(row.sourceEventId),
+ outcome,
+ code,
+ field,
+ safeDetail: detail,
+ };
+ if (finalIssues.length < 100) finalIssues.push(issue);
+ await client.query(
+ `INSERT INTO import_issue (import_run_id,row_number,source_event_id,outcome,code,field,safe_detail)
+ VALUES ($1::bigint,$2,$3,$4,$5,$6,$7) ON CONFLICT DO NOTHING`,
+ [
+ validation.importId,
+ issue.rowNumber,
+ issue.sourceEventId,
+ issue.outcome,
+ issue.code,
+ issue.field,
+ issue.safeDetail,
+ ]
+ );
+ };
+ const resolveSession = async (sessionKey: string): Promise => {
+ const existingId = sessionIds.get(sessionKey);
+ if (existingId) return existingId;
+ const session = pendingSessions.get(sessionKey);
+ if (!session) return null;
+ const inserted = await client.query<{ id: string; same: boolean }>(
+ `WITH inserted AS (
+ INSERT INTO learning_session (import_run_id,application_scope_id,student_id,source,source_session_id,
+ started_at,ended_at,total_elapsed_ms,vendor_attempt_count,timing_quality,ingested_at)
+ VALUES ($1::bigint,$2::bigint,$3::uuid,'synthetic-csv-v1',$4,$5,$6,$7,$8,$9::timing_quality,now())
+ ON CONFLICT (application_scope_id,student_id,source,source_session_id) DO NOTHING
+ RETURNING id::text, TRUE AS same
+ )
+ SELECT id, same FROM inserted
+ UNION ALL
+ SELECT id::text, started_at=$5::timestamptz AND ended_at=$6::timestamptz
+ AND total_elapsed_ms IS NOT DISTINCT FROM $7::int
+ AND vendor_attempt_count IS NOT DISTINCT FROM $8::int
+ AND timing_quality=$9::timing_quality AS same
+ FROM learning_session WHERE application_scope_id=$2::bigint AND student_id=$3::uuid
+ AND source='synthetic-csv-v1' AND source_session_id=$4
+ LIMIT 1`,
+ [
+ validation.importId,
+ validation.applicationScopeId,
+ session.studentId,
+ session.sourceSessionId,
+ session.startedAt,
+ session.endedAt,
+ session.totalElapsedMs,
+ session.vendorAttemptCount,
+ session.timingQuality,
+ ]
+ );
+ if (!inserted.rows[0]?.same) {
+ invalidSessions.add(sessionKey);
+ return null;
+ }
+ sessionIds.set(sessionKey, inserted.rows[0].id);
+ return inserted.rows[0].id;
+ };
+ for (const attempt of analysis.acceptedAttempts) {
+ const sessionKey = `${attempt.studentId}\u0000${attempt.sourceSessionId}`;
+ if (invalidSessions.has(sessionKey)) {
+ counts.rejected += 1;
+ await recordCommitIssue(
+ attempt,
+ 'rejected',
+ 'divergent-stored-session',
+ 'source_session_id',
+ 'The stored session aggregate differs from this row.'
+ );
+ continue;
+ }
+ const retainedUnmapped = await client.query<{ id: string }>(
+ `SELECT id::text FROM unmapped_activity
+ WHERE application_scope_id=$1::bigint AND pseudonymous_student_ref=$2
+ AND source='synthetic-csv-v1' AND source_event_id=$3`,
+ [validation.applicationScopeId, attempt.studentRef, attempt.sourceEventId]
+ );
+ if (retainedUnmapped.rows[0]) {
+ counts.rejected += 1;
+ await recordCommitIssue(
+ attempt,
+ 'rejected',
+ 'divergent-stored-event',
+ 'source_event_id',
+ 'The stored event was retained as unmapped activity.'
+ );
+ continue;
+ }
+ const existing = await client.query<{ id: string; same: boolean }>(
+ `SELECT attempt.id::text,
+ stored_session.source_session_id=$4 AND attempt.skill_id=$5 AND attempt.item_id=$6
+ AND attempt.attempt_index=$7 AND attempt.started_at=$8::timestamptz
+ AND attempt.submitted_at=$9::timestamptz
+ AND attempt.elapsed_ms IS NOT DISTINCT FROM $10::int
+ AND attempt.engaged_ms IS NOT DISTINCT FROM $11::int
+ AND attempt.timing_quality=$12::timing_quality
+ AND attempt.timing_was_winsorized=$13 AND attempt.is_correct=$14
+ AND attempt.answer_given IS NOT DISTINCT FROM $15::jsonb
+ AND attempt.hints_used=$16 AS same
+ FROM attempt
+ JOIN learning_session stored_session ON stored_session.id=attempt.learning_session_id
+ WHERE attempt.application_scope_id=$1::bigint AND attempt.student_id=$2::uuid
+ AND attempt.source='synthetic-csv-v1' AND attempt.source_event_id=$3`,
+ [
+ validation.applicationScopeId,
+ attempt.studentId,
+ attempt.sourceEventId,
+ attempt.sourceSessionId,
+ attempt.skillId,
+ attempt.itemId,
+ attempt.attemptIndex,
+ attempt.startedAt,
+ attempt.submittedAt,
+ attempt.elapsedMs,
+ attempt.engagedMs,
+ attempt.timingQuality,
+ attempt.timingWasWinsorized,
+ attempt.isCorrect,
+ JSON.stringify(attempt.answerGiven),
+ attempt.hintsUsed,
+ ]
+ );
+ if (existing.rows[0]) {
+ if (existing.rows[0].same) {
+ counts.duplicate += 1;
+ await recordCommitIssue(
+ attempt,
+ 'duplicate',
+ 'stored-duplicate',
+ 'source_event_id',
+ 'An identical stored event was already committed.'
+ );
+ } else {
+ counts.rejected += 1;
+ await recordCommitIssue(
+ attempt,
+ 'rejected',
+ 'divergent-stored-event',
+ 'source_event_id',
+ 'The stored event differs from this row.'
+ );
+ }
+ continue;
+ }
+ const sessionId = await resolveSession(sessionKey);
+ if (!sessionId) {
+ counts.rejected += 1;
+ await recordCommitIssue(
+ attempt,
+ 'rejected',
+ 'divergent-stored-session',
+ 'source_session_id',
+ 'The stored session aggregate differs from this row.'
+ );
+ continue;
+ }
+ await client.query(
+ `INSERT INTO attempt (import_run_id,application_scope_id,student_id,skill_id,item_id,learning_session_id,
+ attempt_index,started_at,submitted_at,elapsed_ms,engaged_ms,timing_quality,
+ timing_was_winsorized,is_correct,answer_given,hints_used,source,source_event_id,ingested_at)
+ VALUES ($1::bigint,$2::bigint,$3::uuid,$4,$5,$6::bigint,$7,$8,$9,$10,$11,$12::timing_quality,
+ $13,$14,$15::jsonb,$16,'synthetic-csv-v1',$17,now())`,
+ [
+ validation.importId,
+ validation.applicationScopeId,
+ attempt.studentId,
+ attempt.skillId,
+ attempt.itemId,
+ sessionId,
+ attempt.attemptIndex,
+ attempt.startedAt,
+ attempt.submittedAt,
+ attempt.elapsedMs,
+ attempt.engagedMs,
+ attempt.timingQuality,
+ attempt.timingWasWinsorized,
+ attempt.isCorrect,
+ JSON.stringify(attempt.answerGiven),
+ attempt.hintsUsed,
+ attempt.sourceEventId,
+ ]
+ );
+ counts.accepted += 1;
+ }
+ for (const unmapped of analysis.unmapped) {
+ const retainedAttempt = await client.query<{ id: string }>(
+ `SELECT id::text FROM attempt
+ WHERE application_scope_id=$1::bigint AND student_id=$2::uuid
+ AND source='synthetic-csv-v1' AND source_event_id=$3`,
+ [validation.applicationScopeId, unmapped.studentId, unmapped.sourceEventId]
+ );
+ if (retainedAttempt.rows[0]) {
+ counts.rejected += 1;
+ await recordCommitIssue(
+ unmapped,
+ 'rejected',
+ 'divergent-stored-event',
+ 'source_event_id',
+ 'The stored event was already committed as mapped activity.'
+ );
+ continue;
+ }
+ const retainedUnmapped = await client.query<{ id: string; same: boolean }>(
+ `SELECT id::text,
+ vendor_skill=$4 AND received_at=$5::timestamptz
+ AND failure_kind='unmapped_skill'::ingest_failure_kind AS same
+ FROM unmapped_activity
+ WHERE application_scope_id=$1::bigint AND pseudonymous_student_ref=$2
+ AND source='synthetic-csv-v1' AND source_event_id=$3`,
+ [
+ validation.applicationScopeId,
+ unmapped.pseudonymousStudentRef,
+ unmapped.sourceEventId,
+ unmapped.vendorSkill,
+ unmapped.receivedAt,
+ ]
+ );
+ if (retainedUnmapped.rows[0]) {
+ if (retainedUnmapped.rows[0].same) {
+ counts.duplicate += 1;
+ await recordCommitIssue(
+ unmapped,
+ 'duplicate',
+ 'stored-duplicate',
+ 'source_event_id',
+ 'An identical unmapped event was already retained.'
+ );
+ } else {
+ counts.rejected += 1;
+ await recordCommitIssue(
+ unmapped,
+ 'rejected',
+ 'divergent-stored-event',
+ 'source_event_id',
+ 'The retained unmapped event differs from this row.'
+ );
+ }
+ continue;
+ }
+ await client.query(
+ `INSERT INTO unmapped_activity (import_run_id,application_scope_id,source,source_event_id,vendor_skill,
+ pseudonymous_student_ref,received_at,failure_kind)
+ VALUES ($1::bigint,$2::bigint,'synthetic-csv-v1',$3,$4,$5,$6,'unmapped_skill')`,
+ [
+ validation.importId,
+ validation.applicationScopeId,
+ unmapped.sourceEventId,
+ unmapped.vendorSkill,
+ unmapped.pseudonymousStudentRef,
+ unmapped.receivedAt,
+ ]
+ );
+ counts.unmapped += 1;
+ await recordCommitIssue(
+ unmapped,
+ 'unmapped',
+ 'unmapped-skill',
+ 'vendor_skill',
+ 'The skill is not mapped by synthetic-csv-v1.'
+ );
+ }
+ const status =
+ counts.rejected > 0 || counts.unmapped > 0 ? 'succeeded_with_rejections' : 'succeeded';
+ await client.query(
+ `UPDATE import_run SET status=$2::import_status, committed_at=now(), accepted_count=$3, duplicate_count=$4,
+ unmapped_count=$5, rejected_count=$6 WHERE id=$1::bigint`,
+ [
+ validation.importId,
+ status,
+ counts.accepted,
+ counts.duplicate,
+ counts.unmapped,
+ counts.rejected,
+ ]
+ );
+ const output = {
+ importId: validation.importId,
+ committedAt: new Date().toISOString(),
+ status:
+ status === 'succeeded' ? ('succeeded' as const) : ('succeeded-with-rejections' as const),
+ counts,
+ issues: finalIssues,
+ };
+ await client.query('COMMIT');
+ return output;
+ } catch (error) {
+ await client.query('ROLLBACK').catch(() => undefined);
+ throw error;
+ }
+}
+async function persistedReceipt(
+ importId: string,
+ client: DbQueryable
+): Promise {
+ const row = await client.query<{
+ committed_at: Date;
+ status: string;
+ received_count: number;
+ accepted_count: number;
+ duplicate_count: number;
+ unmapped_count: number;
+ rejected_count: number;
+ }>(
+ `SELECT committed_at,status::text,received_count,accepted_count,duplicate_count,unmapped_count,rejected_count FROM import_run WHERE id=$1::bigint`,
+ [importId]
+ );
+ const issues = await client.query<{
+ row_number: number;
+ source_event_id: string | null;
+ outcome: 'duplicate' | 'unmapped' | 'rejected';
+ code: string;
+ field: string | null;
+ safe_detail: string;
+ }>(
+ `SELECT row_number,source_event_id,outcome,code,field,safe_detail FROM import_issue WHERE import_run_id=$1::bigint ORDER BY row_number,code LIMIT 100`,
+ [importId]
+ );
+ const value = row.rows[0]!;
+ return {
+ importId,
+ committedAt: value.committed_at.toISOString(),
+ status: value.status === 'succeeded' ? 'succeeded' : 'succeeded-with-rejections',
+ counts: {
+ received: value.received_count,
+ accepted: value.accepted_count,
+ duplicate: value.duplicate_count,
+ unmapped: value.unmapped_count,
+ rejected: value.rejected_count,
+ },
+ issues: issues.rows.map((issue) => ({
+ rowNumber: issue.row_number,
+ sourceEventId: issue.source_event_id,
+ outcome: issue.outcome,
+ code: issue.code,
+ field: issue.field,
+ safeDetail: issue.safe_detail,
+ })),
+ };
+}
+function safeEventId(value: string | null): string | null {
+ return value != null && /^[A-Za-z0-9:_-]{1,128}$/.test(value) ? value : null;
+}
+
+/** Adapter used only by server composition; ingest remains driver-free and sees its narrow port. */
+export function createDbImportPersistence(client?: TransactionalQueryable): ImportPersistencePort {
+ return {
+ async saveValidation(record) {
+ const saved = await saveImportValidation(record, client);
+ return { ...record, importId: saved.importId };
+ },
+ async getValidation(access, importId) {
+ return getImportValidation(importId, access, client ?? pool);
+ },
+ async commit(record, analysis) {
+ const queryable = client ?? pool;
+ const scope = await queryable.query<{ id: string }>(
+ `SELECT id::text FROM guide_auth_scope WHERE auth_user_id=$1::uuid AND guide_id=$2::uuid
+ AND studio_id=$3 AND is_synthetic IS TRUE`,
+ [record.access.authUserId, record.access.guideId, record.access.studioId]
+ );
+ if (!scope.rows[0]) throw new Error('Synthetic import scope is unavailable.');
+ return commitImport(
+ { ...record, analysis, applicationScopeId: scope.rows[0].id },
+ analysis,
+ client
+ );
+ },
+ };
+}
diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts
index 2946bb0..ab1a23d 100644
--- a/packages/db/src/index.ts
+++ b/packages/db/src/index.ts
@@ -4,10 +4,53 @@ export {
getStudentsForGuide,
getStudentByIdForGuide,
getGuideScopeForAuthUser,
+ getSyntheticRosterForGuide,
+ getExactEvidenceForGuide,
+ getBoardViewForGuide,
getTriageBoardForGuide,
getMasteryRowsForGuide,
- clearMasterySnapshotForGuide,
- clearBoardForGuide,
} from './scoped.js';
-export type { Queryable, StudentRow, GuideScope, TriageBoardRow, MasteryRow } from './scoped.js';
+export type {
+ Queryable,
+ StudentRow,
+ SyntheticRosterRow,
+ GuideScope,
+ TriageBoardRow,
+ MasteryRow,
+ ExactEvidenceRow,
+} from './scoped.js';
export { opaqueActivityId } from './activity-identity.js';
+export {
+ commitImport,
+ createDbImportPersistence,
+ getImportValidation,
+ saveImportValidation,
+} from './imports.js';
+export type {
+ ImportScope,
+ ImportValidationInput,
+ StoredImportReceipt,
+ StoredImportValidation,
+ TransactionalQueryable,
+} from './imports.js';
+export {
+ claimRefresh,
+ enqueueNightlyRefreshes,
+ failRefresh,
+ heartbeatRefresh,
+ reapExpiredRefreshes,
+ requestRefresh,
+} from './refresh.js';
+export type {
+ ClaimedRefreshRow,
+ DbQueryable,
+ NightlyRefreshRow,
+ RefreshRequestRow,
+} from './refresh.js';
+export { loadCompilerSnapshot, publishCompiledBoardRun, publishEmptyBoardRun } from './boards.js';
+export type {
+ CompiledBoardDraft,
+ CompiledEntry,
+ CompiledMastery,
+ CompiledSignal,
+} from './boards.js';
diff --git a/packages/db/src/refresh.ts b/packages/db/src/refresh.ts
index 4f8f023..16f5d44 100644
--- a/packages/db/src/refresh.ts
+++ b/packages/db/src/refresh.ts
@@ -1,2 +1,185 @@
-/** Reserved DB seam: refresh request/claim/lease state lives here. */
-export type RefreshPersistenceModule = 'refresh';
+import type { RefreshRequestInput } from '@huddle/application';
+import type { QueryResultRow } from 'pg';
+import { pool } from './client.js';
+
+export interface DbQueryable {
+ query(
+ text: string,
+ values?: readonly unknown[]
+ ): Promise<{ rows: T[] }>;
+}
+export interface RefreshRequestRow {
+ id: string;
+ state: 'queued' | 'running' | 'succeeded' | 'failed';
+ requested_at: Date;
+ completed_at: Date | null;
+ resulting_board_run_id: string | null;
+ preserved_board_run_id: string | null;
+ failure_code:
+ | 'input-unavailable'
+ | 'compile-failed'
+ | 'persistence-failed'
+ | 'deadline-missed'
+ | 'worker-timeout'
+ | null;
+}
+export interface ClaimedRefreshRow {
+ id: string;
+ claimed_by_worker_id: string;
+ application_scope_id: string;
+ guide_id: string;
+ studio_id: string;
+ board_date: string;
+ trigger: 'manual' | 'nightly';
+ lease_expires_at: Date;
+}
+export interface NightlyRefreshRow {
+ id: string;
+ application_scope_id: string;
+}
+
+export async function requestRefresh(
+ input: RefreshRequestInput,
+ client: DbQueryable = pool
+): Promise {
+ const manual = input.trigger === 'manual';
+ const scope = manual
+ ? await client.query<{ id: string; guide_id?: string; studio_id?: string }>(
+ `SELECT id FROM guide_auth_scope WHERE auth_user_id = $1 AND guide_id = $2 AND studio_id = $3
+ AND is_synthetic IS TRUE`,
+ [input.access.authUserId, input.access.guideId, input.access.studioId]
+ )
+ : await client.query<{ id: string; guide_id?: string; studio_id?: string }>(
+ `SELECT id, guide_id, studio_id FROM guide_auth_scope WHERE id = $1 AND is_synthetic IS TRUE`,
+ [input.applicationScopeId]
+ );
+ const selected = scope.rows[0];
+ if (!selected || (!manual && (!selected.guide_id || !selected.studio_id)))
+ throw new Error('Synthetic application scope is unavailable.');
+ const guideId = manual ? input.access.guideId : selected.guide_id!;
+ const studioId = manual ? input.access.studioId : selected.studio_id!;
+ const workerId = manual ? null : input.scheduler.workerId;
+ const authUserId = manual ? input.access.authUserId : null;
+ const result = await client.query(
+ `INSERT INTO board_refresh_request (
+ application_scope_id, guide_id, studio_id, board_date, trigger,
+ requested_by_auth_user_id, requested_by_worker_id, state, requested_at
+ ) VALUES ($1, $2, $3, $4::date, $5::refresh_trigger, $6::uuid, $7, 'queued', now())
+ ON CONFLICT DO NOTHING
+ RETURNING id::text, state, requested_at, completed_at, resulting_board_run_id::text,
+ preserved_board_run_id::text, failure_code`,
+ [selected.id, guideId, studioId, input.boardDate, input.trigger, authUserId, workerId]
+ );
+ if (result.rows[0]) return result.rows[0];
+ const existing = await client.query(
+ `SELECT id::text, state, requested_at, completed_at, resulting_board_run_id::text,
+ preserved_board_run_id::text, failure_code
+ FROM board_refresh_request
+ WHERE application_scope_id = $1::bigint AND board_date = $2::date
+ AND (state IN ('queued','running') OR ($3::refresh_trigger = 'nightly' AND trigger = 'nightly'))
+ ORDER BY requested_at DESC LIMIT 1`,
+ [selected.id, input.boardDate, input.trigger]
+ );
+ if (!existing.rows[0]) throw new Error('Refresh request could not be deduplicated.');
+ return existing.rows[0];
+}
+
+/** Compare-and-set claim: supplied guide/scope are never inputs to this operation. */
+export async function claimRefresh(
+ workerId: string,
+ requestId: string,
+ leaseMs: number,
+ client: DbQueryable = pool
+): Promise {
+ const result = await client.query(
+ `UPDATE board_refresh_request
+ SET state = 'running', started_at = now(), heartbeat_at = now(),
+ claimed_by_worker_id = $2,
+ lease_expires_at = now() + ($3::bigint * interval '1 millisecond')
+ WHERE id = $1::bigint AND state = 'queued'
+ RETURNING id::text, claimed_by_worker_id, application_scope_id::text, guide_id::text, studio_id,
+ board_date::text, trigger::text, lease_expires_at`,
+ [requestId, workerId, leaseMs]
+ );
+ return result.rows[0] ?? null;
+}
+export async function heartbeatRefresh(
+ workerId: string,
+ requestId: string,
+ leaseMs: number,
+ client: DbQueryable = pool
+): Promise {
+ const result = await client.query<{ id: string }>(
+ `UPDATE board_refresh_request
+ SET heartbeat_at = now(), lease_expires_at = now() + ($3::bigint * interval '1 millisecond')
+ WHERE id = $1::bigint AND state = 'running'
+ AND claimed_by_worker_id = $2
+ AND lease_expires_at > now()
+ RETURNING id::text`,
+ [requestId, workerId, leaseMs]
+ );
+ return result.rows.length === 1;
+}
+export async function reapExpiredRefreshes(client: DbQueryable = pool): Promise {
+ const result = await client.query<{ id: string }>(
+ `UPDATE board_refresh_request request
+ SET state = 'failed', completed_at = now(), failure_code = 'worker-timeout',
+ preserved_board_run_id = (
+ SELECT head.current_board_run_id FROM board_head head
+ WHERE head.application_scope_id = request.application_scope_id
+ AND head.board_date <= request.board_date
+ ORDER BY head.board_date DESC LIMIT 1
+ )
+ WHERE request.state = 'running' AND request.lease_expires_at <= now()
+ RETURNING request.id::text AS id`
+ );
+ return result.rows.map((row) => row.id);
+}
+export async function enqueueNightlyRefreshes(
+ boardDate: string,
+ workerId: string,
+ client: DbQueryable = pool
+): Promise {
+ await client.query(
+ `INSERT INTO board_refresh_request (
+ application_scope_id,guide_id,studio_id,board_date,trigger,requested_by_worker_id,state,
+ requested_at
+ )
+ SELECT access_scope.id,access_scope.guide_id,access_scope.studio_id,$1::date,'nightly',
+ $2,'queued',clock_timestamp()
+ FROM guide_auth_scope access_scope
+ JOIN guide g ON g.id=access_scope.guide_id
+ WHERE access_scope.is_synthetic IS TRUE AND g.is_synthetic IS TRUE
+ ORDER BY access_scope.id
+ ON CONFLICT DO NOTHING`,
+ [boardDate, workerId]
+ );
+ const queued = await client.query(
+ `SELECT id::text,application_scope_id::text
+ FROM board_refresh_request
+ WHERE board_date=$1::date AND trigger='nightly' AND state='queued'
+ ORDER BY application_scope_id,id`,
+ [boardDate]
+ );
+ return queued.rows;
+}
+export async function failRefresh(
+ requestId: string,
+ failureCode: NonNullable,
+ client: DbQueryable = pool
+): Promise {
+ const result = await client.query<{ preserved_board_run_id: string | null }>(
+ `UPDATE board_refresh_request request
+ SET state = 'failed', completed_at = now(), failure_code = $2::refresh_failure_code,
+ preserved_board_run_id = (
+ SELECT head.current_board_run_id FROM board_head head
+ WHERE head.application_scope_id = request.application_scope_id
+ AND head.board_date <= request.board_date
+ ORDER BY head.board_date DESC LIMIT 1
+ )
+ WHERE request.id = $1::bigint AND request.state = 'running'
+ RETURNING preserved_board_run_id::text`,
+ [requestId, failureCode]
+ );
+ return result.rows[0]?.preserved_board_run_id ?? null;
+}
diff --git a/packages/db/src/scoped.ts b/packages/db/src/scoped.ts
index 80ef78f..9575035 100644
--- a/packages/db/src/scoped.ts
+++ b/packages/db/src/scoped.ts
@@ -1,4 +1,5 @@
-import type { RootCause } from '@huddle/core';
+import type { EvidenceBundle, RootCause } from '@huddle/core';
+import type { BoardView, RefreshView } from '@huddle/application';
import type { PoolClient, QueryResultRow } from 'pg';
import { pool } from './client.js';
@@ -15,6 +16,10 @@ export interface StudentRow {
first_name: string;
grade: number;
}
+export interface SyntheticRosterRow {
+ id: string;
+ pseudonymous_ref: string;
+}
/** An application-owned authorization scope resolved from a verified Supabase Auth user. */
export interface GuideScope {
@@ -42,6 +47,12 @@ export interface MasteryRow {
attempt_count: number | string;
is_known: boolean;
}
+export interface ExactEvidenceRow {
+ boardRunId: string;
+ triageEntryId: string;
+ findingFingerprint: string;
+ evidence: EvidenceBundle;
+}
export async function getStudentsForGuide(
guideId: string,
@@ -88,6 +99,49 @@ export async function getGuideScopeForAuthUser(
return result.rows.length === 1 ? result.rows[0]! : null;
}
+/** Fixed roster reference lookup used only by the synthetic importer after trusted scope resolution. */
+export async function getSyntheticRosterForGuide(
+ scope: GuideScope,
+ client: Queryable = pool
+): Promise {
+ const result = await client.query(
+ `SELECT s.id::text, s.pseudonymous_ref
+ FROM student s
+ JOIN guide_auth_scope access_scope ON access_scope.guide_id = s.guide_id
+ WHERE s.guide_id = $1::uuid AND access_scope.studio_id = $2
+ AND s.is_synthetic IS TRUE AND access_scope.is_synthetic IS TRUE
+ AND s.pseudonymous_ref IS NOT NULL
+ ORDER BY s.pseudonymous_ref`,
+ [scope.guideId, scope.studioId]
+ );
+ return result.rows;
+}
+
+/** Exact immutable bundle retrieval after trusted synthetic guide/studio scope resolution. */
+export async function getExactEvidenceForGuide(
+ scope: GuideScope,
+ triageEntryId: string,
+ client: Queryable = pool
+): Promise {
+ const result = await client.query(
+ `SELECT run.id::text AS "boardRunId", entry.id::text AS "triageEntryId",
+ entry.finding_fingerprint AS "findingFingerprint", signal.evidence
+ FROM triage_entry entry
+ JOIN signal ON signal.id=entry.dominant_signal_id AND signal.board_run_id=entry.board_run_id
+ JOIN board_run run ON run.id=entry.board_run_id
+ JOIN guide_auth_scope access_scope ON access_scope.id=run.application_scope_id
+ JOIN guide g ON g.id=run.guide_id
+ JOIN student s ON s.id=entry.student_id AND s.guide_id=run.guide_id
+ WHERE entry.id=$1::bigint AND run.guide_id=$2::uuid AND run.studio_id=$3
+ AND access_scope.guide_id=run.guide_id AND access_scope.studio_id=run.studio_id
+ AND access_scope.is_synthetic IS TRUE AND g.is_synthetic IS TRUE
+ AND s.is_synthetic IS TRUE
+ LIMIT 1`,
+ [triageEntryId, scope.guideId, scope.studioId]
+ );
+ return result.rows[0] ?? null;
+}
+
/** The only board retrieval path: resolved guide/studio scope first, then board data. */
export async function getTriageBoardForGuide(
scope: GuideScope,
@@ -105,22 +159,23 @@ export async function getTriageBoardForGuide(
COALESCE(sk.id || ' — ' || sk.name, '—') AS "skillName",
te.diagnosis,
te.opener,
- te.generated_at AS "generatedAt",
+ te.language_rendered_at AS "generatedAt",
te.additional_causes AS "additionalCauses"
- FROM triage_entry te
- JOIN signal sig ON sig.id = te.dominant_signal_id
- JOIN student s ON s.id = sig.student_id
+ FROM board_head head
+ JOIN board_run run ON run.id = head.current_board_run_id
+ JOIN triage_entry te ON te.board_run_id = run.id
+ JOIN signal sig ON sig.id = te.dominant_signal_id AND sig.board_run_id = run.id
+ JOIN student s ON s.id = te.student_id
JOIN guide g ON g.id = s.guide_id
JOIN guide_auth_scope access_scope
- ON access_scope.guide_id = s.guide_id
+ ON access_scope.id = head.application_scope_id AND access_scope.guide_id = s.guide_id
LEFT JOIN skill sk ON sk.id = sig.skill_id
WHERE s.guide_id = $1
AND access_scope.studio_id = $2
AND access_scope.is_synthetic IS TRUE
AND g.is_synthetic IS TRUE
AND s.is_synthetic IS TRUE
- AND te.board_date = $3::date
- AND te.legacy_non_displayable IS FALSE
+ AND head.board_date = $3::date
AND sig.kind <> 'fine'
ORDER BY te.rank ASC, s.id ASC
`,
@@ -129,6 +184,191 @@ export async function getTriageBoardForGuide(
return result.rows;
}
+export async function getBoardViewForGuide(
+ scope: GuideScope,
+ requestedBoardDate: string,
+ client: Queryable = pool
+): Promise {
+ const refreshResult = await client.query<{
+ id: string;
+ state: 'queued' | 'running' | 'succeeded' | 'failed';
+ requested_at: Date;
+ completed_at: Date | null;
+ resulting_board_run_id: string | null;
+ preserved_board_run_id: string | null;
+ failure_code: string | null;
+ }>(
+ `SELECT request.id::text,request.state,request.requested_at,request.completed_at,
+ request.resulting_board_run_id::text,request.preserved_board_run_id::text,
+ request.failure_code::text
+ FROM board_refresh_request request
+ JOIN guide_auth_scope access_scope ON access_scope.id=request.application_scope_id
+ JOIN guide g ON g.id=request.guide_id
+ WHERE request.guide_id=$1::uuid AND request.studio_id=$2
+ AND request.board_date=$3::date
+ AND access_scope.is_synthetic IS TRUE AND g.is_synthetic IS TRUE
+ ORDER BY request.requested_at DESC,request.id DESC LIMIT 1`,
+ [scope.guideId, scope.studioId, requestedBoardDate]
+ );
+ const refresh = toRefreshView(refreshResult.rows[0]);
+ const headResult = await client.query<{
+ id: string;
+ board_date: string;
+ as_of: Date;
+ completed_at: Date;
+ input_receipt_set_fingerprint: string;
+ }>(
+ `SELECT run.id::text,run.board_date::text,run.as_of,run.completed_at,
+ run.input_receipt_set_fingerprint
+ FROM board_head head
+ JOIN board_run run ON run.id=head.current_board_run_id
+ JOIN guide_auth_scope access_scope ON access_scope.id=head.application_scope_id
+ JOIN guide g ON g.id=head.guide_id
+ WHERE head.guide_id=$1::uuid AND head.studio_id=$2
+ AND head.board_date <= $3::date
+ AND access_scope.is_synthetic IS TRUE AND g.is_synthetic IS TRUE
+ ORDER BY head.board_date DESC LIMIT 1`,
+ [scope.guideId, scope.studioId, requestedBoardDate]
+ );
+ const head = headResult.rows[0];
+ if (!head) return { kind: 'not-built', refresh };
+ const entriesResult = await client.query<{
+ triage_entry_id: string;
+ finding_fingerprint: string;
+ student_id: string;
+ first_name: string;
+ rank: number;
+ cause: RootCause;
+ skill_id: string | null;
+ skill_name: string | null;
+ severity: number;
+ final_confidence: number;
+ diagnosis: string;
+ opener: string;
+ narration_mode: 'generated' | 'deterministic_fallback';
+ narration_status: 'complete' | 'degraded';
+ narration_degraded_reason:
+ | 'pending'
+ | 'model-unavailable'
+ | 'timeout'
+ | 'provider-error'
+ | 'selection-invalid'
+ | 'grounding-rejected'
+ | 'stale-result'
+ | null;
+ catalog_version: string;
+ render_version: string;
+ additional_cause_count: number;
+ additional_causes: Array<{ cause: RootCause; severity: number }>;
+ }>(
+ `SELECT entry.id::text AS triage_entry_id,entry.finding_fingerprint,
+ student.id::text AS student_id,student.first_name,entry.rank,signal.kind AS cause,
+ signal.skill_id,skill.name AS skill_name,signal.severity,signal.final_confidence,
+ entry.diagnosis,entry.opener,entry.narration_mode,entry.narration_status,
+ entry.narration_degraded_reason,entry.catalog_version,entry.render_version,
+ jsonb_array_length(entry.additional_causes) AS additional_cause_count,
+ entry.additional_causes
+ FROM triage_entry entry
+ JOIN signal ON signal.id=entry.dominant_signal_id AND signal.board_run_id=entry.board_run_id
+ JOIN student ON student.id=entry.student_id
+ LEFT JOIN skill ON skill.id=signal.skill_id
+ WHERE entry.board_run_id=$1::bigint AND student.guide_id=$2::uuid
+ AND student.is_synthetic IS TRUE
+ ORDER BY entry.rank,student.id`,
+ [head.id, scope.guideId]
+ );
+ const entries = entriesResult.rows.map((row) => ({
+ triageEntryId: row.triage_entry_id,
+ findingFingerprint: row.finding_fingerprint,
+ student: { id: row.student_id, firstName: row.first_name },
+ rank: row.rank,
+ cause: row.cause,
+ scope: row.skill_id
+ ? {
+ kind: 'skill' as const,
+ skill: { code: row.skill_id, name: row.skill_name ?? row.skill_id },
+ }
+ : { kind: 'cross-skill' as const },
+ severity: row.severity,
+ finalConfidence: row.final_confidence,
+ diagnosis: row.diagnosis,
+ opener: row.opener,
+ narration: {
+ mode:
+ row.narration_mode === 'deterministic_fallback'
+ ? ('deterministic-fallback' as const)
+ : ('generated' as const),
+ status: row.narration_status,
+ degradedReason: row.narration_degraded_reason,
+ catalogVersion: row.catalog_version,
+ renderVersion: row.render_version,
+ },
+ acknowledgedAt: null,
+ additionalCauseCount: row.additional_cause_count,
+ additionalCauses: row.additional_causes,
+ }));
+ const boardDate = head.board_date;
+ return {
+ kind:
+ boardDate !== requestedBoardDate
+ ? 'stale'
+ : entries.length === 0
+ ? 'successful-empty'
+ : 'ready',
+ boardRunId: head.id,
+ requestedBoardDate,
+ boardDate,
+ asOf: head.as_of.toISOString(),
+ timezone: 'America/Chicago',
+ completedAt: head.completed_at.toISOString(),
+ inputReceiptSetFingerprint: head.input_receipt_set_fingerprint,
+ entries,
+ refresh,
+ narration: {
+ status: entries.some((entry) => entry.narration.status === 'degraded')
+ ? 'degraded'
+ : 'complete',
+ degradedCount: entries.filter((entry) => entry.narration.status === 'degraded').length,
+ },
+ };
+}
+
+function toRefreshView(
+ row:
+ | {
+ id: string;
+ state: 'queued' | 'running' | 'succeeded' | 'failed';
+ requested_at: Date;
+ completed_at: Date | null;
+ resulting_board_run_id: string | null;
+ preserved_board_run_id: string | null;
+ failure_code: string | null;
+ }
+ | undefined
+): RefreshView {
+ if (!row) return { state: 'idle' };
+ if (row.state === 'queued' || row.state === 'running')
+ return {
+ state: row.state,
+ requestId: row.id,
+ requestedAt: row.requested_at.toISOString(),
+ };
+ if (row.state === 'succeeded')
+ return {
+ state: 'succeeded',
+ requestId: row.id,
+ completedAt: row.completed_at!.toISOString(),
+ boardRunId: row.resulting_board_run_id!,
+ };
+ return {
+ state: 'failed',
+ requestId: row.id,
+ completedAt: row.completed_at!.toISOString(),
+ failureCode: row.failure_code as Extract['failureCode'],
+ preservedBoardRunId: row.preserved_board_run_id,
+ };
+}
+
export async function getMasteryRowsForGuide(
client: PoolClient,
guideId: string,
@@ -143,39 +383,3 @@ export async function getMasteryRowsForGuide(
);
return result.rows;
}
-
-export async function clearMasterySnapshotForGuide(
- client: PoolClient,
- boardDate: string,
- guideId: string
-): Promise {
- await client.query(
- `DELETE FROM mastery_snapshot
- WHERE board_date = $1::date
- AND student_id IN (SELECT id FROM student WHERE guide_id = $2)`,
- [boardDate, guideId]
- );
-}
-
-export async function clearBoardForGuide(
- client: PoolClient,
- boardDate: string,
- guideId: string
-): Promise {
- await client.query(
- `DELETE FROM triage_entry
- WHERE board_date = $1::date
- AND student_id IN (SELECT id FROM student WHERE guide_id = $2)`,
- [boardDate, guideId]
- );
- // The legacy nightly path anchors window_end to Chicago midnight, represented as a UTC instant.
- // Casting it to date would resolve against the server's session TimeZone and could leave the
- // previous run's signals orphaned.
- await client.query(
- `DELETE FROM signal
- WHERE student_id IN (SELECT id FROM student WHERE guide_id = $1)
- AND window_end >= ($2::date::timestamp AT TIME ZONE 'UTC')
- AND window_end < (($2::date + 1)::timestamp AT TIME ZONE 'UTC')`,
- [guideId, boardDate]
- );
-}
diff --git a/packages/db/test/mastery-equivalence.test.ts b/packages/db/test/mastery-equivalence.test.ts
index e51557d..8bf457d 100644
--- a/packages/db/test/mastery-equivalence.test.ts
+++ b/packages/db/test/mastery-equivalence.test.ts
@@ -1,8 +1,10 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import pg from 'pg';
import type { Pool, PoolClient } from 'pg';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
+const { Pool: PgPool } = pg;
const __dirname = dirname(fileURLToPath(import.meta.url));
describe.skipIf(process.env.RUN_DB_TESTS !== '1')('mastery equivalence', () => {
@@ -11,20 +13,26 @@ describe.skipIf(process.env.RUN_DB_TESTS !== '1')('mastery equivalence', () => {
const anchor = new Date('2026-07-28T23:59:59.999Z');
const boardDate = '2026-07-28';
const guideId = '10000000-0000-0000-0000-000000000001';
+ const authUserId = '10000000-0000-0000-0000-000000000002';
const studentId = '20000000-0000-0000-0000-000000000001';
+ let boardRunId: string;
beforeAll(async () => {
const database = await import('../src/client.js');
- pool = database.pool;
await database.runMigrations(join(__dirname, '../../../db/migrations'));
+ pool = new PgPool({
+ connectionString: process.env.DATABASE_ADMIN_URL ?? process.env.DATABASE_URL,
+ });
client = await pool.connect();
await client.query('BEGIN');
- await client.query('INSERT INTO guide (id, display_name) VALUES ($1, $2)', [
+ await client.query('INSERT INTO guide (id, display_name, is_synthetic) VALUES ($1, $2, true)', [
guideId,
'Equivalence Guide',
]);
await client.query(
- 'INSERT INTO student (id, guide_id, first_name, grade) VALUES ($1, $2, $3, $4)',
+ `INSERT INTO student
+ (id, guide_id, first_name, grade, is_synthetic, pseudonymous_ref)
+ VALUES ($1, $2, $3, $4, true, 'equivalence-student')`,
[studentId, guideId, 'Snapshot', 4]
);
await client.query(
@@ -33,35 +41,107 @@ describe.skipIf(process.env.RUN_DB_TESTS !== '1')('mastery equivalence', () => {
await client.query(
"INSERT INTO item (id, skill_id, difficulty, choices) VALUES ('test-item', 'TEST.4.1', 0.5, '[]'::jsonb)"
);
+ const scope = await client.query<{ id: string }>(
+ `INSERT INTO guide_auth_scope (auth_user_id, guide_id, studio_id, is_synthetic)
+ VALUES ($1, $2, 'equivalence-studio', true)
+ RETURNING id::text`,
+ [authUserId, guideId]
+ );
+ const applicationScopeId = scope.rows[0]!.id;
+ const importRun = await client.query<{ id: string }>(
+ `INSERT INTO import_run (
+ application_scope_id, guide_id, studio_id, requested_by_worker_id,
+ ingest_origin, adapter_id, adapter_version, source, original_file_name,
+ media_type, idempotency_key, payload_digest, is_synthetic, status,
+ received_at, validated_at, committed_at, received_count, accepted_count
+ )
+ VALUES (
+ $1::bigint, $2::uuid, 'equivalence-studio', 'equivalence-fixture',
+ 'fixture', 'equivalence-fixture', '1', 'equivalence-test', 'equivalence.csv',
+ 'text/csv', 'equivalence-fixture', repeat('0', 64), true, 'succeeded',
+ $3::timestamptz, $3::timestamptz, $3::timestamptz, 6, 6
+ )
+ RETURNING id::text`,
+ [applicationScopeId, guideId, anchor]
+ );
+ const importRunId = importRun.rows[0]!.id;
+ const learningSession = await client.query<{ id: string }>(
+ `INSERT INTO learning_session (
+ import_run_id, application_scope_id, student_id, source, source_session_id,
+ started_at, ended_at, timing_quality, ingested_at
+ )
+ VALUES (
+ $1::bigint, $2::bigint, $3::uuid, 'equivalence-test', 'equivalence-session',
+ $4::timestamptz - interval '7 days', $4::timestamptz, 'none', $4::timestamptz
+ )
+ RETURNING id::text`,
+ [importRunId, applicationScopeId, studentId, anchor]
+ );
+ const learningSessionId = learningSession.rows[0]!.id;
await client.query(
`
INSERT INTO attempt (
- student_id, skill_id, item_id, session_id, attempt_index,
- started_at, submitted_at, elapsed_ms, engaged_ms, session_total_ms,
- timing_quality, is_correct, answer_given, hints_used, source, source_event_id
+ import_run_id, application_scope_id, student_id, skill_id, item_id,
+ learning_session_id, attempt_index, started_at, submitted_at, elapsed_ms,
+ engaged_ms, timing_quality, timing_was_winsorized, is_correct, answer_given,
+ hints_used, source, source_event_id, ingested_at
)
SELECT
- $1, 'TEST.4.1', 'test-item',
- ('30000000-0000-0000-0000-' || lpad(n::text, 12, '0'))::uuid,
- 1,
- $2::timestamptz - n * interval '1 day' - interval '1 minute',
- $2::timestamptz - n * interval '1 day',
- 60000, 60000, NULL, 'engaged'::timing_quality,
- n IN (1, 2, 4), NULL, 0, 'equivalence-test', 'event-' || n
+ $1::bigint, $2::bigint, $3::uuid, 'TEST.4.1', 'test-item',
+ $4::bigint, n,
+ $5::timestamptz - n * interval '1 day' - interval '1 minute',
+ $5::timestamptz - n * interval '1 day',
+ 60000, 60000, 'engaged'::timing_quality, false,
+ n IN (1, 2, 4), NULL, 0, 'equivalence-test', 'event-' || n, $5::timestamptz
FROM generate_series(1, 6) AS n
`,
- [studentId, anchor]
+ [importRunId, applicationScopeId, studentId, learningSessionId, anchor]
+ );
+ const refreshRequest = await client.query<{ id: string }>(
+ `INSERT INTO board_refresh_request (
+ application_scope_id, guide_id, studio_id, board_date, trigger,
+ requested_by_worker_id, claimed_by_worker_id, state, requested_at,
+ started_at, heartbeat_at, lease_expires_at
+ )
+ VALUES (
+ $1::bigint, $2::uuid, 'equivalence-studio', $3::date, 'nightly',
+ 'equivalence-worker', 'equivalence-worker', 'running', $4::timestamptz,
+ $4::timestamptz, $4::timestamptz, $4::timestamptz + interval '5 minutes'
+ )
+ RETURNING id::text`,
+ [applicationScopeId, guideId, boardDate, anchor]
+ );
+ const boardRun = await client.query<{ id: string }>(
+ `INSERT INTO board_run (
+ refresh_request_id, application_scope_id, guide_id, studio_id, board_date,
+ trigger, timezone, as_of, window_start, window_end,
+ input_receipt_set_fingerprint, behavior_fingerprint, completed_at
+ )
+ VALUES (
+ $1::bigint, $2::bigint, $3::uuid, 'equivalence-studio', $4::date,
+ 'nightly', 'America/Chicago', $5::timestamptz,
+ $5::timestamptz - interval '28 days', $5::timestamptz,
+ repeat('0', 64), repeat('1', 64), $5::timestamptz
+ )
+ RETURNING id::text`,
+ [refreshRequest.rows[0]!.id, applicationScopeId, guideId, boardDate, anchor]
+ );
+ boardRunId = boardRun.rows[0]!.id;
+ await client.query(
+ `INSERT INTO board_run_import (board_run_id, application_scope_id, import_run_id)
+ VALUES ($1::bigint, $2::bigint, $3::bigint)`,
+ [boardRunId, applicationScopeId, importRunId]
);
await client.query(
`
INSERT INTO mastery_snapshot (
- board_date, student_id, skill_id, value, attempt_count, is_known
+ board_run_id, as_of, student_id, skill_id, value, attempt_count, is_known
)
- SELECT $1::date, student_id, skill_id, value, attempt_count, is_known
+ SELECT $1::bigint, $2::timestamptz, student_id, skill_id, value, attempt_count, is_known
FROM mastery_at($2::timestamptz)
- WHERE student_id = $3
+ WHERE student_id = $3::uuid
`,
- [boardDate, anchor, studentId]
+ [boardRunId, anchor, studentId]
);
});
@@ -84,7 +164,7 @@ describe.skipIf(process.env.RUN_DB_TESTS !== '1')('mastery equivalence', () => {
actual AS (
SELECT student_id, skill_id, value, attempt_count, is_known
FROM mastery_snapshot
- WHERE board_date = $3::date AND student_id = $2
+ WHERE board_run_id = $3::bigint AND student_id = $2
)
SELECT * FROM (
(SELECT * FROM expected EXCEPT SELECT * FROM actual)
@@ -92,7 +172,7 @@ describe.skipIf(process.env.RUN_DB_TESTS !== '1')('mastery equivalence', () => {
(SELECT * FROM actual EXCEPT SELECT * FROM expected)
) differences
`,
- [anchor, studentId, boardDate]
+ [anchor, studentId, boardRunId]
);
expect(rows).toEqual([]);
});
diff --git a/packages/db/test/operations-boundaries.test.ts b/packages/db/test/operations-boundaries.test.ts
new file mode 100644
index 0000000..24ed9c4
--- /dev/null
+++ b/packages/db/test/operations-boundaries.test.ts
@@ -0,0 +1,298 @@
+import type { ClaimedRefresh } from '@huddle/application';
+import type { SyntheticCsvAnalysis } from '@huddle/ingest';
+import type { QueryResultRow } from 'pg';
+import { describe, expect, it } from 'vitest';
+import { loadCompilerSnapshot } from '../src/boards.js';
+import {
+ commitImport,
+ getImportValidation,
+ type StoredImportValidation,
+ type TransactionalQueryable,
+} from '../src/imports.js';
+
+function mappedAnalysis(): SyntheticCsvAnalysis {
+ return {
+ payloadDigest: 'digest',
+ adapterVersion: '1',
+ committable: true,
+ counts: { received: 1, accepted: 1, duplicate: 0, unmapped: 0, rejected: 0 },
+ issues: [],
+ sessions: [
+ {
+ studentId: 'student',
+ sourceSessionId: 'session-source',
+ startedAt: new Date('2026-07-29T05:00:00.000Z'),
+ endedAt: new Date('2026-07-29T05:01:00.000Z'),
+ totalElapsedMs: null,
+ vendorAttemptCount: null,
+ timingQuality: 'none',
+ },
+ ],
+ acceptedAttempts: [
+ {
+ rowNumber: 2,
+ sourceEventId: 'event',
+ studentId: 'student',
+ studentRef: 'synthetic-student',
+ source: 'synthetic-csv-v1',
+ sourceSessionId: 'session-source',
+ sessionId: 'session-source',
+ sessionStartedAt: new Date('2026-07-29T05:00:00.000Z'),
+ sessionEndedAt: new Date('2026-07-29T05:01:00.000Z'),
+ sessionVendorAttemptCount: null,
+ skillId: 'TEKS.4.2A',
+ itemId: 'item',
+ attemptIndex: 1,
+ startedAt: new Date('2026-07-29T05:00:00.000Z'),
+ submittedAt: new Date('2026-07-29T05:00:10.000Z'),
+ elapsedMs: null,
+ engagedMs: null,
+ sessionTotalMs: null,
+ timingQuality: 'none',
+ timingWasWinsorized: false,
+ isCorrect: false,
+ answerGiven: null,
+ hintsUsed: 0,
+ },
+ ],
+ unmapped: [],
+ };
+}
+
+function unmappedAnalysis(): SyntheticCsvAnalysis {
+ return {
+ payloadDigest: 'digest',
+ adapterVersion: '1',
+ committable: true,
+ counts: { received: 1, accepted: 0, duplicate: 0, unmapped: 1, rejected: 0 },
+ issues: [
+ {
+ rowNumber: 2,
+ sourceEventId: 'event',
+ outcome: 'unmapped',
+ code: 'unmapped-skill',
+ field: 'vendor_skill',
+ safeDetail: 'The skill is not mapped by synthetic-csv-v1.',
+ },
+ ],
+ sessions: [],
+ acceptedAttempts: [],
+ unmapped: [
+ {
+ rowNumber: 2,
+ sourceEventId: 'event',
+ studentId: 'student',
+ pseudonymousStudentRef: 'synthetic-student',
+ vendorSkill: 'UNKNOWN',
+ receivedAt: new Date('2026-07-29T05:00:10.000Z'),
+ failureKind: 'unmapped_skill',
+ },
+ ],
+ };
+}
+
+function validationFor(analysis: SyntheticCsvAnalysis): StoredImportValidation {
+ return {
+ importId: '9',
+ applicationScopeId: '7',
+ access: { authUserId: 'auth', guideId: 'guide', studioId: 'studio' },
+ payloadDigest: 'digest',
+ adapterVersion: '1',
+ fileName: 'input.csv',
+ idempotencyKey: 'key',
+ analysis,
+ };
+}
+
+describe('Operations data boundaries', () => {
+ it('binds trusted synthetic scope into the first validation receipt query', async () => {
+ const calls: Array<{ text: string; values?: readonly unknown[] }> = [];
+ const client = {
+ async query(text: string, values?: readonly unknown[]) {
+ calls.push({ text, values });
+ return { rows: [] as T[] };
+ },
+ };
+ await getImportValidation(
+ '9',
+ { authUserId: 'auth', guideId: 'guide', studioId: 'studio' },
+ client
+ );
+ expect(calls).toHaveLength(1);
+ expect(calls[0]?.text).toMatch(/JOIN guide_auth_scope trusted_scope/);
+ expect(calls[0]?.text).toMatch(/trusted_scope\.auth_user_id = \$2::uuid/);
+ expect(calls[0]?.text).toMatch(/trusted_scope\.is_synthetic IS TRUE/);
+ expect(calls[0]?.text).toMatch(/receipt\.is_synthetic IS TRUE/);
+ expect(calls[0]?.values).toEqual(['9', 'auth', 'guide', 'studio']);
+ });
+
+ it('scopes attempt compilation to the claimed synthetic guide roster', async () => {
+ const calls: Array<{ text: string; values?: readonly unknown[] }> = [];
+ const client = {
+ async query(text: string, values?: readonly unknown[]) {
+ calls.push({ text, values });
+ return { rows: [] as T[] };
+ },
+ };
+ await loadCompilerSnapshot(
+ {
+ requestId: 'request',
+ workerId: 'worker',
+ applicationScopeId: '7',
+ guideId: 'guide',
+ studioId: 'studio',
+ boardDate: '2026-07-29',
+ trigger: 'manual',
+ leaseExpiresAt: '2026-07-29T06:00:00.000Z',
+ } satisfies ClaimedRefresh,
+ {
+ start: new Date('2026-07-22T05:00:00.000Z'),
+ end: new Date('2026-07-29T05:00:00.000Z'),
+ },
+ client as never
+ );
+ const attemptRead = calls.find((call) => /FROM attempt a/.test(call.text));
+ expect(attemptRead?.text).toMatch(
+ /JOIN student student_scope ON student_scope\.id=a\.student_id/
+ );
+ expect(attemptRead?.text).toMatch(/student_scope\.guide_id=\$2::uuid/);
+ expect(attemptRead?.text).toMatch(/student_scope\.is_synthetic IS TRUE/);
+ expect(attemptRead?.values).toEqual(['7', 'guide']);
+ });
+
+ it('treats a reused event in another resolved session as divergent', async () => {
+ const calls: Array<{ text: string; values?: readonly unknown[] }> = [];
+ const client: TransactionalQueryable = {
+ async query(text: string, values?: readonly unknown[]) {
+ calls.push({ text, values });
+ if (/SELECT status::text, payload_digest/.test(text))
+ return { rows: [{ status: 'validated', payload_digest: 'digest' }] as unknown as T[] };
+ if (/FROM attempt\s+JOIN learning_session/.test(text))
+ return { rows: [{ id: 'attempt-1', same: false }] as unknown as T[] };
+ return { rows: [] as T[] };
+ },
+ };
+ const analysis = {
+ payloadDigest: 'digest',
+ adapterVersion: '1',
+ committable: true,
+ counts: { received: 1, accepted: 1, duplicate: 0, unmapped: 0, rejected: 0 },
+ issues: [],
+ sessions: [
+ {
+ studentId: 'student',
+ sourceSessionId: 'session-source',
+ startedAt: new Date('2026-07-29T05:00:00.000Z'),
+ endedAt: new Date('2026-07-29T05:01:00.000Z'),
+ totalElapsedMs: null,
+ vendorAttemptCount: null,
+ timingQuality: 'none',
+ },
+ ],
+ acceptedAttempts: [
+ {
+ rowNumber: 2,
+ sourceEventId: 'event',
+ studentId: 'student',
+ studentRef: 'synthetic-student',
+ source: 'synthetic-csv-v1',
+ sourceSessionId: 'session-source',
+ sessionId: 'session-source',
+ sessionStartedAt: new Date('2026-07-29T05:00:00.000Z'),
+ sessionEndedAt: new Date('2026-07-29T05:01:00.000Z'),
+ sessionVendorAttemptCount: null,
+ skillId: 'TEKS.4.2A',
+ itemId: 'item',
+ attemptIndex: 1,
+ startedAt: new Date('2026-07-29T05:00:00.000Z'),
+ submittedAt: new Date('2026-07-29T05:00:10.000Z'),
+ elapsedMs: null,
+ engagedMs: null,
+ sessionTotalMs: null,
+ timingQuality: 'none',
+ timingWasWinsorized: false,
+ isCorrect: false,
+ answerGiven: null,
+ hintsUsed: 0,
+ },
+ ],
+ unmapped: [],
+ } satisfies SyntheticCsvAnalysis;
+ const validation = {
+ importId: '9',
+ applicationScopeId: '7',
+ access: { authUserId: 'auth', guideId: 'guide', studioId: 'studio' },
+ payloadDigest: 'digest',
+ adapterVersion: '1',
+ fileName: 'input.csv',
+ idempotencyKey: 'key',
+ analysis,
+ } satisfies StoredImportValidation;
+
+ const receipt = await commitImport(validation, analysis, client);
+ const eventLookup = calls.find((call) =>
+ /FROM attempt\s+JOIN learning_session/.test(call.text)
+ );
+ expect(eventLookup?.text).toMatch(/stored_session\.source_session_id=\$4/);
+ expect(eventLookup?.values?.[3]).toBe('session-source');
+ const identityLocks = calls.filter((call) => /pg_advisory_xact_lock/.test(call.text));
+ expect(identityLocks.map((call) => call.values?.[0])).toEqual([
+ '["event","7","student","synthetic-csv-v1","event"]',
+ '["session","7","student","synthetic-csv-v1","session-source"]',
+ ]);
+ expect(calls.indexOf(identityLocks[1]!)).toBeLessThan(calls.indexOf(eventLookup!));
+ expect(calls.some((call) => /WITH inserted AS/.test(call.text))).toBe(false);
+ expect(receipt.counts).toMatchObject({ accepted: 0, rejected: 1 });
+ });
+
+ it('serializes unmapped events and reports the persisted winner as a duplicate', async () => {
+ const calls: Array<{ text: string; values?: readonly unknown[] }> = [];
+ const client: TransactionalQueryable = {
+ async query(text: string, values?: readonly unknown[]) {
+ calls.push({ text, values });
+ if (/SELECT status::text, payload_digest/.test(text))
+ return { rows: [{ status: 'validated', payload_digest: 'digest' }] as unknown as T[] };
+ if (/SELECT id::text,\s+vendor_skill=/.test(text))
+ return { rows: [{ id: 'unmapped-1', same: true }] as unknown as T[] };
+ return { rows: [] as T[] };
+ },
+ };
+ const analysis = unmappedAnalysis();
+
+ const receipt = await commitImport(validationFor(analysis), analysis, client);
+
+ expect(
+ calls
+ .filter((call) => /pg_advisory_xact_lock/.test(call.text))
+ .map((call) => call.values?.[0])
+ ).toEqual(['["event","7","student","synthetic-csv-v1","event"]']);
+ expect(calls.some((call) => /INSERT INTO unmapped_activity/.test(call.text))).toBe(false);
+ expect(receipt.counts).toMatchObject({ accepted: 0, duplicate: 1, unmapped: 0, rejected: 0 });
+ expect(receipt.issues).toEqual([
+ expect.objectContaining({ outcome: 'duplicate', code: 'stored-duplicate' }),
+ ]);
+ });
+
+ it('rejects a mapped replay when the source event was retained as unmapped', async () => {
+ const calls: Array<{ text: string; values?: readonly unknown[] }> = [];
+ const client: TransactionalQueryable = {
+ async query(text: string, values?: readonly unknown[]) {
+ calls.push({ text, values });
+ if (/SELECT status::text, payload_digest/.test(text))
+ return { rows: [{ status: 'validated', payload_digest: 'digest' }] as unknown as T[] };
+ if (/SELECT id::text FROM unmapped_activity/.test(text))
+ return { rows: [{ id: 'unmapped-1' }] as unknown as T[] };
+ return { rows: [] as T[] };
+ },
+ };
+ const analysis = mappedAnalysis();
+
+ const receipt = await commitImport(validationFor(analysis), analysis, client);
+
+ expect(calls.some((call) => /INSERT INTO attempt/.test(call.text))).toBe(false);
+ expect(receipt.counts).toMatchObject({ accepted: 0, duplicate: 0, unmapped: 0, rejected: 1 });
+ expect(receipt.issues).toEqual([
+ expect.objectContaining({ outcome: 'rejected', code: 'divergent-stored-event' }),
+ ]);
+ });
+});
diff --git a/packages/db/test/operations-migrations.test.ts b/packages/db/test/operations-migrations.test.ts
new file mode 100644
index 0000000..140f80a
--- /dev/null
+++ b/packages/db/test/operations-migrations.test.ts
@@ -0,0 +1,67 @@
+import { readFile } from 'node:fs/promises';
+import { fileURLToPath } from 'node:url';
+import { describe, expect, it } from 'vitest';
+
+const migration8 = fileURLToPath(
+ new URL('../../../db/migrations/008_import_receipt_session.sql', import.meta.url)
+);
+const migration9 = fileURLToPath(
+ new URL('../../../db/migrations/009_board_run_head.sql', import.meta.url)
+);
+
+describe('Operations migration contract', () => {
+ it('orders receipt/session lineage before immutable board publication and keeps raw rejected rows out', async () => {
+ const [eight, nine] = await Promise.all([
+ readFile(migration8, 'utf8'),
+ readFile(migration9, 'utf8'),
+ ]);
+ expect(eight).toContain('ALTER TABLE attempt RENAME TO legacy_attempt');
+ expect(eight).toContain('ALTER SEQUENCE attempt_id_seq RENAME TO legacy_attempt_id_seq');
+ expect(eight).toContain('CREATE TABLE import_run');
+ expect(eight).toContain('CREATE TABLE learning_session');
+ expect(eight).toContain('CREATE TABLE unmapped_activity');
+ expect(eight).not.toMatch(/raw_synthetic_fixture|raw_csv|payload_bytes/i);
+ expect(nine).toContain('CREATE TABLE board_refresh_request');
+ expect(nine).toContain('lease_expires_at');
+ expect(nine).toContain('CREATE UNIQUE INDEX one_active_refresh');
+ expect(nine).toContain('CREATE TABLE board_run');
+ expect(nine).toContain('CREATE TABLE board_head');
+ expect(nine).toContain('ALTER SEQUENCE signal_id_seq RENAME TO legacy_signal_id_seq');
+ expect(nine).toContain(
+ 'ALTER SEQUENCE triage_entry_id_seq RENAME TO legacy_triage_entry_id_seq'
+ );
+ expect(nine).toContain('CREATE ROLE huddle_app NOINHERIT LOGIN');
+ expect(nine).toContain('REVOKE ALL ON SCHEMA auth FROM huddle_app');
+ expect(nine).toMatch(
+ /IF EXISTS \(SELECT 1 FROM pg_namespace WHERE nspname = 'auth'\)[\s\S]*REVOKE ALL ON SCHEMA auth FROM huddle_app/
+ );
+ expect(nine).toMatch(/GRANT INSERT ON import_issue[\s\S]*board_run[\s\S]*triage_entry/);
+ expect(nine).toMatch(
+ /REVOKE INSERT, UPDATE, DELETE ON import_issue[\s\S]*attempt[\s\S]*triage_entry/
+ );
+ expect(nine).toMatch(/GRANT UPDATE \(status,[\s\S]*ON import_run TO huddle_app/);
+ expect(nine).toMatch(/GRANT UPDATE \(state,[\s\S]*ON board_refresh_request TO huddle_app/);
+ expect(nine).toContain('claimed_by_worker_id');
+ expect(nine).not.toContain('claimed_at');
+ });
+
+ it('makes runs/heads immutable and requires deterministic fallback text before publication', async () => {
+ const nine = await readFile(migration9, 'utf8');
+ expect(nine).toMatch(
+ /CREATE TABLE board_run[\s\S]*input_receipt_set_fingerprint[\s\S]*behavior_fingerprint/
+ );
+ expect(nine).toMatch(
+ /CREATE TABLE triage_entry[\s\S]*diagnosis text NOT NULL[\s\S]*opener text NOT NULL/
+ );
+ expect(nine).toMatch(
+ /CREATE TABLE signal[\s\S]*fallback_narration_selection jsonb NOT NULL[\s\S]*fallback_opener text NOT NULL/
+ );
+ expect(nine).toMatch(
+ /narration_mode = 'deterministic_fallback'[\s\S]*narration_status = 'degraded'/
+ );
+ expect(nine).toContain(
+ 'FOREIGN KEY (current_board_run_id, application_scope_id, guide_id, studio_id, board_date)'
+ );
+ expect(nine).toContain('ALTER TABLE mastery_snapshot RENAME TO legacy_mastery_snapshot');
+ });
+});
diff --git a/packages/db/test/scoped-access.test.ts b/packages/db/test/scoped-access.test.ts
index 66d6784..16e4305 100644
--- a/packages/db/test/scoped-access.test.ts
+++ b/packages/db/test/scoped-access.test.ts
@@ -3,10 +3,15 @@ import { describe, expect, it } from 'vitest';
import { readdir, readFile } from 'node:fs/promises';
import { dirname, join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
-import { getGuideScopeForAuthUser, getTriageBoardForGuide } from '../src/scoped.js';
+import {
+ getBoardViewForGuide,
+ getExactEvidenceForGuide,
+ getGuideScopeForAuthUser,
+ getTriageBoardForGuide,
+} from '../src/scoped.js';
const root = join(dirname(fileURLToPath(import.meta.url)), '../../..');
-const scopedQueryFile = 'packages/db/src/scoped.ts';
+const scopedQueryFiles = new Set(['packages/db/src/scoped.ts', 'packages/db/src/boards.ts']);
const studentRead = /\b(?:FROM|JOIN)\s+student\b/i;
async function sourceFiles(directory: string): Promise {
@@ -30,7 +35,8 @@ describe('guide-scoped student access', () => {
const violations: string[] = [];
for (const file of files) {
const repoPath = relative(root, file);
- if (repoPath === scopedQueryFile) continue;
+ if (repoPath.includes('/test/') || repoPath.endsWith('.test.ts')) continue;
+ if (scopedQueryFiles.has(repoPath)) continue;
if (studentRead.test(await readFile(file, 'utf8'))) violations.push(repoPath);
}
expect(violations).toEqual([]);
@@ -75,7 +81,76 @@ describe('guide-scoped student access', () => {
expect(calls[0]?.text).toMatch(/g\.is_synthetic IS TRUE/);
expect(calls[0]?.text).toMatch(/s\.is_synthetic IS TRUE/);
expect(calls[0]?.text).toMatch(/te\.additional_causes AS "additionalCauses"/);
- expect(calls[0]?.text).toMatch(/te\.legacy_non_displayable IS FALSE/);
+ expect(calls[0]?.text).toMatch(/FROM board_head head/);
+ expect(calls[0]?.text).toMatch(/JOIN board_run run/);
expect(calls[0]?.values).toEqual(['guide-a', 'synthetic-huddle-demo', '2026-07-28']);
});
+
+ it('binds entry, guide, and studio scope inside exact evidence retrieval', async () => {
+ const calls: Array<{ text: string; values: readonly unknown[] | undefined }> = [];
+ await expect(
+ getExactEvidenceForGuide(
+ { guideId: 'guide-a', studioId: 'synthetic-huddle-demo' },
+ 'entry-a',
+ {
+ async query(text: string, values?: readonly unknown[]) {
+ calls.push({ text, values });
+ return { rows: [] as T[] };
+ },
+ }
+ )
+ ).resolves.toBeNull();
+
+ expect(calls[0]?.text).toMatch(/entry\.id=\$1::bigint/);
+ expect(calls[0]?.text).toMatch(/run\.guide_id=\$2::uuid/);
+ expect(calls[0]?.text).toMatch(/run\.studio_id=\$3/);
+ expect(calls[0]?.text).toMatch(/access_scope\.is_synthetic IS TRUE/);
+ expect(calls[0]?.text).toMatch(/s\.is_synthetic IS TRUE/);
+ expect(calls[0]?.values).toEqual(['entry-a', 'guide-a', 'synthetic-huddle-demo']);
+ });
+
+ it('returns the prior successful head as stale after the requested date fails', async () => {
+ let queryIndex = 0;
+ const view = await getBoardViewForGuide(
+ { guideId: 'guide-a', studioId: 'synthetic-huddle-demo' },
+ '2026-07-29',
+ {
+ async query() {
+ const rows =
+ queryIndex === 0
+ ? [
+ {
+ id: 'request',
+ state: 'failed',
+ requested_at: new Date('2026-07-29T05:00:00.000Z'),
+ completed_at: new Date('2026-07-29T05:01:00.000Z'),
+ resulting_board_run_id: null,
+ preserved_board_run_id: 'run',
+ failure_code: 'compile-failed',
+ },
+ ]
+ : queryIndex === 1
+ ? [
+ {
+ id: 'run',
+ board_date: '2026-07-28',
+ as_of: new Date('2026-07-28T05:00:00.000Z'),
+ completed_at: new Date('2026-07-28T05:01:00.000Z'),
+ input_receipt_set_fingerprint: 'a'.repeat(64),
+ },
+ ]
+ : [];
+ queryIndex += 1;
+ return { rows: rows as unknown as T[] };
+ },
+ }
+ );
+ expect(view).toMatchObject({
+ kind: 'stale',
+ boardRunId: 'run',
+ boardDate: '2026-07-28',
+ requestedBoardDate: '2026-07-29',
+ refresh: { state: 'failed', preservedBoardRunId: 'run' },
+ });
+ });
});
diff --git a/packages/db/tsconfig.json b/packages/db/tsconfig.json
index 0d03902..d4d93b7 100644
--- a/packages/db/tsconfig.json
+++ b/packages/db/tsconfig.json
@@ -6,5 +6,5 @@
"composite": true
},
"include": ["src/**/*.ts", "test/**/*.ts"],
- "references": [{ "path": "../core" }]
+ "references": [{ "path": "../core" }, { "path": "../ingest" }, { "path": "../application" }]
}
diff --git a/packages/db/vitest.config.ts b/packages/db/vitest.config.ts
index c31d5af..e30f547 100644
--- a/packages/db/vitest.config.ts
+++ b/packages/db/vitest.config.ts
@@ -6,6 +6,10 @@ export default defineConfig({
globals: false,
env: {
DATABASE_URL: process.env.DATABASE_URL ?? 'postgresql://localhost:5432/huddle_test',
+ DATABASE_ADMIN_URL:
+ process.env.DATABASE_ADMIN_URL ??
+ process.env.DATABASE_URL ??
+ 'postgresql://localhost:5432/huddle_test',
},
},
});
diff --git a/packages/ingest/src/index.ts b/packages/ingest/src/index.ts
index 37654ad..8da4ebb 100644
--- a/packages/ingest/src/index.ts
+++ b/packages/ingest/src/index.ts
@@ -1,3 +1,21 @@
// Winsorization is the currently implemented ingest-boundary primitive (T019).
export { winsorize } from './winsorize.js';
export type { WinsorizedAttempt } from './winsorize.js';
+export {
+ analyzeSyntheticCsv,
+ MAX_IMPORT_ISSUES,
+ MAX_SYNTHETIC_CSV_BYTES,
+ MAX_SYNTHETIC_CSV_ROWS,
+ SYNTHETIC_CSV_COLUMNS,
+ SYNTHETIC_CSV_V1,
+} from './synthetic-csv-v1.js';
+export type {
+ AcceptedAttemptDraft,
+ SafeImportIssue,
+ SessionDraft,
+ SyntheticCsvAnalysis,
+ SyntheticCsvCounts,
+ SyntheticCsvFile,
+ SyntheticCsvReferences,
+ UnmappedActivityDraft,
+} from './synthetic-csv-v1.js';
diff --git a/packages/ingest/src/synthetic-csv-v1.ts b/packages/ingest/src/synthetic-csv-v1.ts
new file mode 100644
index 0000000..7778277
--- /dev/null
+++ b/packages/ingest/src/synthetic-csv-v1.ts
@@ -0,0 +1,646 @@
+import { createHash } from 'node:crypto';
+import type { Item, TimingQuality } from '@huddle/core';
+import { winsorize, type WinsorizedAttempt } from './winsorize.js';
+
+export const SYNTHETIC_CSV_V1 = 'synthetic-csv-v1';
+export const MAX_SYNTHETIC_CSV_BYTES = 5 * 1024 * 1024;
+export const MAX_SYNTHETIC_CSV_ROWS = 10_000;
+export const MAX_IMPORT_ISSUES = 100;
+
+export const SYNTHETIC_CSV_COLUMNS = [
+ 'dataset_kind',
+ 'source_event_id',
+ 'pseudonymous_student_ref',
+ 'vendor_skill',
+ 'vendor_item_ref',
+ 'source_session_id',
+ 'session_started_at',
+ 'session_ended_at',
+ 'session_total_ms',
+ 'vendor_attempt_count',
+ 'attempt_index',
+ 'started_at',
+ 'submitted_at',
+ 'elapsed_ms',
+ 'engaged_ms',
+ 'timing_quality',
+ 'is_correct',
+ 'answer_given',
+ 'hints_used',
+] as const;
+
+export type SyntheticCsvColumn = (typeof SYNTHETIC_CSV_COLUMNS)[number];
+export type ImportOutcome = 'duplicate' | 'unmapped' | 'rejected';
+export interface SafeImportIssue {
+ rowNumber: number;
+ sourceEventId: string | null;
+ outcome: ImportOutcome;
+ code: string;
+ field: string | null;
+ safeDetail: string;
+}
+export interface SyntheticCsvCounts {
+ received: number;
+ accepted: number;
+ duplicate: number;
+ unmapped: number;
+ rejected: number;
+}
+export interface SyntheticCsvReferences {
+ students: ReadonlyMap;
+ skills: ReadonlySet;
+ items: ReadonlyMap;
+}
+export interface SyntheticCsvFile {
+ name: string;
+ mediaType: 'text/csv';
+ sizeBytes: number;
+ content: Uint8Array;
+}
+export interface SessionDraft {
+ studentId: string;
+ sourceSessionId: string;
+ startedAt: Date;
+ endedAt: Date;
+ totalElapsedMs: number | null;
+ vendorAttemptCount: number | null;
+ timingQuality: 'session_only' | 'none';
+}
+export interface AcceptedAttemptDraft extends WinsorizedAttempt {
+ studentId: string;
+ source: typeof SYNTHETIC_CSV_V1;
+ sourceSessionId: string;
+ sessionStartedAt: Date;
+ sessionEndedAt: Date;
+ sessionVendorAttemptCount: number | null;
+ rowNumber: number;
+}
+export interface UnmappedActivityDraft {
+ rowNumber: number;
+ sourceEventId: string;
+ studentId: string;
+ pseudonymousStudentRef: string;
+ vendorSkill: string;
+ receivedAt: Date;
+ failureKind: 'unmapped_skill';
+}
+export interface SyntheticCsvAnalysis {
+ payloadDigest: string;
+ adapterVersion: '1';
+ committable: boolean;
+ counts: SyntheticCsvCounts;
+ issues: SafeImportIssue[];
+ acceptedAttempts: AcceptedAttemptDraft[];
+ sessions: SessionDraft[];
+ unmapped: UnmappedActivityDraft[];
+}
+
+type ParsedRow = Record & { rowNumber: number };
+
+/**
+ * Parses only the bounded, fixed synthetic demo interchange. It never returns raw
+ * rejected cells; all user-facing details are controlled strings and field names.
+ */
+export function analyzeSyntheticCsv(
+ file: SyntheticCsvFile,
+ references: SyntheticCsvReferences
+): SyntheticCsvAnalysis {
+ const payloadDigest = createHash('sha256').update(file.content).digest('hex');
+ const initial: SyntheticCsvAnalysis = {
+ payloadDigest,
+ adapterVersion: '1',
+ committable: true,
+ counts: { received: 0, accepted: 0, duplicate: 0, unmapped: 0, rejected: 0 },
+ issues: [],
+ acceptedAttempts: [],
+ sessions: [],
+ unmapped: [],
+ };
+ if (file.mediaType !== 'text/csv' || !file.name.toLowerCase().endsWith('.csv')) {
+ return fatal(initial, 'unsupported-media-type', 'file', 'Only CSV files are accepted.');
+ }
+ if (file.sizeBytes !== file.content.byteLength) {
+ return fatal(
+ initial,
+ 'file-size-mismatch',
+ 'file',
+ 'The uploaded file size did not match its bytes.'
+ );
+ }
+ if (file.sizeBytes > MAX_SYNTHETIC_CSV_BYTES) {
+ return fatal(initial, 'file-too-large', 'file', 'The CSV exceeds the 5 MiB application limit.');
+ }
+
+ let text: string;
+ try {
+ text = new TextDecoder('utf-8', { fatal: true }).decode(file.content);
+ } catch {
+ return fatal(initial, 'invalid-utf8', 'file', 'The CSV must be valid UTF-8.');
+ }
+
+ const parsed = parseRfc4180(text);
+ if (!parsed.ok)
+ return fatal(initial, 'malformed-csv', 'file', 'The CSV is not valid RFC 4180 data.');
+ if (parsed.rows.length === 0)
+ return fatal(initial, 'missing-header', 'file', 'The CSV must include the required header.');
+ const header = parsed.rows[0]!;
+ if (
+ header.length !== SYNTHETIC_CSV_COLUMNS.length ||
+ header.some((value, index) => value !== SYNTHETIC_CSV_COLUMNS[index])
+ ) {
+ return fatal(
+ initial,
+ 'invalid-header',
+ 'header',
+ 'The CSV header must match synthetic-csv-v1 exactly.'
+ );
+ }
+ const dataRows = parsed.rows.slice(1);
+ if (dataRows.length > MAX_SYNTHETIC_CSV_ROWS) {
+ return fatal(
+ initial,
+ 'too-many-rows',
+ 'file',
+ 'The CSV exceeds the 10,000-row application limit.'
+ );
+ }
+ initial.counts.received = dataRows.length;
+
+ const validRows: ParsedRow[] = [];
+ for (let index = 0; index < dataRows.length; index += 1) {
+ const cells = dataRows[index]!;
+ const rowNumber = index + 2;
+ if (cells.length !== SYNTHETIC_CSV_COLUMNS.length) {
+ reject(
+ initial,
+ rowNumber,
+ null,
+ 'wrong-column-count',
+ null,
+ 'The row does not match the required column count.'
+ );
+ continue;
+ }
+ const row = Object.fromEntries(
+ SYNTHETIC_CSV_COLUMNS.map((column, columnIndex) => [column, cells[columnIndex]!.trim()])
+ ) as Record;
+ validRows.push({ ...row, rowNumber });
+ }
+
+ const eventGroups = groupBy(validRows, (row) =>
+ JSON.stringify([row.pseudonymous_student_ref, row.source_event_id])
+ );
+ const candidates: ParsedRow[] = [];
+ for (const rows of eventGroups.values()) {
+ const canonical = new Set(rows.map(canonicalEvent));
+ if (canonical.size !== 1) {
+ for (const row of rows) {
+ reject(
+ initial,
+ row.rowNumber,
+ row.source_event_id || null,
+ 'divergent-event-identity',
+ 'source_event_id',
+ 'Rows with this event identity disagree.'
+ );
+ }
+ continue;
+ }
+ candidates.push(rows[0]!);
+ for (const duplicate of rows.slice(1)) {
+ issue(
+ initial,
+ duplicate.rowNumber,
+ duplicate.source_event_id || null,
+ 'duplicate',
+ 'canonical-duplicate',
+ null,
+ 'An identical event row was collapsed.'
+ );
+ initial.counts.duplicate += 1;
+ }
+ }
+
+ const sessionGroups = groupBy(candidates, (row) =>
+ JSON.stringify([row.pseudonymous_student_ref, row.source_session_id])
+ );
+ const allowed = new Set();
+ for (const rows of sessionGroups.values()) {
+ const aggregates = new Set(rows.map(canonicalSession));
+ if (aggregates.size !== 1) {
+ for (const row of rows) {
+ reject(
+ initial,
+ row.rowNumber,
+ row.source_event_id || null,
+ 'divergent-session-aggregate',
+ 'source_session_id',
+ 'Rows in this session disagree about its aggregate.'
+ );
+ }
+ continue;
+ }
+ for (const row of rows) allowed.add(row);
+ }
+
+ for (const row of candidates) {
+ if (!allowed.has(row)) continue;
+ const parsedRow = validateRow(row, references);
+ if (!parsedRow.ok) {
+ if (parsedRow.outcome === 'unmapped') {
+ issue(
+ initial,
+ row.rowNumber,
+ row.source_event_id || null,
+ 'unmapped',
+ parsedRow.code,
+ parsedRow.field,
+ parsedRow.detail
+ );
+ initial.counts.unmapped += 1;
+ initial.unmapped.push({
+ rowNumber: row.rowNumber,
+ sourceEventId: row.source_event_id,
+ studentId: parsedRow.studentId,
+ pseudonymousStudentRef: row.pseudonymous_student_ref,
+ vendorSkill: row.vendor_skill,
+ receivedAt: parsedRow.submittedAt,
+ failureKind: 'unmapped_skill',
+ });
+ } else {
+ reject(
+ initial,
+ row.rowNumber,
+ row.source_event_id || null,
+ parsedRow.code,
+ parsedRow.field,
+ parsedRow.detail
+ );
+ }
+ continue;
+ }
+ initial.acceptedAttempts.push(parsedRow.attempt);
+ initial.counts.accepted += 1;
+ }
+
+ const sessions = groupBy(initial.acceptedAttempts, (attempt) =>
+ JSON.stringify([attempt.studentId, attempt.sourceSessionId])
+ );
+ for (const attempts of sessions.values()) {
+ const first = attempts[0]!;
+ initial.sessions.push({
+ studentId: first.studentId,
+ sourceSessionId: first.sourceSessionId,
+ startedAt: first.sessionStartedAt,
+ endedAt: first.sessionEndedAt,
+ totalElapsedMs: first.sessionTotalMs,
+ vendorAttemptCount: first.sessionVendorAttemptCount,
+ timingQuality: first.sessionTotalMs == null ? 'none' : 'session_only',
+ });
+ }
+ return initial;
+}
+
+function validateRow(
+ row: ParsedRow,
+ references: SyntheticCsvReferences
+):
+ | { ok: true; attempt: AcceptedAttemptDraft }
+ | {
+ ok: false;
+ outcome: 'rejected';
+ code: string;
+ field: string | null;
+ detail: string;
+ }
+ | {
+ ok: false;
+ outcome: 'unmapped';
+ code: string;
+ field: string | null;
+ detail: string;
+ submittedAt: Date;
+ studentId: string;
+ } {
+ if (row.dataset_kind !== 'synthetic')
+ return invalid(
+ 'non-synthetic-dataset',
+ 'dataset_kind',
+ 'Only the fixed synthetic dataset is accepted.'
+ );
+ if (!boundedIdentifier(row.pseudonymous_student_ref))
+ return invalid(
+ 'invalid-row-value',
+ 'pseudonymous_student_ref',
+ 'A required value has an invalid type.'
+ );
+ const student = references.students.get(row.pseudonymous_student_ref);
+ if (!student)
+ return invalid(
+ 'unknown-synthetic-student',
+ 'pseudonymous_student_ref',
+ 'The student reference is not in the fixed synthetic roster.'
+ );
+ const sourceEventId = boundedIdentifier(row.source_event_id);
+ const sourceSessionId = boundedIdentifier(row.source_session_id);
+ const attemptIndex = integer(row.attempt_index);
+ const startedAt = instant(row.started_at);
+ const submittedAt = instant(row.submitted_at);
+ const sessionStartedAt = instant(row.session_started_at);
+ const sessionEndedAt = instant(row.session_ended_at);
+ const elapsedMs = nullableInteger(row.elapsed_ms);
+ const engagedMs = nullableInteger(row.engaged_ms);
+ const sessionTotalMs = nullableInteger(row.session_total_ms);
+ const vendorAttemptCount = nullableInteger(row.vendor_attempt_count);
+ const hintsUsed = integer(row.hints_used);
+ const isCorrect = boolean(row.is_correct);
+ const quality = timingQuality(row.timing_quality);
+ if (
+ !sourceEventId ||
+ !sourceSessionId ||
+ attemptIndex == null ||
+ attemptIndex < 1 ||
+ !startedAt ||
+ !submittedAt ||
+ !sessionStartedAt ||
+ !sessionEndedAt ||
+ hintsUsed == null ||
+ hintsUsed < 0 ||
+ isCorrect == null ||
+ !quality
+ ) {
+ return invalid('invalid-row-value', 'row', 'A required value has an invalid type.');
+ }
+ if (
+ submittedAt < startedAt ||
+ sessionEndedAt < sessionStartedAt ||
+ elapsedMs === undefined ||
+ engagedMs === undefined ||
+ sessionTotalMs === undefined ||
+ vendorAttemptCount === undefined
+ ) {
+ return invalid('invalid-time-order', 'timing', 'The timing values are inconsistent.');
+ }
+ if (
+ (elapsedMs != null && elapsedMs < 0) ||
+ (engagedMs != null && engagedMs < 0) ||
+ (sessionTotalMs != null && sessionTotalMs < 0) ||
+ (vendorAttemptCount != null && vendorAttemptCount <= 0) ||
+ (elapsedMs != null && engagedMs != null && engagedMs > elapsedMs)
+ ) {
+ return invalid(
+ 'invalid-timing-value',
+ 'timing',
+ 'The timing values are outside the allowed range.'
+ );
+ }
+ if (!validTimingShape(quality, elapsedMs, engagedMs, sessionTotalMs, vendorAttemptCount)) {
+ return invalid(
+ 'invalid-timing-quality',
+ 'timing_quality',
+ 'The timing fields do not match the declared quality.'
+ );
+ }
+ if (!boundedVendorSkill(row.vendor_skill))
+ return invalid('invalid-row-value', 'vendor_skill', 'A required value has an invalid type.');
+ if (!references.skills.has(row.vendor_skill))
+ return unmapped(
+ 'unmapped-skill',
+ 'vendor_skill',
+ 'The skill is not mapped by synthetic-csv-v1.',
+ submittedAt,
+ student.id
+ );
+ const item = references.items.get(row.vendor_item_ref);
+ if (!item)
+ return invalid(
+ 'unknown-seeded-item',
+ 'vendor_item_ref',
+ 'The item is not in the seeded item bank.'
+ );
+ if (item.skillId !== row.vendor_skill)
+ return invalid(
+ 'item-skill-mismatch',
+ 'vendor_item_ref',
+ 'The item does not belong to the supplied skill.'
+ );
+ const answerGiven = row.answer_given === '' ? null : row.answer_given;
+ if (
+ item.itemType === 'multiple_choice' &&
+ answerGiven != null &&
+ !item.choices.some((choice) => choice.key === answerGiven)
+ ) {
+ return invalid(
+ 'invalid-answer-key',
+ 'answer_given',
+ 'The answer is not a valid choice for the seeded item.'
+ );
+ }
+ const attempt = winsorize(
+ {
+ sourceEventId,
+ studentRef: row.pseudonymous_student_ref,
+ skillId: item.skillId,
+ itemId: item.id,
+ sessionId: sourceSessionId,
+ attemptIndex,
+ startedAt,
+ submittedAt,
+ elapsedMs,
+ engagedMs,
+ sessionTotalMs,
+ timingQuality: quality,
+ timingWasWinsorized: false,
+ isCorrect,
+ answerGiven: answerGiven == null ? null : { key: answerGiven },
+ hintsUsed,
+ },
+ item.timingProfile
+ );
+ return {
+ ok: true,
+ attempt: {
+ ...attempt,
+ studentId: student.id,
+ source: SYNTHETIC_CSV_V1,
+ sourceSessionId,
+ sessionStartedAt,
+ sessionEndedAt,
+ sessionVendorAttemptCount: vendorAttemptCount,
+ rowNumber: row.rowNumber,
+ },
+ };
+}
+
+function validTimingShape(
+ quality: TimingQuality,
+ elapsed: number | null,
+ engaged: number | null,
+ sessionTotal: number | null,
+ vendorCount: number | null
+): boolean {
+ if (quality === 'engaged')
+ return elapsed != null && engaged != null && sessionTotal == null && vendorCount == null;
+ if (quality === 'wallclock')
+ return elapsed != null && engaged == null && sessionTotal == null && vendorCount == null;
+ if (quality === 'session_only')
+ return elapsed == null && engaged == null && sessionTotal != null && vendorCount != null;
+ return elapsed == null && engaged == null && sessionTotal == null && vendorCount == null;
+}
+function parseRfc4180(input: string): { ok: true; rows: string[][] } | { ok: false } {
+ const rows: string[][] = [];
+ let row: string[] = [];
+ let field = '';
+ let quoted = false;
+ for (let index = 0; index < input.length; index += 1) {
+ const character = input[index]!;
+ if (quoted) {
+ if (character === '"') {
+ if (input[index + 1] === '"') {
+ field += '"';
+ index += 1;
+ } else quoted = false;
+ } else field += character;
+ continue;
+ }
+ if (character === '"') {
+ if (field !== '') return { ok: false };
+ quoted = true;
+ } else if (character === ',') {
+ row.push(field);
+ field = '';
+ } else if (character === '\n') {
+ row.push(field);
+ rows.push(row);
+ row = [];
+ field = '';
+ } else if (character === '\r') {
+ if (input[index + 1] !== '\n') return { ok: false };
+ } else field += character;
+ }
+ if (quoted) return { ok: false };
+ if (field !== '' || row.length > 0) {
+ row.push(field);
+ rows.push(row);
+ }
+ return { ok: true, rows };
+}
+function groupBy(values: readonly T[], key: (value: T) => string): Map {
+ const groups = new Map();
+ for (const value of values) {
+ const group = groups.get(key(value));
+ if (group) group.push(value);
+ else groups.set(key(value), [value]);
+ }
+ return groups;
+}
+function canonicalEvent(row: ParsedRow): string {
+ const { rowNumber: _rowNumber, ...values } = row;
+ return JSON.stringify(
+ Object.keys(values)
+ .sort()
+ .map((key) => [key, values[key as SyntheticCsvColumn]])
+ );
+}
+function canonicalSession(row: ParsedRow): string {
+ return JSON.stringify([
+ row.session_started_at,
+ row.session_ended_at,
+ row.session_total_ms,
+ row.vendor_attempt_count,
+ ]);
+}
+function boundedIdentifier(value: string): string | null {
+ return /^[A-Za-z0-9:_-]{1,128}$/.test(value) ? value : null;
+}
+function boundedVendorSkill(value: string): string | null {
+ return /^[A-Za-z0-9._:-]{1,128}$/.test(value) ? value : null;
+}
+function integer(value: string): number | null {
+ if (!/^\d+$/.test(value)) return null;
+ const parsed = Number(value);
+ return Number.isSafeInteger(parsed) && parsed <= 2_147_483_647 ? parsed : null;
+}
+function nullableInteger(value: string): number | null | undefined {
+ return value === '' ? null : (integer(value) ?? undefined);
+}
+function instant(value: string): Date | null {
+ const match = value.match(
+ /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,3}))?(Z|[+-]\d{2}:\d{2})$/
+ );
+ if (!match) return null;
+ const parsed = new Date(value);
+ if (Number.isNaN(parsed.getTime())) return null;
+ const year = Number(match[1]);
+ const month = Number(match[2]);
+ const day = Number(match[3]);
+ // Calendar validation uses a UTC construction, not the host timezone.
+ const calendar = new Date(Date.UTC(year, month - 1, day));
+ return calendar.getUTCFullYear() === year &&
+ calendar.getUTCMonth() === month - 1 &&
+ calendar.getUTCDate() === day
+ ? parsed
+ : null;
+}
+function boolean(value: string): boolean | null {
+ return value === 'true' ? true : value === 'false' ? false : null;
+}
+function timingQuality(value: string): TimingQuality | null {
+ return ['engaged', 'wallclock', 'session_only', 'none'].includes(value)
+ ? (value as TimingQuality)
+ : null;
+}
+function invalid(code: string, field: string | null, detail: string) {
+ return { ok: false as const, outcome: 'rejected' as const, code, field, detail };
+}
+function unmapped(
+ code: string,
+ field: string | null,
+ detail: string,
+ submittedAt: Date,
+ studentId: string
+) {
+ return {
+ ok: false as const,
+ outcome: 'unmapped' as const,
+ code,
+ field,
+ detail,
+ submittedAt,
+ studentId,
+ };
+}
+function fatal(
+ analysis: SyntheticCsvAnalysis,
+ code: string,
+ field: string | null,
+ detail: string
+): SyntheticCsvAnalysis {
+ analysis.committable = false;
+ issue(analysis, 0, null, 'rejected', code, field, detail);
+ analysis.counts.rejected = 1;
+ return analysis;
+}
+function reject(
+ analysis: SyntheticCsvAnalysis,
+ rowNumber: number,
+ sourceEventId: string | null,
+ code: string,
+ field: string | null,
+ detail: string
+): void {
+ issue(analysis, rowNumber, sourceEventId, 'rejected', code, field, detail);
+ analysis.counts.rejected += 1;
+}
+function issue(
+ analysis: SyntheticCsvAnalysis,
+ rowNumber: number,
+ sourceEventId: string | null,
+ outcome: ImportOutcome,
+ code: string,
+ field: string | null,
+ safeDetail: string
+): void {
+ if (analysis.issues.length < MAX_IMPORT_ISSUES)
+ analysis.issues.push({ rowNumber, sourceEventId, outcome, code, field, safeDetail });
+}
diff --git a/packages/ingest/test/synthetic-csv-v1.test.ts b/packages/ingest/test/synthetic-csv-v1.test.ts
new file mode 100644
index 0000000..43eab8a
--- /dev/null
+++ b/packages/ingest/test/synthetic-csv-v1.test.ts
@@ -0,0 +1,207 @@
+import { describe, expect, it } from 'vitest';
+import { items, skills } from '@huddle/core/seed';
+import {
+ analyzeSyntheticCsv,
+ MAX_SYNTHETIC_CSV_BYTES,
+ SYNTHETIC_CSV_COLUMNS,
+ type SyntheticCsvFile,
+} from '../src/synthetic-csv-v1.js';
+
+const references = {
+ students: new Map([['student-01', { id: '00000000-0000-4000-8000-000000000001' }]]),
+ skills: new Set(skills.map((skill) => skill.id)),
+ items: new Map(items.map((item) => [item.id, item])),
+};
+const row = (overrides: Record = {}) => ({
+ dataset_kind: 'synthetic',
+ source_event_id: 'event-1',
+ pseudonymous_student_ref: 'student-01',
+ vendor_skill: 'TEKS.4.2A',
+ vendor_item_ref: 'mcq:TEKS.4.2A-01',
+ source_session_id: 'session-1',
+ session_started_at: '2026-07-27T15:00:00.000Z',
+ session_ended_at: '2026-07-27T15:01:00.000Z',
+ session_total_ms: '',
+ vendor_attempt_count: '',
+ attempt_index: '1',
+ started_at: '2026-07-27T15:00:00.000Z',
+ submitted_at: '2026-07-27T15:00:10.000Z',
+ elapsed_ms: '10000',
+ engaged_ms: '',
+ timing_quality: 'wallclock',
+ is_correct: 'true',
+ answer_given: 'A',
+ hints_used: '0',
+ ...overrides,
+});
+function file(rows: Array>): SyntheticCsvFile {
+ const csv = [
+ SYNTHETIC_CSV_COLUMNS.join(','),
+ ...rows.map((value) => SYNTHETIC_CSV_COLUMNS.map((key) => value[key] ?? '').join(',')),
+ ].join('\r\n');
+ const content = new TextEncoder().encode(csv);
+ return {
+ name: 'synthetic-huddle-demo.csv',
+ mediaType: 'text/csv',
+ sizeBytes: content.byteLength,
+ content,
+ };
+}
+
+describe('synthetic-csv-v1', () => {
+ it('accepts fixed seeded references and preserves session-only source counts once', () => {
+ const analysis = analyzeSyntheticCsv(
+ file([
+ row({
+ timing_quality: 'session_only',
+ elapsed_ms: '',
+ session_total_ms: '60000',
+ vendor_attempt_count: '3',
+ answer_given: 'A',
+ }),
+ ]),
+ references
+ );
+ expect(analysis.counts).toEqual({
+ received: 1,
+ accepted: 1,
+ duplicate: 0,
+ unmapped: 0,
+ rejected: 0,
+ });
+ expect(analysis.sessions).toEqual([
+ expect.objectContaining({
+ totalElapsedMs: 60000,
+ vendorAttemptCount: 3,
+ timingQuality: 'session_only',
+ }),
+ ]);
+ expect(analysis.acceptedAttempts[0]).toMatchObject({
+ elapsedMs: null,
+ timingQuality: 'session_only',
+ timingWasWinsorized: false,
+ });
+ });
+
+ it('rejects divergent identity groups independently of file order', () => {
+ const first = row({ source_event_id: 'same' });
+ const divergent = row({ source_event_id: 'same', answer_given: 'B' });
+ for (const rows of [
+ [first, divergent],
+ [divergent, first],
+ ]) {
+ const analysis = analyzeSyntheticCsv(file(rows), references);
+ expect(analysis.counts).toMatchObject({ received: 2, accepted: 0, rejected: 2 });
+ expect(analysis.issues.map((issue) => issue.code)).toEqual([
+ 'divergent-event-identity',
+ 'divergent-event-identity',
+ ]);
+ }
+ });
+
+ it('collapses canonical duplicates and rejects complete conflicting sessions', () => {
+ const identical = row({ source_event_id: 'event-2' });
+ const duplicate = row({ source_event_id: 'event-2' });
+ const accepted = analyzeSyntheticCsv(file([identical, duplicate]), references);
+ expect(accepted.counts).toMatchObject({ accepted: 1, duplicate: 1, rejected: 0 });
+
+ const conflicting = analyzeSyntheticCsv(
+ file([
+ row({ source_event_id: 'event-3' }),
+ row({
+ source_event_id: 'event-4',
+ session_total_ms: '5',
+ vendor_attempt_count: '1',
+ timing_quality: 'session_only',
+ elapsed_ms: '',
+ }),
+ ]),
+ references
+ );
+ expect(conflicting.counts).toMatchObject({ accepted: 0, rejected: 2 });
+ expect(conflicting.issues.map((issue) => issue.code)).toEqual([
+ 'divergent-session-aggregate',
+ 'divergent-session-aggregate',
+ ]);
+ });
+
+ it('proves synthetic references rather than trusting the marker', () => {
+ for (const [change, code] of [
+ [{ pseudonymous_student_ref: 'not-a-roster-member' }, 'unknown-synthetic-student'],
+ [{ vendor_item_ref: 'other-item' }, 'unknown-seeded-item'],
+ [{ vendor_skill: 'TEKS.4.2B' }, 'item-skill-mismatch'],
+ [{ answer_given: 'Z' }, 'invalid-answer-key'],
+ [{ dataset_kind: 'real' }, 'non-synthetic-dataset'],
+ ] as const) {
+ const analysis = analyzeSyntheticCsv(file([row(change)]), references);
+ expect(analysis.counts.accepted).toBe(0);
+ expect(analysis.issues[0]?.code).toBe(code);
+ }
+ });
+
+ it('enforces exact post-transport byte and row application limits', () => {
+ const content = new Uint8Array(MAX_SYNTHETIC_CSV_BYTES + 1);
+ const tooBig = analyzeSyntheticCsv(
+ { name: 'x.csv', mediaType: 'text/csv', sizeBytes: content.byteLength, content },
+ references
+ );
+ expect(tooBig.issues[0]?.code).toBe('file-too-large');
+ expect(tooBig.committable).toBe(false);
+ const rows = Array.from({ length: 10_001 }, (_, index) =>
+ row({ source_event_id: `event-${index}`, source_session_id: `session-${index}` })
+ );
+ expect(analyzeSyntheticCsv(file(rows), references).issues[0]?.code).toBe('too-many-rows');
+ });
+
+ it('rejects malformed and non-UTF-8 input without retaining cells', () => {
+ const invalidUtf8 = analyzeSyntheticCsv(
+ { name: 'x.csv', mediaType: 'text/csv', sizeBytes: 1, content: new Uint8Array([0xff]) },
+ references
+ );
+ expect(invalidUtf8.issues).toEqual([
+ expect.objectContaining({ code: 'invalid-utf8', safeDetail: 'The CSV must be valid UTF-8.' }),
+ ]);
+ expect(invalidUtf8.committable).toBe(false);
+ });
+
+ it('rejects identifiers and integers that cannot be persisted', () => {
+ const invalidIdentifier = analyzeSyntheticCsv(
+ file([row({ source_event_id: 'contains spaces' })]),
+ references
+ );
+ expect(invalidIdentifier.issues[0]?.code).toBe('invalid-row-value');
+ const overflow = analyzeSyntheticCsv(file([row({ attempt_index: '2147483648' })]), references);
+ expect(overflow.issues[0]?.code).toBe('invalid-row-value');
+ });
+
+ it('validates shared fields before retaining an unmapped skill', () => {
+ const invalid = analyzeSyntheticCsv(
+ file([row({ vendor_skill: 'UNKNOWN', source_event_id: 'contains spaces' })]),
+ references
+ );
+ expect(invalid.counts).toMatchObject({ unmapped: 0, rejected: 1 });
+ expect(invalid.issues[0]?.code).toBe('invalid-row-value');
+ expect(invalid.unmapped).toEqual([]);
+
+ const valid = analyzeSyntheticCsv(file([row({ vendor_skill: 'UNKNOWN' })]), references);
+ expect(valid.counts).toMatchObject({ unmapped: 1, rejected: 0 });
+ expect(valid.unmapped[0]).toMatchObject({
+ rowNumber: 2,
+ studentId: '00000000-0000-4000-8000-000000000001',
+ });
+ expect(valid.unmapped[0]?.receivedAt.toISOString()).toBe('2026-07-27T15:00:10.000Z');
+
+ for (const vendorSkill of ['contains spaces', 'x'.repeat(129)]) {
+ const invalidSkill = analyzeSyntheticCsv(
+ file([row({ vendor_skill: vendorSkill })]),
+ references
+ );
+ expect(invalidSkill.counts).toMatchObject({ unmapped: 0, rejected: 1 });
+ expect(invalidSkill.issues[0]).toMatchObject({
+ code: 'invalid-row-value',
+ field: 'vendor_skill',
+ });
+ expect(invalidSkill.unmapped).toEqual([]);
+ }
+ });
+});
diff --git a/packages/narrator/src/catalog.ts b/packages/narrator/src/catalog.ts
new file mode 100644
index 0000000..a505a40
--- /dev/null
+++ b/packages/narrator/src/catalog.ts
@@ -0,0 +1,349 @@
+import { createHash } from 'node:crypto';
+import type { EvidenceBundle, PermittedLanguageOption, RootCause } from '@huddle/core';
+import { normalizeIntegerToken, normalizeSkillCode } from '@huddle/core';
+
+export const FALLBACK_CATALOG_VERSION = 'operations-fallback-v1';
+export const FALLBACK_RENDER_VERSION = 'operations-render-v1';
+
+export interface OptionSelection {
+ id: string;
+ slotRefs: Record;
+}
+export interface NarrationSelection {
+ diagnosis: { propositions: OptionSelection[] };
+ opener: OptionSelection;
+}
+export interface DeterministicNarration {
+ bundle: EvidenceBundle;
+ selection: NarrationSelection;
+ diagnosis: string;
+ opener: string;
+ catalogVersion: string;
+ renderVersion: string;
+ languageFingerprint: string;
+ narrationFingerprint: string;
+}
+
+type CatalogOption = {
+ id: string;
+ template: string;
+ eligibleCause: RootCause;
+ slots: Record;
+};
+type CatalogEntry = {
+ diagnosis: CatalogOption;
+ opener: CatalogOption;
+};
+
+const studentSlot = { studentName: '/student/firstName' };
+const option = (id: string, template: string, eligibleCause: RootCause): CatalogOption => ({
+ id,
+ template,
+ eligibleCause,
+ slots: studentSlot,
+});
+
+const CATALOG: Record = {
+ guessing: {
+ diagnosis: option(
+ 'diagnosis.guessing.pattern',
+ 'For {{studentName}}, the recorded response pattern is consistent with guessing.',
+ 'guessing'
+ ),
+ opener: option(
+ 'opener.guessing.explain-choice',
+ '{{studentName}}, walk me through how you chose your answer.',
+ 'guessing'
+ ),
+ },
+ prerequisite_gap: {
+ diagnosis: option(
+ 'diagnosis.prerequisite-gap.review',
+ 'For {{studentName}}, the recorded work indicates that a prerequisite needs review.',
+ 'prerequisite_gap'
+ ),
+ opener: option(
+ 'opener.prerequisite-gap.example',
+ '{{studentName}}, let’s rebuild the prerequisite with a worked example.',
+ 'prerequisite_gap'
+ ),
+ },
+ grinding: {
+ diagnosis: option(
+ 'diagnosis.grinding.strategy',
+ 'For {{studentName}}, the recorded attempts show repeated work without a changed outcome.',
+ 'grinding'
+ ),
+ opener: option(
+ 'opener.grinding.strategy',
+ '{{studentName}}, let’s pause and choose a different strategy.',
+ 'grinding'
+ ),
+ },
+ hint_farming: {
+ diagnosis: option(
+ 'diagnosis.hint-farming.independence',
+ 'For {{studentName}}, the recorded attempts rely on hints more than independent work.',
+ 'hint_farming'
+ ),
+ opener: option(
+ 'opener.hint-farming.first-step',
+ '{{studentName}}, try an opening step before opening a hint.',
+ 'hint_farming'
+ ),
+ },
+ no_read_retry: {
+ diagnosis: option(
+ 'diagnosis.no-read-retry.review',
+ 'For {{studentName}}, the recorded retries repeat a response without a review step.',
+ 'no_read_retry'
+ ),
+ opener: option(
+ 'opener.no-read-retry.feedback',
+ '{{studentName}}, read the feedback and name a change to make.',
+ 'no_read_retry'
+ ),
+ },
+ decay: {
+ diagnosis: option(
+ 'diagnosis.decay.refresh',
+ 'For {{studentName}}, the recorded work indicates that an earlier skill needs review.',
+ 'decay'
+ ),
+ opener: option(
+ 'opener.decay.example',
+ '{{studentName}}, let’s revisit an earlier example before continuing.',
+ 'decay'
+ ),
+ },
+ disengagement: {
+ diagnosis: option(
+ 'diagnosis.disengagement.activity',
+ 'For {{studentName}}, recent recorded activity is lower than this student’s own pattern.',
+ 'disengagement'
+ ),
+ opener: option(
+ 'opener.disengagement.next-step',
+ '{{studentName}}, what would make the next step feel manageable?',
+ 'disengagement'
+ ),
+ },
+ fine: {
+ diagnosis: option(
+ 'diagnosis.fine.continue',
+ 'For {{studentName}}, the recorded work does not indicate an intervention.',
+ 'fine'
+ ),
+ opener: option(
+ 'opener.fine.continue',
+ '{{studentName}}, keep going with the next item.',
+ 'fine'
+ ),
+ },
+};
+
+export function deterministicFallback(bundle: EvidenceBundle): DeterministicNarration {
+ const groundedBundle = attachCatalogOptions(bundle);
+ const entry = CATALOG[bundle.finding.dominantCause];
+ const selection: NarrationSelection = {
+ diagnosis: {
+ propositions: [
+ {
+ id: entry.diagnosis.id,
+ slotRefs: { ...entry.diagnosis.slots },
+ },
+ ],
+ },
+ opener: {
+ id: entry.opener.id,
+ slotRefs: { ...entry.opener.slots },
+ },
+ };
+ const rendered = validateAndRenderSelection(groundedBundle, selection);
+ const languageFingerprint = digest({
+ catalogVersion: FALLBACK_CATALOG_VERSION,
+ renderVersion: FALLBACK_RENDER_VERSION,
+ selection,
+ ...rendered,
+ });
+ return {
+ bundle: groundedBundle,
+ selection,
+ ...rendered,
+ catalogVersion: FALLBACK_CATALOG_VERSION,
+ renderVersion: FALLBACK_RENDER_VERSION,
+ languageFingerprint,
+ narrationFingerprint: digest({
+ bundle: groundedBundle,
+ languageFingerprint,
+ }),
+ };
+}
+
+export function attachCatalogOptions(bundle: EvidenceBundle): EvidenceBundle {
+ const entry = CATALOG[bundle.finding.dominantCause];
+ return {
+ ...bundle,
+ languageOptions: {
+ catalogVersion: FALLBACK_CATALOG_VERSION,
+ propositions: [permittedOption(bundle, entry.diagnosis)],
+ openers: [permittedOption(bundle, entry.opener)],
+ },
+ };
+}
+
+export function validateAndRenderSelection(
+ bundle: EvidenceBundle,
+ selection: NarrationSelection
+): { diagnosis: string; opener: string } {
+ const expectedBundle = attachCatalogOptions({
+ ...bundle,
+ languageOptions: { catalogVersion: '', propositions: [], openers: [] },
+ });
+ if (
+ bundle.languageOptions.catalogVersion !== FALLBACK_CATALOG_VERSION ||
+ JSON.stringify(bundle.languageOptions) !== JSON.stringify(expectedBundle.languageOptions) ||
+ selection.diagnosis.propositions.length !== 1
+ ) {
+ throw groundingError();
+ }
+
+ const entry = CATALOG[bundle.finding.dominantCause];
+ const proposition = selection.diagnosis.propositions[0];
+ if (!proposition) throw groundingError();
+ const diagnosis = validateAndRenderOption(
+ bundle,
+ proposition,
+ entry.diagnosis,
+ bundle.languageOptions.propositions
+ );
+ const opener = validateAndRenderOption(
+ bundle,
+ selection.opener,
+ entry.opener,
+ bundle.languageOptions.openers
+ );
+ if (
+ wordCount(diagnosis) === 0 ||
+ wordCount(diagnosis) > 45 ||
+ wordCount(opener) === 0 ||
+ wordCount(opener) > 20 ||
+ !/^[^.!?]+[.!?]$/.test(opener) ||
+ /[{}]/.test(`${diagnosis}${opener}`)
+ ) {
+ throw groundingError();
+ }
+ validateRenderedAtoms(bundle, diagnosis);
+ validateRenderedAtoms(bundle, opener);
+ return { diagnosis, opener };
+}
+
+export function validateFallbackSelection(
+ bundle: EvidenceBundle,
+ selection: NarrationSelection
+): void {
+ validateAndRenderSelection(bundle, selection);
+}
+
+function permittedOption(
+ bundle: EvidenceBundle,
+ catalogOption: CatalogOption
+): PermittedLanguageOption {
+ return {
+ id: catalogOption.id,
+ slotBindings: Object.fromEntries(
+ Object.entries(catalogOption.slots).map(([slot, bundlePath]) => {
+ const value = resolvePointer(bundle, bundlePath);
+ return [slot, { bundlePath, valueHash: digest(value) }];
+ })
+ ),
+ };
+}
+
+function validateAndRenderOption(
+ bundle: EvidenceBundle,
+ selection: OptionSelection,
+ catalogOption: CatalogOption,
+ permitted: PermittedLanguageOption[]
+): string {
+ if (
+ catalogOption.eligibleCause !== bundle.finding.dominantCause ||
+ selection.id !== catalogOption.id
+ ) {
+ throw groundingError();
+ }
+ const permittedSelection = permitted.find((candidate) => candidate.id === selection.id);
+ if (!permittedSelection) throw groundingError();
+ const requiredSlots = Object.keys(catalogOption.slots).sort();
+ const selectedSlots = Object.keys(selection.slotRefs).sort();
+ if (JSON.stringify(requiredSlots) !== JSON.stringify(selectedSlots)) throw groundingError();
+
+ let rendered = catalogOption.template;
+ for (const slot of requiredSlots) {
+ const bundlePath = selection.slotRefs[slot];
+ const binding = permittedSelection.slotBindings[slot];
+ if (
+ !bundlePath ||
+ !binding ||
+ bundlePath !== catalogOption.slots[slot] ||
+ bundlePath !== binding.bundlePath
+ ) {
+ throw groundingError();
+ }
+ const value = resolvePointer(bundle, bundlePath);
+ if (typeof value !== 'string' || value.trim() === '' || digest(value) !== binding.valueHash)
+ throw groundingError();
+ rendered = rendered.replaceAll(`{{${slot}}}`, value);
+ }
+ return rendered;
+}
+
+function resolvePointer(value: unknown, pointer: string): unknown {
+ if (!pointer.startsWith('/')) throw groundingError();
+ let current = value;
+ for (const segment of pointer
+ .slice(1)
+ .split('/')
+ .map((part) => part.replaceAll('~1', '/').replaceAll('~0', '~'))) {
+ if (current === null || typeof current !== 'object' || !(segment in current))
+ throw groundingError();
+ current = (current as Record)[segment];
+ }
+ return current;
+}
+
+function wordCount(value: string): number {
+ return value.match(/[\p{L}\p{N}]+(?:['’][\p{L}\p{N}]+)*/gu)?.length ?? 0;
+}
+
+function validateRenderedAtoms(bundle: EvidenceBundle, rendered: string): void {
+ if (
+ !rendered.includes(bundle.student.firstName) ||
+ /\b\d+(?:\.\d+)?%?\b/.test(rendered) ||
+ /\b\d+(?:\.\d+)?\s*(?:ms|milliseconds?|s|sec|seconds?|m|min|minutes?)\b/i.test(rendered) ||
+ /\d{4}-\d{2}-\d{2}/.test(rendered)
+ ) {
+ throw groundingError();
+ }
+ const tokens = rendered.match(/[\p{L}\p{N}.]+/gu) ?? [];
+ const authorizedTokens = new Set(
+ bundle.student.firstName.match(/[\p{L}\p{N}.]+/gu)?.map((token) => token.toLowerCase()) ?? []
+ );
+ if (
+ tokens.some(
+ (token) =>
+ !authorizedTokens.has(token.toLowerCase()) &&
+ (normalizeIntegerToken(token) !== null || normalizeSkillCode(token) !== null)
+ )
+ ) {
+ throw groundingError();
+ }
+}
+
+function digest(value: unknown): string {
+ return createHash('sha256').update(JSON.stringify(value)).digest('hex');
+}
+
+function groundingError(): Error {
+ return new Error('Narration selection is not grounded in this evidence bundle.');
+}
diff --git a/packages/narrator/src/index.ts b/packages/narrator/src/index.ts
index f1b2787..a6533fc 100644
--- a/packages/narrator/src/index.ts
+++ b/packages/narrator/src/index.ts
@@ -8,3 +8,12 @@ export {
makeNarrationRequest,
} from './request.js';
export { SYSTEM_PROMPT, makeUserMessage } from './prompt.js';
+export {
+ FALLBACK_CATALOG_VERSION,
+ FALLBACK_RENDER_VERSION,
+ attachCatalogOptions,
+ deterministicFallback,
+ validateAndRenderSelection,
+ validateFallbackSelection,
+} from './catalog.js';
+export type { DeterministicNarration, NarrationSelection, OptionSelection } from './catalog.js';
diff --git a/packages/narrator/test/fallback-catalog.test.ts b/packages/narrator/test/fallback-catalog.test.ts
new file mode 100644
index 0000000..583bd89
--- /dev/null
+++ b/packages/narrator/test/fallback-catalog.test.ts
@@ -0,0 +1,122 @@
+import { describe, expect, it } from 'vitest';
+import type { EvidenceBundle, RootCause } from '@huddle/core';
+import {
+ deterministicFallback,
+ FALLBACK_CATALOG_VERSION,
+ validateFallbackSelection,
+} from '../src/index.js';
+
+const causes: RootCause[] = [
+ 'guessing',
+ 'prerequisite_gap',
+ 'grinding',
+ 'hint_farming',
+ 'no_read_retry',
+ 'decay',
+ 'disengagement',
+ 'fine',
+];
+
+function bundle(cause: RootCause): EvidenceBundle {
+ return {
+ bundleVersion: '1',
+ behaviorFingerprint: 'behavior',
+ student: { id: 'student', firstName: 'Avery' },
+ scope: { kind: 'cross-skill' },
+ finding: {
+ dominantCause: cause,
+ severity: 0.8,
+ rawConfidence: 0.8,
+ finalConfidence: 0.8,
+ confidenceBreakdown: {
+ timingMultiplier: 1,
+ winsorizationMultiplier: 1,
+ conflictMultiplier: 1,
+ },
+ ruleId: 'rule',
+ ruleVersion: '1',
+ },
+ window: {
+ timezone: 'America/Chicago',
+ start: '2026-07-22T05:00:00.000Z',
+ end: '2026-07-29T05:00:00.000Z',
+ asOf: '2026-07-29T05:00:00.000Z',
+ localDays: 7,
+ },
+ attempts: [],
+ sessions: [],
+ computed: {
+ attemptCount: 0,
+ wrongCount: 0,
+ medianWrongDurationMs: null,
+ personalCorrectBaselineMs: null,
+ personalSessionMeanBaselineMs: null,
+ distractorConcentration: null,
+ winsorizedOutCount: 0,
+ },
+ derived: {
+ wrongOfLastN: null,
+ speedRatio: null,
+ consecutiveWrong: null,
+ daysSinceFirstAttempt: null,
+ },
+ prerequisiteCheck: null,
+ conflicts: [],
+ additionalCauses: [],
+ abstentions: [],
+ languageOptions: { catalogVersion: 'pending', propositions: [], openers: [] },
+ };
+}
+
+describe('deterministic fallback catalog', () => {
+ it.each(causes)('validates and renders the committed %s selection', (cause) => {
+ const result = deterministicFallback(bundle(cause));
+ expect(result.catalogVersion).toBe(FALLBACK_CATALOG_VERSION);
+ expect(result.diagnosis.trim()).not.toBe('');
+ expect(result.opener.trim()).not.toBe('');
+ expect(result.opener).toContain('Avery');
+ expect(result.selection.diagnosis.propositions).toHaveLength(1);
+ expect(() => validateFallbackSelection(result.bundle, result.selection)).not.toThrow();
+ });
+
+ it('rejects options that a selection tries to authorize for itself', () => {
+ const result = deterministicFallback(bundle('guessing'));
+ const selfAuthorized = {
+ ...result.bundle,
+ languageOptions: {
+ ...result.bundle.languageOptions,
+ openers: [
+ {
+ id: 'opener.fabricated',
+ slotBindings: result.bundle.languageOptions.openers[0]!.slotBindings,
+ },
+ ],
+ },
+ };
+ expect(() =>
+ validateFallbackSelection(selfAuthorized, {
+ ...result.selection,
+ opener: { ...result.selection.opener, id: 'opener.fabricated' },
+ })
+ ).toThrow(/not grounded/);
+ });
+
+ it('rejects stale hashes and unauthorized bundle pointers', () => {
+ const result = deterministicFallback(bundle('guessing'));
+ expect(() =>
+ validateFallbackSelection(
+ { ...result.bundle, student: { ...result.bundle.student, firstName: 'Blake' } },
+ result.selection
+ )
+ ).toThrow(/not grounded/);
+ expect(() =>
+ validateFallbackSelection(result.bundle, {
+ ...result.selection,
+ opener: {
+ ...result.selection.opener,
+ slotRefs: { studentName: '/student/id' },
+ },
+ })
+ ).toThrow(/not grounded/);
+ });
+});
diff --git a/packages/signal-engine/src/index.ts b/packages/signal-engine/src/index.ts
index 1ce537d..a34c72b 100644
--- a/packages/signal-engine/src/index.ts
+++ b/packages/signal-engine/src/index.ts
@@ -1,7 +1,7 @@
// Signal engine: pure rules, baselines, ranking (Constitution Principle I)
export { runEngine, type EngineInput, type EngineOutput } from './engine.js';
export { allRules } from './rules/index.js';
-export { rankBySeverity, rankedEvidenceBundle } from './rank/index.js';
+export { compareSignals, rankBySeverity, rankedEvidenceBundle } from './rank/index.js';
export { computePersonalBaseline } from './baselines/index.js';
export { assembleEvidenceBundle, type EvidenceBundle } from './evidence.js';
export { attenuateConfidence, applyConflictPenalty } from './confidence.js';
diff --git a/scripts/nightly.test.ts b/scripts/nightly.test.ts
new file mode 100644
index 0000000..28fbe1d
--- /dev/null
+++ b/scripts/nightly.test.ts
@@ -0,0 +1,42 @@
+import { describe, expect, it } from 'vitest';
+import { parseNightlyArgs } from './nightly.js';
+
+describe('nightly operator arguments', () => {
+ it('preserves the matching test-only historical dispatch contract', () => {
+ expect(
+ parseNightlyArgs(
+ [
+ '--board-date',
+ '2026-07-27',
+ '--fixture-now',
+ '2026-07-27T07:30:00-05:00',
+ '--url',
+ 'http://127.0.0.1:3000/internal/refresh',
+ ],
+ { NODE_ENV: 'test' }
+ )
+ ).toEqual({
+ help: false,
+ endpoint: 'http://127.0.0.1:3000/internal/refresh',
+ overrides: {
+ boardDate: '2026-07-27',
+ fixtureNow: '2026-07-27T07:30:00-05:00',
+ },
+ });
+ });
+
+ it('rejects unsupported, partial, and non-test overrides', () => {
+ expect(() => parseNightlyArgs(['--unknown', 'value'], { NODE_ENV: 'test' })).toThrow(
+ 'Unsupported nightly option'
+ );
+ expect(() => parseNightlyArgs(['--board-date', '2026-07-27'], { NODE_ENV: 'test' })).toThrow(
+ 'matching --board-date and --fixture-now'
+ );
+ expect(() =>
+ parseNightlyArgs(
+ ['--board-date', '2026-07-27', '--fixture-now', '2026-07-27T07:30:00-05:00'],
+ { NODE_ENV: 'production' }
+ )
+ ).toThrow('only when NODE_ENV=test');
+ });
+});
diff --git a/scripts/nightly.ts b/scripts/nightly.ts
index bb58aec..18cc7af 100644
--- a/scripts/nightly.ts
+++ b/scripts/nightly.ts
@@ -1,537 +1,102 @@
-import { pool, type PoolClient } from '@huddle/db/client.js';
-import { opaqueActivityId } from '@huddle/db/activity-identity.js';
+import { createHmac } from 'node:crypto';
+import { pathToFileURL } from 'node:url';
import {
- clearBoardForGuide,
- clearMasterySnapshotForGuide,
- getMasteryRowsForGuide,
- getStudentsForGuide,
-} from '@huddle/db/scoped.js';
-import type {
- AbsenceSpan,
- Attempt,
- EvidenceBundle,
- Item,
- Signal,
- Skill,
- SkillPrereq,
- Student,
-} from '@huddle/core';
-import {
- allRules,
- BOARD_TIMEZONE,
- boardWindows,
- rankBySeverity,
- rankedEvidenceBundle,
- runEngine,
- type MasteryLookup,
-} from '@huddle/signal-engine';
-import { Narrator, renderBatch } from '@huddle/narrator';
-import { RULE_CONFIG } from '@huddle/signal-engine/config/thresholds.js';
-
-interface MasteryRow {
- student_id: string;
- skill_id: string;
- value: number | null;
- attempt_count: number | string;
- is_known: boolean;
-}
-
-type MasteryValue = { value: number; isKnown: true } | { value: null; isKnown: false };
-
-function parseArgs() {
- const args = process.argv.slice(2);
- const values: Record = {};
- const flags = new Set();
- for (let i = 0; i < args.length; i++) {
- const arg = args[i];
- if (arg.startsWith('--')) {
- const next = args[i + 1];
- if (next && !next.startsWith('--')) {
- values[arg] = next;
- i++;
- } else {
- flags.add(arg);
- }
- }
- }
- return { values, flags };
+ nightlyDispatchPayload,
+ validateNightlyDispatchOverrides,
+ type NightlyDispatchOverrides,
+} from '@huddle/application';
+
+export interface NightlyCliOptions {
+ help: boolean;
+ endpoint: string;
+ overrides: NightlyDispatchOverrides;
}
-function todayISO() {
- const parts = new Intl.DateTimeFormat('en-US', {
- timeZone: BOARD_TIMEZONE,
- year: 'numeric',
- month: '2-digit',
- day: '2-digit',
- }).formatToParts(new Date());
- const values = Object.fromEntries(
- parts.filter((part) => part.type !== 'literal').map((part) => [part.type, part.value])
- );
- return `${values.year}-${values.month}-${values.day}`;
-}
-
-async function loadGuide(client: PoolClient, guideIdArg?: string) {
- if (guideIdArg) {
- const { rows } = await client.query<{ id: string; display_name: string }>(
- 'SELECT id, display_name FROM guide WHERE id = $1',
- [guideIdArg]
- );
- if (rows.length === 0) throw new Error(`Guide not found: ${guideIdArg}`);
- return rows[0];
+export function parseNightlyArgs(
+ args: readonly string[],
+ environment: NodeJS.ProcessEnv = process.env
+): NightlyCliOptions {
+ if (args.includes('--help')) {
+ if (args.length !== 1) throw new Error('--help cannot be combined with other options.');
+ return {
+ help: true,
+ endpoint: environment.INTERNAL_REFRESH_URL ?? 'http://127.0.0.1:3000/internal/refresh',
+ overrides: {},
+ };
}
- const { rows } = await client.query<{ id: string; display_name: string }>(
- 'SELECT id, display_name FROM guide ORDER BY display_name LIMIT 1'
- );
- if (rows.length === 0) throw new Error('No guide found. Run seeding first.');
- return rows[0];
-}
-
-async function loadSkills(client: PoolClient) {
- const { rows } = await client.query<{ id: string; name: string; grade: number; strand: string }>(
- 'SELECT id, name, grade, strand FROM skill ORDER BY id'
- );
- return rows as Skill[];
-}
-
-async function loadSkillPrereqs(client: PoolClient) {
- const { rows } = await client.query<{ skill_id: string; prereq_id: string; strength: number }>(
- 'SELECT skill_id, prereq_id, strength FROM skill_prereq'
- );
- return rows.map(
- (r) => ({ skillId: r.skill_id, prereqId: r.prereq_id, strength: r.strength }) as SkillPrereq
- );
-}
-
-async function loadItems(client: PoolClient) {
- const { rows } = await client.query<{
- id: string;
- skill_id: string;
- difficulty: number;
- choices: unknown;
- item_type: Item['itemType'];
- timing_profile: Item['timingProfile'];
- }>('SELECT id, skill_id, difficulty, choices, item_type, timing_profile FROM item');
- return rows.map(
- (r) =>
- ({
- id: r.id,
- skillId: r.skill_id,
- difficulty: r.difficulty,
- choices: r.choices,
- itemType: r.item_type,
- timingProfile: r.timing_profile,
- }) as Item
- );
-}
-
-async function loadAbsences(client: PoolClient, studentIds: string[]) {
- if (studentIds.length === 0) return [];
- const { rows } = await client.query<{
- id: number;
- student_id: string;
- start_date: string;
- end_date: string;
- attendance_source: string;
- }>(
- 'SELECT id, student_id, start_date::text AS start_date, end_date::text AS end_date, source AS attendance_source FROM absence WHERE student_id = ANY($1::uuid[])',
- [studentIds]
- );
- return rows.map(
- (row) =>
- ({
- id: row.id,
- studentId: row.student_id,
- startDate: row.start_date,
- endDate: row.end_date,
- source: row.attendance_source,
- }) as AbsenceSpan
- );
-}
-
-function buildMasteryByAnchor(
- nowRows: MasteryRow[],
- startRows: MasteryRow[],
- anchor: Date,
- windowStart: Date
-): Map>> {
- const map = new Map>>([
- [anchor.toISOString(), new Map()],
- [windowStart.toISOString(), new Map()],
- ]);
-
- function add(anchorIso: string, row: MasteryRow) {
- const byStudent = map.get(anchorIso)!;
- let bySkill = byStudent.get(row.student_id);
- if (!bySkill) {
- bySkill = new Map();
- byStudent.set(row.student_id, bySkill);
- }
- bySkill.set(
- row.skill_id,
- row.is_known && row.value != null
- ? { value: row.value, isKnown: true }
- : { value: null, isKnown: false }
- );
+ const values = new Map();
+ const supported = new Set(['--url', '--board-date', '--fixture-now']);
+ for (let index = 0; index < args.length; index += 2) {
+ const name = args[index];
+ const value = args[index + 1];
+ if (!name || !supported.has(name)) throw new Error(`Unsupported nightly option: ${name ?? ''}`);
+ if (!value || value.startsWith('--')) throw new Error(`${name} requires a value.`);
+ if (values.has(name)) throw new Error(`${name} may only be supplied once.`);
+ values.set(name, value);
}
-
- for (const r of nowRows) add(anchor.toISOString(), r);
- for (const r of startRows) add(windowStart.toISOString(), r);
- return map;
-}
-
-function masteryLookupForStudent(
- masteryByAnchor: Map>>,
- studentId: string
-): MasteryLookup {
- return {
- at(skillId: string, anchor: Date) {
- const anchorIso = anchor.toISOString();
- const byStudent = masteryByAnchor.get(anchorIso);
- // Mastery is materialized only at the window anchors; an unrecognised anchor is a
- // wiring error, not an unknown mastery value, and must not degrade to "unknown".
- if (!byStudent) throw new Error(`No mastery was computed for anchor ${anchorIso}`);
- return byStudent.get(studentId)?.get(skillId) ?? { value: null, isKnown: false };
+ const overrides = validateNightlyDispatchOverrides(
+ {
+ boardDate: values.get('--board-date'),
+ fixtureNow: values.get('--fixture-now'),
},
- };
-}
-
-function parseAnswerGiven(
- raw: unknown
-): { key: string; label?: string; misconceptionId?: string } | null {
- if (raw == null || typeof raw !== 'object') return null;
- const obj = raw as { key?: unknown; label?: unknown; misconceptionId?: unknown };
- if (typeof obj.key !== 'string') return null;
- const parsed: { key: string; label?: string; misconceptionId?: string } = { key: obj.key };
- if (typeof obj.label === 'string') parsed.label = obj.label;
- if (typeof obj.misconceptionId === 'string') parsed.misconceptionId = obj.misconceptionId;
- return parsed;
-}
-
-async function loadAttempts(
- client: PoolClient,
- studentIds: string[],
- windowStart: Date,
- windowEnd: Date
-) {
- if (studentIds.length === 0) return [];
- const { rows } = await client.query(
- `SELECT id, student_id, skill_id, item_id, session_id, attempt_index, started_at, submitted_at,
- elapsed_ms, engaged_ms, session_total_ms, timing_quality, timing_was_winsorized,
- is_correct, answer_given, hints_used,
- source, source_event_id, ingested_at
- FROM attempt
- WHERE student_id = ANY($1::uuid[])
- AND submitted_at >= $2
- AND submitted_at < $3
- ORDER BY submitted_at, id`,
- [studentIds, windowStart, windowEnd]
- );
- return rows.map(
- (r) =>
- ({
- id: Number(r.id),
- // eslint-disable-next-line no-restricted-syntax -- Legacy composition hashes opaque provenance without branching on it.
- activityId: opaqueActivityId(r.source, r.source_event_id),
- studentId: r.student_id,
- skillId: r.skill_id,
- itemId: r.item_id,
- sessionId: r.session_id,
- attemptIndex: Number(r.attempt_index),
- startedAt: r.started_at,
- submittedAt: r.submitted_at,
- elapsedMs: r.elapsed_ms,
- engagedMs: r.engaged_ms,
- sessionTotalMs: r.session_total_ms,
- timingQuality: r.timing_quality,
- timingWasWinsorized: r.timing_was_winsorized,
- isCorrect: r.is_correct,
- answerGiven: parseAnswerGiven(r.answer_given),
- hintsUsed: Number(r.hints_used),
- ingestedAt: r.ingested_at,
- }) as Attempt
+ environment.NODE_ENV === 'test'
);
-}
-
-type Narration = {
- customId: string;
- result: { diagnosis: string; opener: string } | null;
- error?: string;
-};
-
-/** Runs outside any transaction: every call here is a network round trip to the model. */
-async function renderNarrations(
- entries: readonly { signalId: number; bundle: EvidenceBundle }[],
- useBatch: boolean
-): Promise {
- if (entries.length === 0) return [];
- const apiKey = process.env.ANTHROPIC_API_KEY;
- if (!apiKey) {
- return entries.map((entry) => ({
- customId: String(entry.signalId),
- result: null,
- error: 'missing API key',
- }));
- }
- if (useBatch) return renderBatch(entries, apiKey);
-
- const narrator = new Narrator(apiKey);
- const narrations: Narration[] = [];
- for (const entry of entries) {
- try {
- narrations.push({
- customId: String(entry.signalId),
- result: await narrator.render(entry.bundle),
- });
- } catch (error) {
- const message = error instanceof Error ? error.message : String(error);
- narrations.push({ customId: String(entry.signalId), result: null, error: message });
- }
- }
- return narrations;
-}
-
-/** Narration is attached to already committed rows, keyed by the persisted signal id. */
-async function writeNarrations(
- client: PoolClient,
- boardDate: string,
- narrations: readonly Narration[]
-): Promise {
- for (const narration of narrations) {
- if (narration.error)
- console.error(`Narration unavailable for signal ${narration.customId}: ${narration.error}`);
- }
- const rendered = narrations.filter((narration) => narration.result != null);
- if (rendered.length === 0) return 0;
-
- await client.query('BEGIN');
- for (const narration of rendered) {
- await client.query(
- `UPDATE triage_entry
- SET diagnosis = $1, opener = $2, generated_at = now()
- WHERE board_date = $3::date AND dominant_signal_id = $4`,
- [narration.result!.diagnosis, narration.result!.opener, boardDate, Number(narration.customId)]
+ if (!overrides)
+ throw new Error(
+ 'A matching --board-date and --fixture-now pair is accepted only when NODE_ENV=test.'
);
- }
- await client.query('COMMIT');
- return rendered.length;
+ const endpoint =
+ values.get('--url') ??
+ environment.INTERNAL_REFRESH_URL ??
+ 'http://127.0.0.1:3000/internal/refresh';
+ const parsedEndpoint = new URL(endpoint);
+ if (parsedEndpoint.protocol !== 'http:' && parsedEndpoint.protocol !== 'https:')
+ throw new Error('--url must use http or https.');
+ return { help: false, endpoint: parsedEndpoint.toString(), overrides };
}
async function main() {
- const { values, flags } = parseArgs();
- const boardDate = values['--board-date'] ?? process.env.BOARD_DATE ?? todayISO();
- const guideIdArg = values['--guide-id'] ?? process.env.GUIDE_ID;
- const useBatch = flags.has('--batch');
-
- const { windowStart, historyStart, windowEnd } = boardWindows(
- new Date(`${boardDate}T12:00:00.000Z`),
- RULE_CONFIG
- );
- const computedAt = new Date();
-
- const client = await pool.connect();
- try {
- await client.query('BEGIN');
-
- const guide = await loadGuide(client, guideIdArg);
- const students = (await getStudentsForGuide(guide.id, client)).map(
- (row) =>
- ({
- id: row.id,
- guideId: row.guide_id,
- firstName: row.first_name,
- grade: row.grade,
- }) as Student
- );
- if (students.length === 0) {
- console.log(`No students for guide ${guide.display_name}; nothing to do.`);
- await client.query('COMMIT');
- return;
- }
-
- const skills = await loadSkills(client);
- const skillPrereqs = await loadSkillPrereqs(client);
- const items = await loadItems(client);
- const studentIds = students.map((s) => s.id);
- const absences = await loadAbsences(client, studentIds);
-
- const nowRows = await getMasteryRowsForGuide(client, guide.id, windowEnd);
- const startRows = await getMasteryRowsForGuide(client, guide.id, windowStart);
-
- await clearMasterySnapshotForGuide(client, boardDate, guide.id);
- const snapshotValues: string[] = [];
- const snapshotParams: unknown[] = [];
- let pi = 1;
- for (const r of nowRows) {
- snapshotParams.push(
- boardDate,
- r.student_id,
- r.skill_id,
- r.value,
- Number(r.attempt_count),
- r.is_known
- );
- snapshotValues.push(`($${pi++}, $${pi++}, $${pi++}, $${pi++}, $${pi++}, $${pi++})`);
- }
- if (snapshotValues.length > 0) {
- await client.query(
- `INSERT INTO mastery_snapshot (board_date, student_id, skill_id, value, attempt_count, is_known) VALUES ${snapshotValues.join(', ')}`,
- snapshotParams
- );
- }
-
- const allAttempts = await loadAttempts(client, studentIds, historyStart, windowEnd);
- const masteryByAnchor = buildMasteryByAnchor(nowRows, startRows, windowEnd, windowStart);
-
- const computedSignals: Signal[] = [];
- for (const student of students) {
- // A student with no attempts at all is still evaluated: silence is the signal.
- const studentAttempts = allAttempts.filter((a) => a.studentId === student.id);
- const mastery = masteryLookupForStudent(masteryByAnchor, student.id);
- const input = {
- students: [student],
- skills,
- attempts: studentAttempts,
- items,
- skillPrereqs,
- mastery,
- attendance: absences,
- attendanceLoaded: true,
- config: RULE_CONFIG,
- now: computedAt,
- window: { start: windowStart, end: windowEnd },
- };
- const output = runEngine(input, allRules);
- computedSignals.push(...output.signals);
- }
-
- await clearBoardForGuide(client, boardDate, guide.id);
-
- if (computedSignals.length === 0) {
- await client.query('COMMIT');
- console.log(`Nightly complete for ${boardDate}: 0 signals, 0 triage entries.`);
- return;
- }
-
- const signalValueRows: string[] = [];
- const signalParams: unknown[] = [];
- let si = 1;
- for (const s of computedSignals) {
- signalParams.push(
- s.studentId,
- s.skillId,
- s.kind,
- s.severity,
- s.confidence,
- JSON.stringify(s.evidence),
- s.ruleVersion,
- s.windowStart,
- s.windowEnd,
- s.computedAt
- );
- signalValueRows.push(
- `($${si++}, $${si++}, $${si++}, $${si++}, $${si++}, $${si++}::jsonb, $${si++}, $${si++}, $${si++}, $${si++})`
- );
- }
-
- const { rows: insertedSignalRows } = await client.query<{
- id: number;
- student_id: string;
- skill_id: string | null;
- kind: string;
- severity: number;
- confidence: number;
- evidence: unknown;
- rule_version: string;
- window_start: Date;
- window_end: Date;
- computed_at: Date;
- }>(
- `INSERT INTO signal (student_id, skill_id, kind, severity, confidence, evidence, rule_version, window_start, window_end, computed_at)
- VALUES ${signalValueRows.join(', ')}
- RETURNING id, student_id, skill_id, kind, severity, confidence, evidence, rule_version, window_start, window_end, computed_at`,
- signalParams
+ const options = parseNightlyArgs(process.argv.slice(2));
+ if (options.help) {
+ console.info(
+ 'Usage: npm run nightly -- [--url ] [--board-date YYYY-MM-DD --fixture-now ISO-8601]'
);
-
- const actualSignals: Signal[] = insertedSignalRows.map((r) => ({
- id: r.id,
- studentId: r.student_id,
- skillId: r.skill_id,
- kind: r.kind as Signal['kind'],
- severity: r.severity,
- confidence: r.confidence,
- evidence: r.evidence,
- ruleVersion: r.rule_version,
- windowStart: r.window_start,
- windowEnd: r.window_end,
- computedAt: r.computed_at,
- }));
-
- const ranked = rankBySeverity(actualSignals);
-
- const batchEntries: { signalId: number; bundle: EvidenceBundle }[] = [];
- const signalById = new Map();
- for (const s of actualSignals) signalById.set(s.id ?? 0, s);
-
- for (const entry of ranked) {
- const signal = signalById.get(entry.dominantSignalId);
- if (!signal) continue;
- const bundle = rankedEvidenceBundle(signal, entry);
- signal.evidence = bundle;
- await client.query('UPDATE signal SET evidence = $1::jsonb WHERE id = $2', [
- JSON.stringify(bundle),
- entry.dominantSignalId,
- ]);
- batchEntries.push({
- signalId: entry.dominantSignalId,
- bundle,
- });
- }
-
- const triageValues: string[] = [];
- const triageParams: unknown[] = [];
- let ti = 1;
- for (const entry of ranked) {
- if (!signalById.has(entry.dominantSignalId)) continue;
- triageParams.push(
- boardDate,
- entry.studentId,
- entry.dominantSignalId,
- entry.rank,
- JSON.stringify(entry.additionalCauses)
- );
- triageValues.push(`($${ti++}, $${ti++}, $${ti++}, $${ti++}, $${ti++}::jsonb)`);
- }
-
- if (triageValues.length > 0) {
- await client.query(
- `INSERT INTO triage_entry (board_date, student_id, dominant_signal_id, rank, additional_causes)
- VALUES ${triageValues.join(', ')}`,
- triageParams
- );
- }
-
- // The deterministic board is committed before any model call, so a slow or failed
- // narration can never roll back the mastery snapshot, signals, or ranking.
- await client.query('COMMIT');
- console.log(
- `Nightly complete for ${boardDate}: ${computedSignals.length} signals, ${triageValues.length} triage entries.`
- );
-
- const narrations = await renderNarrations(batchEntries, useBatch);
- const narrated = await writeNarrations(client, boardDate, narrations);
- console.log(
- `Narration written for ${boardDate}: ${narrated} of ${batchEntries.length} entries.`
- );
- } catch (err) {
- await client.query('ROLLBACK').catch(() => {});
- throw err;
- } finally {
- client.release();
- await pool.end();
+ return;
}
+ const secret = process.env.INTERNAL_REFRESH_SECRET;
+ if (!secret) throw new Error('INTERNAL_REFRESH_SECRET is required.');
+ const timestamp = String(Date.now());
+ const payload = nightlyDispatchPayload(options.overrides);
+ const signature = createHmac('sha256', secret).update(`${payload}.${timestamp}`).digest('hex');
+ const response = await fetch(options.endpoint, {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/json',
+ 'x-huddle-timestamp': timestamp,
+ 'x-huddle-signature': signature,
+ },
+ body: JSON.stringify({ nightly: true, ...options.overrides }),
+ });
+ if (!response.ok) throw new Error(`Nightly dispatcher returned HTTP ${response.status}.`);
+ const result = (await response.json()) as {
+ ok?: boolean;
+ eligible?: boolean;
+ dispatched?: number;
+ succeeded?: number;
+ };
+ if (!result.ok) throw new Error('Nightly dispatcher reported a failed refresh.');
+ console.info(
+ JSON.stringify({
+ event: 'nightly-dispatch',
+ eligible: result.eligible === true,
+ dispatched: result.dispatched ?? 0,
+ succeeded: result.succeeded ?? 0,
+ })
+ );
}
-main().catch((err) => {
- console.error(err);
- process.exit(1);
-});
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
+ main().catch((error) => {
+ console.error(error instanceof Error ? error.message : 'Nightly dispatch failed.');
+ process.exitCode = 1;
+ });
diff --git a/scripts/tsconfig.json b/scripts/tsconfig.json
index be84221..1b2ef1c 100644
--- a/scripts/tsconfig.json
+++ b/scripts/tsconfig.json
@@ -8,6 +8,7 @@
"include": ["./**/*.ts"],
"references": [
{ "path": "../packages/core" },
+ { "path": "../packages/application" },
{ "path": "../packages/db" },
{ "path": "../packages/signal-engine" },
{ "path": "../packages/narrator" }
diff --git a/scripts/vitest.config.ts b/scripts/vitest.config.ts
new file mode 100644
index 0000000..eaf73ac
--- /dev/null
+++ b/scripts/vitest.config.ts
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ test: {
+ include: ['*.test.ts'],
+ },
+});
diff --git a/specs/001-huddle-triage-board/plan.md b/specs/001-huddle-triage-board/plan.md
index a66fa92..b14754d 100644
--- a/specs/001-huddle-triage-board/plan.md
+++ b/specs/001-huddle-triage-board/plan.md
@@ -2,9 +2,9 @@
**Branch**: `001-huddle-triage-board` | **Date**: 2026-07-27 | **Spec**: [spec.md](./spec.md)
-**Status**: In implementation — the bounded shared foundation plus the Evidence Desk UI and
-framework-neutral visible-open boundary have landed. Operations composition, import/refresh
-persistence, concrete board/acknowledgment storage, model runtime, deployment, and the full
+**Status**: In implementation — the shared foundation and bounded synthetic Operations golden path
+plus the Evidence Desk UI and framework-neutral visible-open boundary have landed. Production
+Evidence Desk composition, automatic acknowledgment, model runtime, deployment, and the full
simulator/eval remain out of scope.
**Input**: Feature specification from `/specs/001-huddle-triage-board/spec.md`
@@ -232,18 +232,25 @@ selection may replace only narration/provenance after matching the expected narr
persisted at publication and can never move the head or alter ranking, causes, evidence, scope, or
freshness.
-### Current implementation reconciliation (read-only evidence)
+### Current implementation reconciliation
-Merged PR #2 (`9dc16a5`) already establishes Supabase public Auth sign-in, server-side user
-verification, synthetic `guide_auth_scope`, server-only Postgres reads through `@huddle/db`, and
-revoked browser Data API privileges. The repository physically names the dedicated data-access
-package `packages/db`; this delta uses that path rather than proposing a no-value rename. The
-Evidence Desk shell, route state, progressive evidence presentation, and framework-neutral
-`EvidenceReader` visible-open boundary are now present. The board route intentionally has no
+The current worktree retains the Supabase public Auth sign-in, server-side user verification,
+synthetic `guide_auth_scope`, server-only Postgres access through `@huddle/db`, and revoked browser
+Data API privileges from merged PR #2 (`9dc16a5`). It now also implements the bounded Operations
+golden path: strict synthetic CSV validate/commit with receipt lineage, manual and protected nightly
+refresh through one compiler, leases/reaping, Chicago windows, immutable run publication with atomic
+head promotion and prior-head preservation, engine-backed ranking/evidence, deterministic
+cause-specific fallbacks, a restricted runtime database role, and redacted operational events. The
+repository physically names the dedicated data-access package `packages/db`; this implementation
+keeps that path.
+
+The Evidence Desk shell, route state, progressive evidence presentation, and framework-neutral
+`EvidenceReader` visible-open boundary are also present. The board route intentionally has no
production `BoardReader`, evidence-read, reveal-lease, or acknowledgment-ledger composition yet and
-therefore fails closed as unavailable rather than falling back to the legacy table path. Concrete
-import, immutable refresh publication, and acknowledgment persistence remain Operations/integration
-gaps tracked below.
+therefore fails closed as unavailable rather than falling back to the legacy table path. Completing
+that composition, automatic report-open acknowledgment, optional model-backed narration attachment,
+deployment scheduler wiring, and the remaining simulator/eval gates are still implementation gaps,
+not alternate contracts.
### Future gates preserved
diff --git a/specs/001-huddle-triage-board/quickstart.md b/specs/001-huddle-triage-board/quickstart.md
index 8c414c2..b522bd1 100644
--- a/specs/001-huddle-triage-board/quickstart.md
+++ b/specs/001-huddle-triage-board/quickstart.md
@@ -1,23 +1,27 @@
# Quickstart: Huddle — Morning Triage Board
-**Feature**: `001-huddle-triage-board` | **Status**: mixed implementation and future command contract
-
-The worktree contains the Supabase Auth/`packages/db` foundation plus the bounded Evidence Desk UI
-and framework-neutral visible-open boundary. The `/board` route deliberately remains unavailable
-until Operations/integration supplies concrete `BoardReader`, evidence-read/reveal-lease, and atomic
-acknowledgment-ledger composition; it does not invent a production persistence fallback. Import,
-immutable refresh publication, concrete acknowledgment persistence, and the complete guide-flow
-verification remain future tasks. Commands below describe the completed workflow contract unless
-the root `package.json` already defines them. T002a wires every root script, T002b parses this file
-and requires each documented script plus a successful noninteractive `--help`, and T111/T136 run
-the completed validations.
+**Feature**: `001-huddle-triage-board` | **Status**: partial implementation command contract
+
+The bounded synthetic Operations path in this worktree implements strict import validation and
+transactional commit, immutable refresh publication, protected nightly dispatch, deterministic
+cause-specific fallback, and the Supabase Auth/`packages/db` foundation. The bounded Evidence Desk UI
+and framework-neutral visible-open boundary are also present. The `/board` route deliberately
+remains unavailable until integration supplies concrete `BoardReader`, evidence-read/reveal-lease,
+and atomic acknowledgment-ledger composition; it does not invent a production persistence fallback.
+Automatic acknowledgment, optional model-narration attachment, and several validation commands
+below remain future implementation contracts. T002a wires every root script, T002b parses this file
+and requires each documented script plus a successful noninteractive `--help`, and T111/T136 run the
+completed validations.
## Prerequisites and setup
- Node 22 LTS and npm
-- Supabase Postgres (PostgreSQL 16), with server-only `DATABASE_URL`
+- Supabase Postgres (PostgreSQL 16), with a server-only administrative `DATABASE_ADMIN_URL` for
+ migrations and restricted `huddle_app` runtime `DATABASE_URL`
- Supabase Auth public URL/publishable key for the encapsulated browser Auth client only
- one explicitly provisioned synthetic guide/student roster and `guide_auth_scope`
+- `INTERNAL_REFRESH_SECRET` for protected refresh dispatch; the previous secret is optional during
+ rotation and `INTERNAL_REFRESH_URL` defaults to the local internal route
- `ANTHROPIC_API_KEY` only for trusted out-of-band generated-narration refresh; never for board
publication, deterministic fallback, or CI scoring
@@ -103,10 +107,17 @@ has at least 20 truth rows per cause.
```bash
unset ANTHROPIC_API_KEY
npm run nightly -- --help
-NODE_ENV=test npm run nightly -- --board-date 2026-07-27 \
- --fixture-now 2026-07-27T07:30:00-05:00
npm run dev -- --help
+# Terminal 1: start the protected local application and leave it running.
npm run dev
+
+# Terminal 2: dispatch only after Terminal 1 is listening on port 3000.
+set -a
+. ./.env
+set +a
+unset ANTHROPIC_API_KEY
+NODE_ENV=test npm run nightly -- --board-date 2026-07-27 \
+ --fixture-now 2026-07-27T07:30:00-05:00
# open http://localhost:3000/board
```
diff --git a/specs/001-huddle-triage-board/research.md b/specs/001-huddle-triage-board/research.md
index 4a7c303..c44952f 100644
--- a/specs/001-huddle-triage-board/research.md
+++ b/specs/001-huddle-triage-board/research.md
@@ -436,7 +436,7 @@ browser/guide-selected scope, and reads no student table. A refresh request is s
immutable successful board runs. One transaction writes run-scoped mastery/signals/entries with
mandatory cause-specific fallback text and
atomically moves `board_head`; failure records a bounded code and leaves the prior head unchanged.
-This resolves the current implementation's destructive date-keyed rewrite without introducing a
+The bounded Operations slice replaced the prior destructive date-keyed rewrite without introducing a
service boundary.
**Rationale**: The accepted demo needs truthful loading/failure/freshness states, a guide-owned import,
diff --git a/vitest.workspace.ts b/vitest.workspace.ts
index 6b8718b..5c333d1 100644
--- a/vitest.workspace.ts
+++ b/vitest.workspace.ts
@@ -1,3 +1,7 @@
import { defineWorkspace } from 'vitest/config';
-export default defineWorkspace(['packages/*/vitest.config.ts', 'apps/web/vitest.config.ts']);
+export default defineWorkspace([
+ 'packages/*/vitest.config.ts',
+ 'apps/web/vitest.config.ts',
+ 'scripts/vitest.config.ts',
+]);