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', +]);