From bc366a2c651f75c28c560be066fa8562feef6265 Mon Sep 17 00:00:00 2001 From: alexdancer Date: Wed, 29 Jul 2026 21:57:25 -0500 Subject: [PATCH 01/13] feat: package focused Huddle portfolio demo --- .env.example | 32 +- .github/workflows/ci.yml | 5 + .gitignore | 1 + README.md | 140 +++ .../app/board/evidence/[entry_id]/page.tsx | 29 +- apps/web/app/board/page.tsx | 32 +- apps/web/app/globals.css | 242 ++++ apps/web/app/health/route.ts | 11 + apps/web/app/import/actions.ts | 7 +- apps/web/app/import/import-workflow.tsx | 20 +- apps/web/app/import/page.tsx | 27 +- apps/web/app/layout.tsx | 22 +- apps/web/app/login/page.tsx | 16 +- apps/web/app/login/sign-in-form.tsx | 2 +- apps/web/app/page.tsx | 37 +- apps/web/lib/demo-config.ts | 15 + apps/web/lib/evidence-desk-operations.ts | 43 + apps/web/lib/operations.ts | 2 +- apps/web/lib/supabase/server.ts | 2 +- apps/web/next-env.d.ts | 6 + apps/web/next.config.js | 23 + apps/web/package.json | 2 +- apps/web/public/synthetic-huddle-sample.csv | 55 +- apps/web/test/demo-config.test.ts | 20 + apps/web/test/health-route.test.ts | 15 + db/migrate.ts | 16 +- db/migrations/010_evidence_acknowledgment.sql | 63 + package-lock.json | 779 +++++++++--- package.json | 24 +- packages/db/package.json | 4 + packages/db/src/acknowledgement.ts | 185 ++- packages/db/src/boards.ts | 81 +- packages/db/src/demo-seed.ts | 204 ++++ packages/db/src/evidence.ts | 162 ++- packages/db/src/index.ts | 3 + packages/db/src/scoped.ts | 15 +- .../db/test/acknowledgment-privileges.test.ts | 66 ++ .../evidence-acknowledgment-migration.test.ts | 22 + packages/db/test/evidence-persistence.test.ts | 61 + packages/db/test/scoped-access.test.ts | 6 +- .../fixtures/portfolio/fallback-corpus.json | 1049 +++++++++++++++++ packages/eval/test/portfolio-gate.test.ts | 29 + packages/ingest/test/portfolio-csv.test.ts | 43 + packages/narrator/package.json | 4 + scripts/demo-data.ts | 45 + scripts/portfolio-eval.ts | 203 ++++ scripts/smoke-hosted.ts | 58 + scripts/verify-quickstart.ts | 77 ++ specs/001-huddle-triage-board/plan.md | 20 +- specs/001-huddle-triage-board/quickstart.md | 380 +----- vercel.json | 7 + 51 files changed, 3800 insertions(+), 612 deletions(-) create mode 100644 README.md create mode 100644 apps/web/app/globals.css create mode 100644 apps/web/app/health/route.ts create mode 100644 apps/web/lib/demo-config.ts create mode 100644 apps/web/lib/evidence-desk-operations.ts create mode 100644 apps/web/next-env.d.ts create mode 100644 apps/web/test/demo-config.test.ts create mode 100644 apps/web/test/health-route.test.ts create mode 100644 db/migrations/010_evidence_acknowledgment.sql create mode 100644 packages/db/src/demo-seed.ts create mode 100644 packages/db/test/acknowledgment-privileges.test.ts create mode 100644 packages/db/test/evidence-acknowledgment-migration.test.ts create mode 100644 packages/db/test/evidence-persistence.test.ts create mode 100644 packages/eval/fixtures/portfolio/fallback-corpus.json create mode 100644 packages/eval/test/portfolio-gate.test.ts create mode 100644 packages/ingest/test/portfolio-csv.test.ts create mode 100644 scripts/demo-data.ts create mode 100644 scripts/portfolio-eval.ts create mode 100644 scripts/smoke-hosted.ts create mode 100644 scripts/verify-quickstart.ts create mode 100644 vercel.json diff --git a/.env.example b/.env.example index 39a2d89..3ffcbdc 100644 --- a/.env.example +++ b/.env.example @@ -1,21 +1,31 @@ -# Supabase Auth public configuration. The application exposes these only through its -# Auth-only browser wrapper; migration 006 denies browser roles access to application data. +# Browser-visible Supabase Auth configuration. This wrapper exposes Auth only; migrations revoke +# browser Data API access to application tables. NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=sb_publishable_placeholder -# Administrative connection used only by db:migrate — server-only. -DATABASE_ADMIN_URL=postgresql://postgres:[ADMIN-PASSWORD]@db.[YOUR-PROJECT-REF].supabase.co:5432/postgres +# Administrative connection used only by db:migrate and synthetic seed/reset operators. +# Keep this out of Vercel's web runtime environment. +DATABASE_ADMIN_URL=postgresql://postgres:[ADMIN-PASSWORD]@db.[PROJECT-REF].supabase.co:5432/postgres -# 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 +# Restricted huddle_app runtime connection. Server-only; never prefix it with NEXT_PUBLIC. +DATABASE_URL=postgresql://huddle_app:[RUNTIME-PASSWORD]@db.[PROJECT-REF].supabase.co:5432/postgres -# Optional only for narration; server-only. -ANTHROPIC_API_KEY= +# Operator-only UUID copied from the one synthetic reviewer account in Supabase Auth. +HUDDLE_DEMO_AUTH_USER_ID=00000000-0000-4000-8000-000000000000 + +# Fixed reviewed CSV/board anchor. This private portfolio corpus is intentionally time-pinned. +HUDDLE_DEMO_BOARD_DATE=2026-07-27 + +# Server-only HMAC keys for one-use evidence reveal/acknowledgment grants. Generate at least 32 bytes. +HUDDLE_ACKNOWLEDGMENT_KEY_CURRENT=replace-with-a-long-random-secret +HUDDLE_ACKNOWLEDGMENT_KEY_PREVIOUS= -# Protected nightly and per-request refresh dispatch; server-only. -INTERNAL_REFRESH_SECRET=replace-with-a-random-secret +# Protected nightly dispatcher. Manual reviewer refresh does not require a scheduler. +INTERNAL_REFRESH_SECRET=replace-with-a-different-random-secret INTERNAL_REFRESH_SECRET_PREVIOUS= INTERNAL_REFRESH_URL=http://127.0.0.1:3000/internal/refresh +# Optional generated narration only. Leave unset for the reviewed deterministic-fallback demo/eval. +ANTHROPIC_API_KEY= + NODE_ENV=development diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2f497f1..ea535a5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,5 +38,10 @@ jobs: - run: npm run lint - run: npm run format:check - run: npm run check:determinism + - run: npm run verify:quickstart + - uses: actions/upload-artifact@v4 + with: + name: portfolio-eval-report + path: artifacts/portfolio-eval-report.json - run: npm test - run: npm run build diff --git a/.gitignore b/.gitignore index 233772b..61d2ad8 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ dist/ # Test and coverage coverage/ .vitest/ +artifacts/ # Local tooling indexes .codegraph/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..207cfd6 --- /dev/null +++ b/README.md @@ -0,0 +1,140 @@ +# Huddle + +Huddle is a private, synthetic-only morning triage board built as a focused work sample for Texas +Sports Academy. It helps a guide or coach decide **who to see first, why, and what to say first** when +adaptive academic software carries practice and adults carry motivation and intervention. + +The portfolio demo is deliberately narrow: sign in, upload one fixed CSV, validate and commit it, +refresh, inspect the ranked Evidence Desk, open exact evidence, and see the report acknowledged. It is +not a diagnosis product and makes **no accuracy claim**. + +## Why this is useful at TSA + +- **Guide utility:** one deterministic queue turns attempt-level activity into a short morning plan. +- **Coach-like intervention:** every report includes a concrete opener, not another analytics chart. +- **Responsible student data:** the roster, activity, names, and evaluation corpus are visibly + synthetic; real student data is rejected and remains out of scope. +- **Trustworthy AI boundary:** rules classify and rank without a model. Optional AI may select only + reviewed language-catalog IDs and evidence slots; deterministic fallback always keeps the board + usable. + +## Architecture and trust boundaries + +```text +Browser (Supabase Auth only) + -> Next.js Server Components / narrow server actions + -> @huddle/application use cases + -> @huddle/db scoped SQL + -> Supabase PostgreSQL + +CSV -> strict synthetic-csv-v1 adapter -> immutable import receipt + -> deterministic signal engine -> immutable board run/head + -> Evidence Desk -> signed one-use reveal -> PostgreSQL acknowledgment ledger + +Optional narration -> closed catalog + exact bundle slots +Deterministic ranking/evaluation -X-> model client +``` + +`packages/db` is the only package that owns `pg`; service/database credentials never enter browser +code. Supabase Auth sessions are verified server-side and resolve exactly one synthetic guide/studio +scope. Browser Data API privileges are revoked by migration. RLS, retention policy, and multi-guide +access are mandatory before real data or a pilot and are intentionally not implemented here. + +Authoritative deeper contracts: [plan](specs/001-huddle-triage-board/plan.md), +[application boundaries](specs/001-huddle-triage-board/contracts/application-interfaces.md), and +[evaluation posture](specs/001-huddle-triage-board/contracts/eval-harness.md). + +## Clean local quickstart + +Prerequisites: Node 22, npm, and one clean Supabase project with Postgres 16 + Auth. In Supabase Auth, +create **one private reviewer email/password account** and copy its user UUID. Do not create any real +student records. + +```bash +npm ci +cp .env.example .env +# Fill the two public Auth values, DATABASE_ADMIN_URL, DATABASE_URL, +# HUDDLE_DEMO_AUTH_USER_ID, and HUDDLE_ACKNOWLEDGMENT_KEY_CURRENT. +npm run db:migrate +npm run demo:seed +npm run dev +``` + +Open `http://localhost:3000/login`. The reviewed demo uses +`HUDDLE_DEMO_BOARD_DATE=2026-07-27`, so the fixed CSV remains reproducible rather than drifting with +the wall clock. + +Reset is explicit and scoped to the fixed synthetic demo identity: + +```bash +npm run demo:reset -- --confirm-synthetic-only +``` + +The reset command refuses non-synthetic guide/student identities. Use it only in the dedicated demo +project; it clears that synthetic scope's imports, board runs, evidence opens, and acknowledgments, +then restores the four-person synthetic roster. + +## Fixed portfolio evaluation + +```bash +unset ANTHROPIC_API_KEY +npm run eval:portfolio +npm run verify:quickstart +``` + +`eval:portfolio` regenerates eight deterministic fallback cases in memory and byte-compares them with +[`fallback-corpus.json`](packages/eval/fixtures/portfolio/fallback-corpus.json). Unknown catalog IDs +and unauthorized evidence slots must hard-fail for every case. Output records `modelCalls: 0` and +writes `artifacts/portfolio-eval-report.json`. + +This is evidence for deterministic fallback, grounding, and model-free execution only. It reports no +precision, recall, F1, or diagnosis accuracy. The full simulator/holdout/permutation accuracy gate in +the evaluation contract remains deferred before any pilot or accuracy-backed claim. + +## Vercel + Supabase reviewer deployment + +`vercel.json` builds the npm workspace and packages `apps/web`. Configure these Vercel variables: + +- `NEXT_PUBLIC_SUPABASE_URL`, `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY` +- server-only `DATABASE_URL` +- server-only `HUDDLE_ACKNOWLEDGMENT_KEY_CURRENT` +- `HUDDLE_DEMO_BOARD_DATE=2026-07-27` +- `INTERNAL_REFRESH_SECRET` only if the protected nightly dispatcher will be used + +Do **not** put `DATABASE_ADMIN_URL`, `HUDDLE_DEMO_AUTH_USER_ID`, a Supabase service-role key, or any +credential in browser-visible variables. Run migrations and `demo:seed` from a trusted operator +terminal before deployment. The reviewer-facing `/board` and `/import` operations require the +Supabase account; unauthenticated requests expose no roster, evidence, or freshness. + +After Vercel reports ready: + +```bash +npm run smoke:hosted -- --base-url https://YOUR-PRIVATE-DEMO.vercel.app +``` + +That command checks health/policy output, sign-in, the fixed synthetic CSV, and fail-closed anonymous +board access. The captain still performs the authenticated walkthrough below; the smoke command does +not pretend to replace it. + +## Two-minute reviewer walkthrough + +1. **0:00–0:15 — Sign in.** Note the private Supabase Auth boundary and synthetic-only label. +2. **0:15–0:40 — Import.** Open **Import synthetic CSV**, download/select the fixed file, then + validate. Call out the separate received/accepted/duplicate/unmapped/rejected counts and that + validation stores no activity. +3. **0:40–0:55 — Commit and refresh.** Commit the same bytes, then choose **Refresh board now**. The + deterministic engine publishes one immutable ranked run; no model key is needed. +4. **0:55–1:30 — Use the Evidence Desk.** Open the top report. Read the cause-specific opener, compare + it with the student's own baseline, and expand exact attempts/sessions. Severity determines rank; + evidence confidence is shown separately. +5. **1:30–1:45 — Show trust behavior.** Point to “Deterministic fallback · degraded” and the visible + “Seen” acknowledgment. Refresh/back navigation keeps the report and seen state. +6. **1:45–2:00 — Show engineering evidence.** Run `npm run eval:portfolio`: eight fixed cases, hard-fail + grounding injections, zero model calls, and an explicit no-accuracy-claim posture. + +## AI-assisted development workflow + +The work was shaped spec-first, split into enforceable package boundaries, reviewed in independent +agent passes, and checked with deterministic negative dependency tests. AI accelerated exploration, +implementation, and review; committed contracts, tests, fixed artifacts, SQL constraints, and human +walkthrough evidence remain the acceptance authority. diff --git a/apps/web/app/board/evidence/[entry_id]/page.tsx b/apps/web/app/board/evidence/[entry_id]/page.tsx index 1debbef..0f60876 100644 --- a/apps/web/app/board/evidence/[entry_id]/page.tsx +++ b/apps/web/app/board/evidence/[entry_id]/page.tsx @@ -1,25 +1,16 @@ +import { redirect } from 'next/navigation'; import { resolveGuideAccess } from '../../../../lib/guide-access'; export const dynamic = 'force-dynamic'; -export default async function EvidencePage({ params }: { params: { entry_id: string } }) { +/** Legacy deep links re-enter the one EvidenceReader + visible-open acknowledgment path. */ +export default async function EvidencePage({ params }: { params: Promise<{ 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 -

-
- ); + if (!access) redirect('/login'); + const { entry_id: entryId } = await params; + const { getExactEvidenceForGuide } = await import('@huddle/db/scoped.js'); + const result = await getExactEvidenceForGuide(access, entryId); + if (!result) redirect('/board'); + const query = new URLSearchParams({ run: result.boardRunId, entry: result.triageEntryId }); + redirect(`/board?${query.toString()}`); } diff --git a/apps/web/app/board/page.tsx b/apps/web/app/board/page.tsx index 8301a97..35fc6f5 100644 --- a/apps/web/app/board/page.tsx +++ b/apps/web/app/board/page.tsx @@ -1,6 +1,11 @@ import { resolveGuideAccess } from '../../lib/guide-access'; +import { configuredBoardDate } from '../../lib/demo-config'; +import { boardReader, evidenceReaderForRequest } from '../../lib/evidence-desk-operations'; +import { + createAcknowledgeVisibleOpenAction, + createAuthorizeVisibleOpenAction, +} from './acknowledge-visible-open'; import { EvidenceDesk } from './evidence-desk'; -import { chicagoBoardDate } from './lib/triage'; import { readEvidenceDeskState } from './lib/evidence-desk-state'; export const metadata = { title: 'Huddle Evidence Desk' }; @@ -16,20 +21,33 @@ const deskStyles = ` export default async function BoardPage({ searchParams, }: { - searchParams: { entry?: string; run?: string }; + searchParams: Promise<{ entry?: string; run?: string }>; }) { + const query = await searchParams; + const evidenceReader = evidenceReaderForRequest(); const state = await readEvidenceDeskState( - { resolveAccess: resolveGuideAccess }, + { resolveAccess: resolveGuideAccess, boardReader, evidenceReader: evidenceReader ?? undefined }, { - boardDate: chicagoBoardDate(new Date()), - boardRunId: searchParams.run, - triageEntryId: searchParams.entry, + boardDate: configuredBoardDate(), + boardRunId: query.run, + triageEntryId: query.entry, } ); + const actionDependencies = evidenceReader + ? { resolveAccess: resolveGuideAccess, evidenceReader } + : null; return ( <> - + ); } diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css new file mode 100644 index 0000000..a4dca0c --- /dev/null +++ b/apps/web/app/globals.css @@ -0,0 +1,242 @@ +:root { + --huddle-ink: #241c1f; + --huddle-muted: #675c61; + --huddle-brand: #9a2148; + --huddle-brand-dark: #771735; + --huddle-soft: #fff5f7; + --huddle-line: #e7dce0; + --huddle-blue: #1757a6; +} + +* { + box-sizing: border-box; +} +body { + margin: 0; + color: var(--huddle-ink); + background: #fffdfd; + font-family: Inter, ui-sans-serif, system-ui, sans-serif; +} +a { + color: var(--huddle-blue); +} +button, +input { + font: inherit; +} +button { + cursor: pointer; +} +:focus-visible { + outline: 3px solid #2767bd; + outline-offset: 3px; +} +.app-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + min-height: 66px; + padding: 0 max(20px, calc((100vw - 1180px) / 2)); + border-bottom: 1px solid var(--huddle-line); + background: rgba(255, 255, 255, 0.96); +} +.app-brand { + color: var(--huddle-ink); + font-size: 1.1rem; + font-weight: 900; + letter-spacing: -0.03em; + text-decoration: none; +} +.app-brand span { + color: var(--huddle-brand); +} +.app-nav { + display: flex; + flex-wrap: wrap; + gap: 18px; +} +.app-nav a { + color: var(--huddle-muted); + font-size: 0.9rem; + font-weight: 750; + text-decoration: none; +} +.app-nav a:hover { + color: var(--huddle-brand); +} +.site-main { + width: min(1120px, calc(100% - 32px)); + margin: 0 auto; + padding: 64px 0; +} +.hero { + display: grid; + grid-template-columns: 1.35fr 0.8fr; + gap: 48px; + align-items: center; + min-height: 62vh; +} +.eyebrow-global { + margin: 0 0 10px; + color: var(--huddle-brand); + font-size: 0.78rem; + font-weight: 900; + letter-spacing: 0.1em; + text-transform: uppercase; +} +.hero h1, +.card h1 { + margin: 0; + font-size: clamp(2.3rem, 6vw, 4.8rem); + line-height: 0.98; + letter-spacing: -0.055em; +} +.hero h1 span { + color: var(--huddle-brand); +} +.hero-copy { + max-width: 43rem; + margin: 24px 0; + color: var(--huddle-muted); + font-size: 1.15rem; + line-height: 1.65; +} +.actions { + display: flex; + flex-wrap: wrap; + gap: 12px; +} +.button, +button { + display: inline-flex; + justify-content: center; + align-items: center; + min-height: 44px; + padding: 10px 16px; + border: 1px solid var(--huddle-brand); + border-radius: 9px; + background: var(--huddle-brand); + color: white; + font-weight: 800; + text-decoration: none; +} +.button:hover, +button:hover { + background: var(--huddle-brand-dark); +} +.button.secondary { + background: white; + color: var(--huddle-brand); +} +.promise-card, +.card { + border: 1px solid var(--huddle-line); + border-radius: 18px; + background: white; + box-shadow: 0 20px 60px rgba(55, 25, 35, 0.08); +} +.promise-card { + padding: 26px; +} +.promise-card h2 { + margin-top: 0; +} +.promise-list { + display: grid; + gap: 16px; + padding: 0; + list-style: none; +} +.promise-list li { + padding-left: 26px; + position: relative; + color: var(--huddle-muted); + line-height: 1.45; +} +.promise-list li::before { + content: '✓'; + position: absolute; + left: 0; + color: #176c4b; + font-weight: 900; +} +.card { + max-width: 760px; + margin: 34px auto; + padding: clamp(24px, 5vw, 48px); +} +.card h1 { + font-size: clamp(2rem, 5vw, 3.4rem); +} +.card p { + color: var(--huddle-muted); + line-height: 1.55; +} +.form-stack, +.import-workflow { + display: grid; + gap: 15px; + margin-top: 28px; +} +.form-stack label, +.import-workflow label { + display: grid; + gap: 7px; + color: var(--huddle-ink); + font-weight: 750; +} +.form-stack input, +.import-workflow input { + width: 100%; + min-height: 46px; + padding: 10px 12px; + border: 1px solid #cfc2c7; + border-radius: 8px; + background: white; +} +.import-workflow dl { + display: grid; + grid-template-columns: 1fr auto; + gap: 8px 20px; + margin: 0; + padding: 18px; + border-radius: 12px; + background: var(--huddle-soft); +} +.import-workflow dt { + color: var(--huddle-muted); +} +.import-workflow dd { + margin: 0; + font-weight: 850; + font-variant-numeric: tabular-nums; +} +.policy-note { + padding: 13px 15px; + border: 1px solid #b8d1ee; + border-radius: 10px; + background: #edf5ff; + color: #234a78 !important; +} +.app-footer { + padding: 28px 20px 42px; + border-top: 1px solid var(--huddle-line); + color: var(--huddle-muted); + text-align: center; + font-size: 0.82rem; +} +@media (max-width: 760px) { + .app-header { + align-items: flex-start; + flex-direction: column; + padding: 15px 16px; + } + .hero { + grid-template-columns: 1fr; + min-height: auto; + } + .site-main { + padding-top: 36px; + } +} diff --git a/apps/web/app/health/route.ts b/apps/web/app/health/route.ts new file mode 100644 index 0000000..db843d7 --- /dev/null +++ b/apps/web/app/health/route.ts @@ -0,0 +1,11 @@ +export const dynamic = 'force-dynamic'; + +export function GET() { + return Response.json({ + ok: true, + service: 'huddle', + dataPolicy: 'synthetic-only', + decisionSupport: 'deterministic', + accuracyClaim: 'none', + }); +} diff --git a/apps/web/app/import/actions.ts b/apps/web/app/import/actions.ts index 9602bec..eed6685 100644 --- a/apps/web/app/import/actions.ts +++ b/apps/web/app/import/actions.ts @@ -37,12 +37,15 @@ export async function requestManualRefreshAction(): Promise<{ 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 [{ compilerFor }, { configuredBoardDate }] = await Promise.all([ + import('../../lib/operations'), + import('../../lib/demo-config'), + ]); const compiler = compilerFor(); const requested = await compiler.request({ trigger: 'manual', access, - boardDate: chicagoBoardDate(), + boardDate: configuredBoardDate(), }); if (requested.state === 'succeeded') return { kind: 'succeeded', message: 'The current board is already refreshed.' }; diff --git a/apps/web/app/import/import-workflow.tsx b/apps/web/app/import/import-workflow.tsx index a4b20b2..da373ea 100644 --- a/apps/web/app/import/import-workflow.tsx +++ b/apps/web/app/import/import-workflow.tsx @@ -9,6 +9,7 @@ export function ImportWorkflow() { const [preview, setPreview] = useState(null); const [receipt, setReceipt] = useState(null); const [error, setError] = useState(null); + const [refreshMessage, setRefreshMessage] = useState(null); const [pending, startTransition] = useTransition(); const [idempotencyKey] = useState(() => crypto.randomUUID()); const file = () => input.current?.files?.[0] ?? null; @@ -22,6 +23,7 @@ export function ImportWorkflow() { const validate = () => startTransition(async () => { setError(null); + setRefreshMessage(null); setReceipt(null); setPreview(null); if (!file()) { @@ -44,11 +46,14 @@ export function ImportWorkflow() { }); const result = receipt ?? preview; return ( -
+

Import synthetic activity

- Use the fixed demo CSV only. Validation never publishes activity; refresh is a separate - action. + Use the fixed reviewed CSV only. Validation never publishes activity; commit rechecks the + same bytes, and refresh is a separate action. +

+

+ The adapter requires dataset_kind=synthetic and a fixed pseudonymous roster.

@@ -65,6 +70,7 @@ export function ImportWorkflow() { setPreview(null); setReceipt(null); setError(null); + setRefreshMessage(null); }} /> ) : null} {error ?

{error}

: null} + {refreshMessage ? ( +

+ {refreshMessage} Open the ranked Evidence Desk. +

+ ) : null} {result ? ( <>

{receipt ? 'Import committed' : 'Validation preview'}

diff --git a/apps/web/app/import/page.tsx b/apps/web/app/import/page.tsx index bd0c75b..09965c4 100644 --- a/apps/web/app/import/page.tsx +++ b/apps/web/app/import/page.tsx @@ -1,9 +1,30 @@ +import { resolveGuideAccess } from '../../lib/guide-access'; import { ImportWorkflow } from './import-workflow'; -export default function ImportPage() { +export const dynamic = 'force-dynamic'; + +export default async function ImportPage() { + const access = await resolveGuideAccess(); + if (!access) { + return ( +
+
+

Private reviewer access

+

Import unavailable

+

Sign in with the provisioned synthetic guide account before importing activity.

+ + Reviewer sign in + +
+
+ ); + } return ( -
- +
+
+

Synthetic-only operations

+ +
); } diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 0949612..967cf18 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -1,12 +1,30 @@ +import './globals.css'; + export const metadata = { title: 'Huddle', - description: 'Morning Triage Board', + description: 'Synthetic-only deterministic morning triage for guides and coaches', }; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - {children} + +
+ + Huddle + + +
+ {children} +
+ Private portfolio demo · synthetic student data only · deterministic decision support · no + accuracy claim +
+ ); } diff --git a/apps/web/app/login/page.tsx b/apps/web/app/login/page.tsx index 4cdcc65..e4b1b14 100644 --- a/apps/web/app/login/page.tsx +++ b/apps/web/app/login/page.tsx @@ -1,12 +1,20 @@ import { SignInForm } from './sign-in-form'; -export const metadata = { title: 'Sign in — Huddle' }; +export const metadata = { title: 'Reviewer sign in — Huddle' }; export default function LoginPage() { return ( -
-

Sign in to Huddle

- +
+
+

Private reviewer access

+

Sign in to Huddle

+

+ Use the provisioned synthetic guide account. Authentication is handled by Supabase; board + data is read only by the server through the scoped PostgreSQL data layer. +

+

No real student data belongs in this demo.

+ +
); } diff --git a/apps/web/app/login/sign-in-form.tsx b/apps/web/app/login/sign-in-form.tsx index 7ebbf66..d2afb3f 100644 --- a/apps/web/app/login/sign-in-form.tsx +++ b/apps/web/app/login/sign-in-form.tsx @@ -28,7 +28,7 @@ export function SignInForm() { } return ( -
+ diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 5795d78..48b6303 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -1,3 +1,38 @@ export default function Home() { - return
Huddle
; + return ( +
+
+
+

Texas Sports Academy work sample · synthetic-only

+

+ Put adult attention where it can change the morning. +

+

+ Huddle turns adaptive-academic attempt data into a ranked guide queue: who to see, why + the evidence says now, and one concrete opening sentence. The judgment is deterministic; + optional AI can change wording, never the decision. +

+ +
+ +
+
+ ); } diff --git a/apps/web/lib/demo-config.ts b/apps/web/lib/demo-config.ts new file mode 100644 index 0000000..bc2081a --- /dev/null +++ b/apps/web/lib/demo-config.ts @@ -0,0 +1,15 @@ +import { chicagoBoardDate } from '@huddle/application'; + +/** A fixed date keeps the reviewed CSV useful after deployment; unset keeps normal Chicago today. */ +export function configuredBoardDate(now: Date = new Date()): string { + const configured = process.env.HUDDLE_DEMO_BOARD_DATE; + if (configured === undefined || configured === '') return chicagoBoardDate(now); + if (!/^\d{4}-\d{2}-\d{2}$/.test(configured)) { + throw new Error('HUDDLE_DEMO_BOARD_DATE must be YYYY-MM-DD.'); + } + const parsed = new Date(`${configured}T00:00:00.000Z`); + if (Number.isNaN(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== configured) { + throw new Error('HUDDLE_DEMO_BOARD_DATE must be a real calendar date.'); + } + return configured; +} diff --git a/apps/web/lib/evidence-desk-operations.ts b/apps/web/lib/evidence-desk-operations.ts new file mode 100644 index 0000000..4f9490a --- /dev/null +++ b/apps/web/lib/evidence-desk-operations.ts @@ -0,0 +1,43 @@ +import 'server-only'; + +import { randomUUID } from 'node:crypto'; +import { + createBoardReader, + createEvidenceReader, + type BoardReader, + type EvidenceReader, +} from '@huddle/application'; +import { createServerAcknowledgmentTokenService } from './acknowledgment-tokens'; + +export const boardReader: BoardReader = createBoardReader({ + async read(access, boardDate) { + const { getBoardViewForGuide } = await import('@huddle/db/scoped.js'); + return getBoardViewForGuide(access, boardDate); + }, +}); + +/** Returns null rather than exposing evidence when the private signing key is not configured. */ +export function evidenceReaderForRequest(): EvidenceReader | null { + const tokens = createServerAcknowledgmentTokenService(); + if (!tokens) return null; + return createEvidenceReader({ + tokens, + newOpeningId: randomUUID, + evidence: { + async findAuthorizedEvidence(access, input, intent) { + const { findAuthorizedEvidence } = await import('@huddle/db/evidence.js'); + return findAuthorizedEvidence(access, input, intent); + }, + async acquireVisibleRevealLease(access, input) { + const { acquireVisibleRevealLease } = await import('@huddle/db/evidence.js'); + return acquireVisibleRevealLease(access, input); + }, + }, + acknowledgments: { + async consumeVisibleOpening(input) { + const { createDbAcknowledgmentLedger } = await import('@huddle/db/acknowledgement.js'); + return createDbAcknowledgmentLedger().consumeVisibleOpening(input); + }, + }, + }); +} diff --git a/apps/web/lib/operations.ts b/apps/web/lib/operations.ts index a5f6f29..29f7c69 100644 --- a/apps/web/lib/operations.ts +++ b/apps/web/lib/operations.ts @@ -12,7 +12,7 @@ import { } from '@huddle/application'; import type { Attempt, EvidenceBundle } from '@huddle/core'; import { items, skillPrereqs, skills } from '@huddle/core/seed'; -import { deterministicFallback } from '@huddle/narrator'; +import { deterministicFallback } from '@huddle/narrator/catalog.js'; import { allRules, compareSignals, runEngine } from '@huddle/signal-engine'; import { RULE_CONFIG } from '@huddle/signal-engine/config/thresholds.js'; import { diff --git a/apps/web/lib/supabase/server.ts b/apps/web/lib/supabase/server.ts index 6b8af4b..d67cfbc 100644 --- a/apps/web/lib/supabase/server.ts +++ b/apps/web/lib/supabase/server.ts @@ -17,7 +17,7 @@ export async function getVerifiedAuthUserId(): Promise { const config = publicSupabaseConfig(); if (!config) return null; - const cookieStore = cookies(); + const cookieStore = await cookies(); const supabase = createServerClient(config.url, config.publishableKey, { cookies: { getAll() { diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts new file mode 100644 index 0000000..830fb59 --- /dev/null +++ b/apps/web/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/web/next.config.js b/apps/web/next.config.js index 4db7937..649380f 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -1,8 +1,31 @@ +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); + /** @type {import('next').NextConfig} */ const nextConfig = { reactStrictMode: true, + poweredByHeader: false, + outputFileTracingRoot: join(here, '../..'), // Transport is deliberately above the 5 MiB application limit for multipart overhead. experimental: { serverActions: { bodySizeLimit: '6mb' } }, + async headers() { + return [ + { + source: '/(.*)', + headers: [ + { key: 'X-Content-Type-Options', value: 'nosniff' }, + { key: 'X-Frame-Options', value: 'DENY' }, + { key: 'Referrer-Policy', value: 'no-referrer' }, + { + key: 'Permissions-Policy', + value: 'camera=(), microphone=(), geolocation=()', + }, + ], + }, + ]; + }, }; export default nextConfig; diff --git a/apps/web/package.json b/apps/web/package.json index 861b487..96fa320 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -17,7 +17,7 @@ "@huddle/signal-engine": "^0.1.0", "@supabase/ssr": "^0.5.2", "@supabase/supabase-js": "^2.111.0", - "next": "^14.2.5", + "next": "15.5.22", "react": "^18.3.0", "react-dom": "^18.3.0", "zod": "^3.23.0" diff --git a/apps/web/public/synthetic-huddle-sample.csv b/apps/web/public/synthetic-huddle-sample.csv index 3b790ec..e0d0990 100644 --- a/apps/web/public/synthetic-huddle-sample.csv +++ b/apps/web/public/synthetic-huddle-sample.csv @@ -1,4 +1,53 @@ 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 +synthetic,avery-01,synthetic-student-01,TEKS.4.3E,mcq:TEKS.4.3E-01,avery-01-session,2026-07-08T08:00:00Z,2026-07-08T08:01:00Z,,,1,2026-07-08T08:00:00Z,2026-07-08T08:01:00Z,60000,60000,engaged,true,B,0 +synthetic,avery-02,synthetic-student-01,TEKS.4.3E,mcq:TEKS.4.3E-01,avery-02-session,2026-07-09T08:00:00Z,2026-07-09T08:01:00Z,,,1,2026-07-09T08:00:00Z,2026-07-09T08:01:00Z,62000,62000,engaged,true,B,0 +synthetic,avery-03,synthetic-student-01,TEKS.4.3E,mcq:TEKS.4.3E-01,avery-03-session,2026-07-10T08:00:00Z,2026-07-10T08:01:00Z,,,1,2026-07-10T08:00:00Z,2026-07-10T08:01:00Z,58000,58000,engaged,true,B,0 +synthetic,avery-04,synthetic-student-01,TEKS.4.3E,mcq:TEKS.4.3E-01,avery-04-session,2026-07-11T08:00:00Z,2026-07-11T08:01:00Z,,,1,2026-07-11T08:00:00Z,2026-07-11T08:01:00Z,61000,61000,engaged,true,B,0 +synthetic,avery-05,synthetic-student-01,TEKS.4.3E,mcq:TEKS.4.3E-01,avery-05-session,2026-07-12T08:00:00Z,2026-07-12T08:01:00Z,,,1,2026-07-12T08:00:00Z,2026-07-12T08:01:00Z,59000,59000,engaged,true,B,0 +synthetic,avery-06,synthetic-student-01,TEKS.4.3E,mcq:TEKS.4.3E-01,avery-06-session,2026-07-13T08:00:00Z,2026-07-13T08:01:00Z,,,1,2026-07-13T08:00:00Z,2026-07-13T08:01:00Z,60000,60000,engaged,true,B,0 +synthetic,avery-07,synthetic-student-01,TEKS.4.3E,mcq:TEKS.4.3E-01,avery-07-session,2026-07-14T08:00:00Z,2026-07-14T08:01:00Z,,,1,2026-07-14T08:00:00Z,2026-07-14T08:01:00Z,60500,60500,engaged,true,B,0 +synthetic,avery-08,synthetic-student-01,TEKS.4.3E,mcq:TEKS.4.3E-01,avery-08-session,2026-07-15T08:00:00Z,2026-07-15T08:01:00Z,,,1,2026-07-15T08:00:00Z,2026-07-15T08:01:00Z,61500,61500,engaged,true,B,0 +synthetic,avery-09,synthetic-student-01,TEKS.4.3E,mcq:TEKS.4.3E-01,avery-09-session,2026-07-22T08:00:00Z,2026-07-22T08:00:10Z,,,1,2026-07-22T08:00:00Z,2026-07-22T08:00:10Z,10000,10000,engaged,false,A,0 +synthetic,avery-10,synthetic-student-01,TEKS.4.3E,mcq:TEKS.4.3E-01,avery-10-session,2026-07-22T08:05:00Z,2026-07-22T08:05:10Z,,,1,2026-07-22T08:05:00Z,2026-07-22T08:05:10Z,9500,9500,engaged,false,C,0 +synthetic,avery-11,synthetic-student-01,TEKS.4.3E,mcq:TEKS.4.3E-01,avery-11-session,2026-07-23T08:00:00Z,2026-07-23T08:00:09Z,,,1,2026-07-23T08:00:00Z,2026-07-23T08:00:09Z,9000,9000,engaged,false,D,0 +synthetic,avery-12,synthetic-student-01,TEKS.4.3E,mcq:TEKS.4.3E-01,avery-12-session,2026-07-23T08:10:00Z,2026-07-23T08:10:10Z,,,1,2026-07-23T08:10:00Z,2026-07-23T08:10:10Z,10500,10500,engaged,false,A,0 +synthetic,avery-13,synthetic-student-01,TEKS.4.3E,mcq:TEKS.4.3E-01,avery-13-session,2026-07-24T08:00:00Z,2026-07-24T08:01:20Z,,,1,2026-07-24T08:00:00Z,2026-07-24T08:01:20Z,80000,80000,engaged,true,B,0 +synthetic,blake-01,synthetic-student-02,TEKS.4.3E,mcq:TEKS.4.3E-01,blake-01-session,2026-07-08T08:00:00Z,2026-07-08T08:01:00Z,,,1,2026-07-08T08:00:00Z,2026-07-08T08:01:00Z,60000,60000,engaged,true,B,0 +synthetic,blake-02,synthetic-student-02,TEKS.4.3E,mcq:TEKS.4.3E-01,blake-02-session,2026-07-09T08:00:00Z,2026-07-09T08:01:00Z,,,1,2026-07-09T08:00:00Z,2026-07-09T08:01:00Z,62000,62000,engaged,true,B,0 +synthetic,blake-03,synthetic-student-02,TEKS.4.3E,mcq:TEKS.4.3E-01,blake-03-session,2026-07-10T08:00:00Z,2026-07-10T08:01:00Z,,,1,2026-07-10T08:00:00Z,2026-07-10T08:01:00Z,58000,58000,engaged,true,B,0 +synthetic,blake-04,synthetic-student-02,TEKS.4.3E,mcq:TEKS.4.3E-01,blake-04-session,2026-07-11T08:00:00Z,2026-07-11T08:01:00Z,,,1,2026-07-11T08:00:00Z,2026-07-11T08:01:00Z,61000,61000,engaged,true,B,0 +synthetic,blake-05,synthetic-student-02,TEKS.4.3E,mcq:TEKS.4.3E-01,blake-05-session,2026-07-12T08:00:00Z,2026-07-12T08:01:00Z,,,1,2026-07-12T08:00:00Z,2026-07-12T08:01:00Z,59000,59000,engaged,true,B,0 +synthetic,blake-06,synthetic-student-02,TEKS.4.3E,mcq:TEKS.4.3E-01,blake-06-session,2026-07-13T08:00:00Z,2026-07-13T08:01:00Z,,,1,2026-07-13T08:00:00Z,2026-07-13T08:01:00Z,60000,60000,engaged,true,B,0 +synthetic,blake-07,synthetic-student-02,TEKS.4.3E,mcq:TEKS.4.3E-01,blake-07-session,2026-07-14T08:00:00Z,2026-07-14T08:01:00Z,,,1,2026-07-14T08:00:00Z,2026-07-14T08:01:00Z,60500,60500,engaged,true,B,0 +synthetic,blake-08,synthetic-student-02,TEKS.4.3E,mcq:TEKS.4.3E-01,blake-08-session,2026-07-15T08:00:00Z,2026-07-15T08:01:00Z,,,1,2026-07-15T08:00:00Z,2026-07-15T08:01:00Z,61500,61500,engaged,true,B,0 +synthetic,blake-09,synthetic-student-02,TEKS.4.3E,mcq:TEKS.4.3E-01,blake-09-session,2026-07-22T08:00:00Z,2026-07-22T08:00:50Z,,,1,2026-07-22T08:00:00Z,2026-07-22T08:00:50Z,54500,54500,engaged,false,A,0 +synthetic,blake-10,synthetic-student-02,TEKS.4.3E,mcq:TEKS.4.3E-01,blake-10-session,2026-07-22T08:10:00Z,2026-07-22T08:10:50Z,,,1,2026-07-22T08:10:00Z,2026-07-22T08:10:50Z,55000,55000,engaged,false,A,0 +synthetic,blake-11,synthetic-student-02,TEKS.4.3E,mcq:TEKS.4.3E-01,blake-11-session,2026-07-23T08:00:00Z,2026-07-23T08:00:50Z,,,1,2026-07-23T08:00:00Z,2026-07-23T08:00:50Z,55500,55500,engaged,false,A,0 +synthetic,blake-12,synthetic-student-02,TEKS.4.3E,mcq:TEKS.4.3E-01,blake-12-session,2026-07-24T08:00:00Z,2026-07-24T08:01:00Z,,,1,2026-07-24T08:00:00Z,2026-07-24T08:01:00Z,56000,56000,engaged,false,A,0 +synthetic,blake-13,synthetic-student-02,TEKS.4.3E,mcq:TEKS.4.3E-01,blake-13-session,2026-07-25T08:00:00Z,2026-07-25T08:01:00Z,,,1,2026-07-25T08:00:00Z,2026-07-25T08:01:00Z,56500,56500,engaged,false,A,0 +synthetic,casey-baseline-01,synthetic-student-03,TEKS.4.2A,mcq:TEKS.4.2A-01,casey-baseline-01-session,2026-07-08T08:00:00Z,2026-07-08T08:01:00Z,,,1,2026-07-08T08:00:00Z,2026-07-08T08:01:00Z,60000,60000,engaged,true,A,0 +synthetic,casey-baseline-02,synthetic-student-03,TEKS.4.2A,mcq:TEKS.4.2A-01,casey-baseline-02-session,2026-07-09T08:00:00Z,2026-07-09T08:01:00Z,,,1,2026-07-09T08:00:00Z,2026-07-09T08:01:00Z,62000,62000,engaged,true,A,0 +synthetic,casey-baseline-03,synthetic-student-03,TEKS.4.2A,mcq:TEKS.4.2A-01,casey-baseline-03-session,2026-07-10T08:00:00Z,2026-07-10T08:01:00Z,,,1,2026-07-10T08:00:00Z,2026-07-10T08:01:00Z,58000,58000,engaged,true,A,0 +synthetic,casey-baseline-04,synthetic-student-03,TEKS.4.2A,mcq:TEKS.4.2A-01,casey-baseline-04-session,2026-07-11T08:00:00Z,2026-07-11T08:01:00Z,,,1,2026-07-11T08:00:00Z,2026-07-11T08:01:00Z,61000,61000,engaged,true,A,0 +synthetic,casey-baseline-05,synthetic-student-03,TEKS.4.2A,mcq:TEKS.4.2A-01,casey-baseline-05-session,2026-07-12T08:00:00Z,2026-07-12T08:01:00Z,,,1,2026-07-12T08:00:00Z,2026-07-12T08:01:00Z,59000,59000,engaged,true,A,0 +synthetic,casey-baseline-06,synthetic-student-03,TEKS.4.2A,mcq:TEKS.4.2A-01,casey-baseline-06-session,2026-07-13T08:00:00Z,2026-07-13T08:01:00Z,,,1,2026-07-13T08:00:00Z,2026-07-13T08:01:00Z,60000,60000,engaged,true,A,0 +synthetic,casey-baseline-07,synthetic-student-03,TEKS.4.2A,mcq:TEKS.4.2A-01,casey-baseline-07-session,2026-07-14T08:00:00Z,2026-07-14T08:01:00Z,,,1,2026-07-14T08:00:00Z,2026-07-14T08:01:00Z,60500,60500,engaged,true,A,0 +synthetic,casey-baseline-08,synthetic-student-03,TEKS.4.2A,mcq:TEKS.4.2A-01,casey-baseline-08-session,2026-07-15T08:00:00Z,2026-07-15T08:01:00Z,,,1,2026-07-15T08:00:00Z,2026-07-15T08:01:00Z,61500,61500,engaged,true,A,0 +synthetic,casey-current-01,synthetic-student-03,TEKS.4.2A,mcq:TEKS.4.2A-01,casey-current-01-session,2026-07-22T14:00:00.000Z,2026-07-22T14:01:00.000Z,,,1,2026-07-22T14:00:00.000Z,2026-07-22T14:01:00.000Z,60000,60000,engaged,false,B,2 +synthetic,casey-current-02,synthetic-student-03,TEKS.4.2A,mcq:TEKS.4.2A-01,casey-current-02-session,2026-07-23T14:00:00.000Z,2026-07-23T14:01:00.000Z,,,1,2026-07-23T14:00:00.000Z,2026-07-23T14:01:00.000Z,60000,60000,engaged,false,B,2 +synthetic,casey-current-03,synthetic-student-03,TEKS.4.2A,mcq:TEKS.4.2A-01,casey-current-03-session,2026-07-24T14:00:00.000Z,2026-07-24T14:01:00.000Z,,,1,2026-07-24T14:00:00.000Z,2026-07-24T14:01:00.000Z,60000,60000,engaged,false,B,2 +synthetic,casey-current-04,synthetic-student-03,TEKS.4.2A,mcq:TEKS.4.2A-01,casey-current-04-session,2026-07-25T14:00:00.000Z,2026-07-25T14:01:00.000Z,,,1,2026-07-25T14:00:00.000Z,2026-07-25T14:01:00.000Z,60000,60000,engaged,false,B,2 +synthetic,casey-current-05,synthetic-student-03,TEKS.4.2A,mcq:TEKS.4.2A-01,casey-current-05-session,2026-07-26T14:00:00.000Z,2026-07-26T14:01:00.000Z,,,1,2026-07-26T14:00:00.000Z,2026-07-26T14:01:00.000Z,60000,60000,engaged,false,B,2 +synthetic,drew-baseline-01,synthetic-student-04,TEKS.4.2A,mcq:TEKS.4.2A-01,drew-baseline-01-session,2026-07-08T08:00:00Z,2026-07-08T08:01:00Z,,,1,2026-07-08T08:00:00Z,2026-07-08T08:01:00Z,60000,60000,engaged,true,A,0 +synthetic,drew-baseline-02,synthetic-student-04,TEKS.4.2A,mcq:TEKS.4.2A-01,drew-baseline-02-session,2026-07-09T08:00:00Z,2026-07-09T08:01:00Z,,,1,2026-07-09T08:00:00Z,2026-07-09T08:01:00Z,62000,62000,engaged,true,A,0 +synthetic,drew-baseline-03,synthetic-student-04,TEKS.4.2A,mcq:TEKS.4.2A-01,drew-baseline-03-session,2026-07-10T08:00:00Z,2026-07-10T08:01:00Z,,,1,2026-07-10T08:00:00Z,2026-07-10T08:01:00Z,58000,58000,engaged,true,A,0 +synthetic,drew-baseline-04,synthetic-student-04,TEKS.4.2A,mcq:TEKS.4.2A-01,drew-baseline-04-session,2026-07-11T08:00:00Z,2026-07-11T08:01:00Z,,,1,2026-07-11T08:00:00Z,2026-07-11T08:01:00Z,61000,61000,engaged,true,A,0 +synthetic,drew-baseline-05,synthetic-student-04,TEKS.4.2A,mcq:TEKS.4.2A-01,drew-baseline-05-session,2026-07-12T08:00:00Z,2026-07-12T08:01:00Z,,,1,2026-07-12T08:00:00Z,2026-07-12T08:01:00Z,59000,59000,engaged,true,A,0 +synthetic,drew-baseline-06,synthetic-student-04,TEKS.4.2A,mcq:TEKS.4.2A-01,drew-baseline-06-session,2026-07-13T08:00:00Z,2026-07-13T08:01:00Z,,,1,2026-07-13T08:00:00Z,2026-07-13T08:01:00Z,60000,60000,engaged,true,A,0 +synthetic,drew-baseline-07,synthetic-student-04,TEKS.4.2A,mcq:TEKS.4.2A-01,drew-baseline-07-session,2026-07-14T08:00:00Z,2026-07-14T08:01:00Z,,,1,2026-07-14T08:00:00Z,2026-07-14T08:01:00Z,60500,60500,engaged,true,A,0 +synthetic,drew-baseline-08,synthetic-student-04,TEKS.4.2A,mcq:TEKS.4.2A-01,drew-baseline-08-session,2026-07-15T08:00:00Z,2026-07-15T08:01:00Z,,,1,2026-07-15T08:00:00Z,2026-07-15T08:01:00Z,61500,61500,engaged,true,A,0 +synthetic,drew-current-01,synthetic-student-04,TEKS.4.2A,mcq:TEKS.4.2A-01,drew-current-01-session,2026-07-22T15:00:00.000Z,2026-07-22T15:01:00.000Z,,,1,2026-07-22T15:00:00.000Z,2026-07-22T15:01:00.000Z,60000,60000,engaged,true,A,0 +synthetic,drew-current-02,synthetic-student-04,TEKS.4.2A,mcq:TEKS.4.2A-01,drew-current-02-session,2026-07-23T15:00:00.000Z,2026-07-23T15:01:00.000Z,,,1,2026-07-23T15:00:00.000Z,2026-07-23T15:01:00.000Z,60000,60000,engaged,true,A,0 +synthetic,drew-current-03,synthetic-student-04,TEKS.4.2A,mcq:TEKS.4.2A-01,drew-current-03-session,2026-07-24T15:00:00.000Z,2026-07-24T15:01:00.000Z,,,1,2026-07-24T15:00:00.000Z,2026-07-24T15:01:00.000Z,60000,60000,engaged,true,A,0 +synthetic,drew-current-04,synthetic-student-04,TEKS.4.2A,mcq:TEKS.4.2A-01,drew-current-04-session,2026-07-25T15:00:00.000Z,2026-07-25T15:01:00.000Z,,,1,2026-07-25T15:00:00.000Z,2026-07-25T15:01:00.000Z,60000,60000,engaged,true,A,0 +synthetic,drew-current-05,synthetic-student-04,TEKS.4.2A,mcq:TEKS.4.2A-01,drew-current-05-session,2026-07-26T15:00:00.000Z,2026-07-26T15:01:00.000Z,,,1,2026-07-26T15:00:00.000Z,2026-07-26T15:01:00.000Z,60000,60000,engaged,true,A,0 diff --git a/apps/web/test/demo-config.test.ts b/apps/web/test/demo-config.test.ts new file mode 100644 index 0000000..673e869 --- /dev/null +++ b/apps/web/test/demo-config.test.ts @@ -0,0 +1,20 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { configuredBoardDate } from '../lib/demo-config'; + +const original = process.env.HUDDLE_DEMO_BOARD_DATE; +afterEach(() => { + if (original === undefined) delete process.env.HUDDLE_DEMO_BOARD_DATE; + else process.env.HUDDLE_DEMO_BOARD_DATE = original; +}); + +describe('portfolio demo board date', () => { + it('pins the reviewed fixed corpus when configured', () => { + process.env.HUDDLE_DEMO_BOARD_DATE = '2026-07-27'; + expect(configuredBoardDate(new Date('2030-01-01T12:00:00Z'))).toBe('2026-07-27'); + }); + + it('rejects invalid configured dates instead of silently reading another board', () => { + process.env.HUDDLE_DEMO_BOARD_DATE = '2026-02-31'; + expect(() => configuredBoardDate()).toThrow(/real calendar date/); + }); +}); diff --git a/apps/web/test/health-route.test.ts b/apps/web/test/health-route.test.ts new file mode 100644 index 0000000..e5944e1 --- /dev/null +++ b/apps/web/test/health-route.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; +import { GET } from '../app/health/route'; + +describe('public health boundary', () => { + it('exposes policy posture without data or credentials', async () => { + const response = GET(); + await expect(response.json()).resolves.toEqual({ + ok: true, + service: 'huddle', + dataPolicy: 'synthetic-only', + decisionSupport: 'deterministic', + accuracyClaim: 'none', + }); + }); +}); diff --git a/db/migrate.ts b/db/migrate.ts index 9d70421..bfb4573 100644 --- a/db/migrate.ts +++ b/db/migrate.ts @@ -1,16 +1,20 @@ import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; -import { runMigrations } from '@huddle/db/client.js'; -const __dirname = dirname(fileURLToPath(import.meta.url)); +const help = `Usage: npm run db:migrate\n\nApplies committed forward-only migrations with DATABASE_ADMIN_URL. The web runtime uses only DATABASE_URL.`; async function main() { - const migrationsDir = join(__dirname, 'migrations'); + if (process.argv.includes('--help')) { + console.log(help); + return; + } + const { runMigrations } = await import('@huddle/db/client.js'); + const migrationsDir = join(dirname(fileURLToPath(import.meta.url)), 'migrations'); const applied = await runMigrations(migrationsDir); - console.log(`Applied migrations: ${applied.join(', ')}`); + console.log(`Applied migrations: ${applied.join(', ') || 'none (already current)'}`); } -main().catch((err) => { - console.error(err); +main().catch((err: unknown) => { + console.error(err instanceof Error ? err.message : 'Migration failed.'); process.exit(1); }); diff --git a/db/migrations/010_evidence_acknowledgment.sql b/db/migrations/010_evidence_acknowledgment.sql new file mode 100644 index 0000000..bb6b68e --- /dev/null +++ b/db/migrations/010_evidence_acknowledgment.sql @@ -0,0 +1,63 @@ +-- Visible evidence opens and acknowledgments. Evidence remains immutable; these tables record only +-- authorization/seen state for the fixed synthetic demo scope. +CREATE TYPE report_grant_disposition AS ENUM ('consumed', 'expired_replaced'); + +CREATE TABLE report_visible_open ( + opening_id uuid PRIMARY KEY, + application_scope_id bigint NOT NULL, + auth_user_id uuid NOT NULL, + guide_id uuid NOT NULL, + studio_id text NOT NULL, + board_date date NOT NULL, + board_run_id bigint NOT NULL, + triage_entry_id bigint NOT NULL, + finding_fingerprint text NOT NULL CHECK (finding_fingerprint ~ '^[0-9a-f]{64}$'), + grant_expires_at timestamptz NOT NULL, + reveal_authorized_at timestamptz NOT NULL DEFAULT clock_timestamp(), + FOREIGN KEY (application_scope_id, auth_user_id) + REFERENCES guide_auth_scope(id, auth_user_id), + FOREIGN KEY (application_scope_id, guide_id, studio_id) + REFERENCES guide_auth_scope(id, guide_id, studio_id), + FOREIGN KEY (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), + FOREIGN KEY (triage_entry_id, board_run_id, finding_fingerprint) + REFERENCES triage_entry(id, board_run_id, finding_fingerprint) +); + +CREATE TABLE report_acknowledgement ( + id bigserial PRIMARY KEY, + application_scope_id bigint NOT NULL, + board_date date NOT NULL, + board_run_id bigint NOT NULL, + triage_entry_id bigint NOT NULL, + finding_fingerprint text NOT NULL CHECK (finding_fingerprint ~ '^[0-9a-f]{64}$'), + opening_id uuid NOT NULL REFERENCES report_visible_open(opening_id), + acknowledged_at timestamptz NOT NULL DEFAULT clock_timestamp(), + UNIQUE (application_scope_id, board_date, finding_fingerprint), + FOREIGN KEY (board_run_id, application_scope_id) + REFERENCES board_run(id, application_scope_id), + FOREIGN KEY (triage_entry_id, board_run_id, finding_fingerprint) + REFERENCES triage_entry(id, board_run_id, finding_fingerprint) +); + +CREATE TABLE report_acknowledgement_grant_use ( + grant_nonce text PRIMARY KEY, + opening_id uuid NOT NULL REFERENCES report_visible_open(opening_id), + acknowledgement_id bigint NOT NULL REFERENCES report_acknowledgement(id), + disposition report_grant_disposition NOT NULL, + replacement_for_nonce text UNIQUE, + grant_expires_at timestamptz NOT NULL, + consumed_at timestamptz NOT NULL DEFAULT clock_timestamp(), + CHECK ( + (disposition = 'consumed') + OR (disposition = 'expired_replaced' AND replacement_for_nonce IS NULL) + ) +); + +-- Runtime may append and read ledger facts, but immutable evidence/open state is never updated or +-- row-locked. SELECT ... FOR UPDATE requires UPDATE privilege and is intentionally unavailable. +REVOKE UPDATE, DELETE ON report_visible_open, report_acknowledgement, + report_acknowledgement_grant_use FROM huddle_app; +GRANT SELECT, INSERT ON report_visible_open, report_acknowledgement, + report_acknowledgement_grant_use TO huddle_app; +GRANT USAGE ON SEQUENCE report_acknowledgement_id_seq TO huddle_app; diff --git a/package-lock.json b/package-lock.json index 5a246ac..c17e75c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,9 @@ }, "engines": { "node": ">=22.0.0" + }, + "optionalDependencies": { + "sharp": "0.35.3" } }, "apps/web": { @@ -36,7 +39,7 @@ "@huddle/signal-engine": "^0.1.0", "@supabase/ssr": "^0.5.2", "@supabase/supabase-js": "^2.111.0", - "next": "^14.2.5", + "next": "15.5.22", "react": "^18.3.0", "react-dom": "^18.3.0", "zod": "^3.23.0" @@ -79,6 +82,16 @@ "node": ">=6.9.0" } }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", @@ -767,6 +780,507 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", + "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", + "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz", + "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", + "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz", + "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz", + "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz", + "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz", + "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz", + "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz", + "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz", + "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz", + "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz", + "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz", + "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz", + "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz", + "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz", + "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz", + "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz", + "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz", + "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz", + "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz", + "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.1" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz", + "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz", + "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz", + "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", + "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -775,15 +1289,15 @@ "license": "MIT" }, "node_modules/@next/env": { - "version": "14.2.35", - "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.35.tgz", - "integrity": "sha512-DuhvCtj4t9Gwrx80dmz2F4t/zKQ4ktN8WrMwOuVzkJfBilwAwGr6v16M5eI8yCuZ63H9TTuEU09Iu2HqkzFPVQ==", + "version": "15.5.22", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.22.tgz", + "integrity": "sha512-O5BlKb3KtsHkvO0gjjV66PuJnAgCtIEIzwkt50HRAHsQkU1t77eksIXSZV84/WMtZJjWrnDUPKHVRi0D62nSAA==", "license": "MIT" }, "node_modules/@next/swc-darwin-arm64": { - "version": "14.2.33", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.33.tgz", - "integrity": "sha512-HqYnb6pxlsshoSTubdXKu15g3iivcbsMXg4bYpjL2iS/V6aQot+iyF4BUc2qA/J/n55YtvE4PHMKWBKGCF/+wA==", + "version": "15.5.22", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.22.tgz", + "integrity": "sha512-/VISwtffSg8+fVvBbXdglsvruCsdbBC4dG25iU6xascKVqfQKsj/OtjGnOEkIS7pX5GB9e9/r5QprpicsGL3gw==", "cpu": [ "arm64" ], @@ -797,9 +1311,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "14.2.33", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.33.tgz", - "integrity": "sha512-8HGBeAE5rX3jzKvF593XTTFg3gxeU4f+UWnswa6JPhzaR6+zblO5+fjltJWIZc4aUalqTclvN2QtTC37LxvZAA==", + "version": "15.5.22", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.22.tgz", + "integrity": "sha512-NiA9ve8hbiuhG/Q17a2mZDRVxMTtg3rTOgjLnDaLlE+AEPAQlkkuKrfePEbeOrgYmX0U2KGX4EVEn09hXU5GlQ==", "cpu": [ "x64" ], @@ -813,9 +1327,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "14.2.33", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.33.tgz", - "integrity": "sha512-JXMBka6lNNmqbkvcTtaX8Gu5by9547bukHQvPoLe9VRBx1gHwzf5tdt4AaezW85HAB3pikcvyqBToRTDA4DeLw==", + "version": "15.5.22", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.22.tgz", + "integrity": "sha512-vAPa9vltW+UW/KWtjXeSUFgV3wb1x9d/BeyC6WFI6eBpL0D2f70oGwtOp6193mNW3qusrpgBzMQferPf+Zh8Dw==", "cpu": [ "arm64" ], @@ -829,9 +1343,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "14.2.33", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.33.tgz", - "integrity": "sha512-Bm+QulsAItD/x6Ih8wGIMfRJy4G73tu1HJsrccPW6AfqdZd0Sfm5Imhgkgq2+kly065rYMnCOxTBvmvFY1BKfg==", + "version": "15.5.22", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.22.tgz", + "integrity": "sha512-iknK80pWlNDnkdSr13bd8mMuG3Z2oTxODwsZHvuMY7caMk77+rBLdHVWsy8v2EVa3ZojJ/+wJX5fnq8va6Gv8A==", "cpu": [ "arm64" ], @@ -845,9 +1359,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "14.2.33", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.33.tgz", - "integrity": "sha512-FnFn+ZBgsVMbGDsTqo8zsnRzydvsGV8vfiWwUo1LD8FTmPTdV+otGSWKc4LJec0oSexFnCYVO4hX8P8qQKaSlg==", + "version": "15.5.22", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.22.tgz", + "integrity": "sha512-penuEdkwU2OOAiS+n4LE8T/VIoCfAI01QcLZTJ2xc3+l4Q22L/DzURocmI2LU1b+8BMQoLAP1Sze3uYAZT05Bg==", "cpu": [ "x64" ], @@ -861,9 +1375,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "14.2.33", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.33.tgz", - "integrity": "sha512-345tsIWMzoXaQndUTDv1qypDRiebFxGYx9pYkhwY4hBRaOLt8UGfiWKr9FSSHs25dFIf8ZqIFaPdy5MljdoawA==", + "version": "15.5.22", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.22.tgz", + "integrity": "sha512-ZM0BKJm3FZ+guG6WT6PcyOLtp6paZ5tngcJC/uUKvLW4Y0TQnnVi1+UGdo8Q6Yxp5gaS82pmC1rD/oFlhkWB3g==", "cpu": [ "x64" ], @@ -877,9 +1391,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "14.2.33", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.33.tgz", - "integrity": "sha512-nscpt0G6UCTkrT2ppnJnFsYbPDQwmum4GNXYTeoTIdsmMydSKFz9Iny2jpaRupTb+Wl298+Rh82WKzt9LCcqSQ==", + "version": "15.5.22", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.22.tgz", + "integrity": "sha512-rY/YaumrZaS0//94BnHLF5VSRp0GFUO4GvXNuoCBb0cGSci96yO+p1JaNL2aq9YZAYv9cuZRziV02x5IQH/wjg==", "cpu": [ "arm64" ], @@ -892,26 +1406,10 @@ "node": ">= 10" } }, - "node_modules/@next/swc-win32-ia32-msvc": { - "version": "14.2.33", - "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.33.tgz", - "integrity": "sha512-pc9LpGNKhJ0dXQhZ5QMmYxtARwwmWLpeocFmVG5Z0DzWq5Uf0izcI8tLc+qOpqxO1PWqZ5A7J1blrUIKrIFc7Q==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "14.2.33", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.33.tgz", - "integrity": "sha512-nOjfZMy8B94MdisuzZo9/57xuFVLHJaDj5e/xrduJp9CV2/HrfxTRH2fbyLe+K9QT41WBLUd4iXX3R7jBp0EUg==", + "version": "15.5.22", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.22.tgz", + "integrity": "sha512-s5IA4cyrbR2XK/5NWcu5dp8CfPBiKME+UhvNperia7uQybEgg5+LIhGMiY37WQE4rcI4owsDcU4IVUjLoTuDkA==", "cpu": [ "x64" ], @@ -1377,20 +1875,13 @@ "node": ">=22.0.0" } }, - "node_modules/@swc/counter": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", - "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", - "license": "Apache-2.0" - }, "node_modules/@swc/helpers": { - "version": "0.5.5", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.5.tgz", - "integrity": "sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==", + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", "license": "Apache-2.0", "dependencies": { - "@swc/counter": "^0.1.3", - "tslib": "^2.4.0" + "tslib": "^2.8.0" } }, "node_modules/@types/cookie": { @@ -1949,17 +2440,6 @@ "concat-map": "0.0.1" } }, - "node_modules/busboy": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", - "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", - "dependencies": { - "streamsearch": "^1.1.0" - }, - "engines": { - "node": ">=10.16.0" - } - }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -2143,6 +2623,16 @@ "dev": true, "license": "MIT" }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -2528,12 +3018,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "license": "ISC" - }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -2805,41 +3289,40 @@ "license": "MIT" }, "node_modules/next": { - "version": "14.2.35", - "resolved": "https://registry.npmjs.org/next/-/next-14.2.35.tgz", - "integrity": "sha512-KhYd2Hjt/O1/1aZVX3dCwGXM1QmOV4eNM2UTacK5gipDdPN/oHHK/4oVGy7X8GMfPMsUTUEmGlsy0EY1YGAkig==", + "version": "15.5.22", + "resolved": "https://registry.npmjs.org/next/-/next-15.5.22.tgz", + "integrity": "sha512-mrtal1sRxO4YrlDS98sDuIvGZivKbFix8w7oAL9ZynfOgc3cADQOQgvwtMooc18Qr8bKzvQAcHwHZ0mbJ7zcfQ==", "license": "MIT", "dependencies": { - "@next/env": "14.2.35", - "@swc/helpers": "0.5.5", - "busboy": "1.6.0", + "@next/env": "15.5.22", + "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", - "graceful-fs": "^4.2.11", "postcss": "8.4.31", - "styled-jsx": "5.1.1" + "styled-jsx": "5.1.6" }, "bin": { "next": "dist/bin/next" }, "engines": { - "node": ">=18.17.0" + "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "14.2.33", - "@next/swc-darwin-x64": "14.2.33", - "@next/swc-linux-arm64-gnu": "14.2.33", - "@next/swc-linux-arm64-musl": "14.2.33", - "@next/swc-linux-x64-gnu": "14.2.33", - "@next/swc-linux-x64-musl": "14.2.33", - "@next/swc-win32-arm64-msvc": "14.2.33", - "@next/swc-win32-ia32-msvc": "14.2.33", - "@next/swc-win32-x64-msvc": "14.2.33" + "@next/swc-darwin-arm64": "15.5.22", + "@next/swc-darwin-x64": "15.5.22", + "@next/swc-linux-arm64-gnu": "15.5.22", + "@next/swc-linux-arm64-musl": "15.5.22", + "@next/swc-linux-x64-gnu": "15.5.22", + "@next/swc-linux-x64-musl": "15.5.22", + "@next/swc-win32-arm64-msvc": "15.5.22", + "@next/swc-win32-x64-msvc": "15.5.22", + "sharp": "^0.34.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", - "@playwright/test": "^1.41.2", - "react": "^18.2.0", - "react-dom": "^18.2.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "peerDependenciesMeta": { @@ -2849,37 +3332,12 @@ "@playwright/test": { "optional": true }, - "sass": { + "babel-plugin-react-compiler": { "optional": true - } - } - }, - "node_modules/next/node_modules/postcss": { - "version": "8.4.31", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", - "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" + "sass": { + "optional": true } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.6", - "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" - }, - "engines": { - "node": "^10 || ^12 || >=14" } }, "node_modules/optionator": { @@ -3091,10 +3549,9 @@ } }, "node_modules/postcss": { - "version": "8.5.23", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", - "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", - "dev": true, + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", "funding": [ { "type": "opencollective", @@ -3287,7 +3744,7 @@ "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, + "devOptional": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -3296,6 +3753,56 @@ "node": ">=10" } }, + "node_modules/sharp": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", + "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.3", + "@img/sharp-darwin-x64": "0.35.3", + "@img/sharp-freebsd-wasm32": "0.35.3", + "@img/sharp-libvips-darwin-arm64": "1.3.2", + "@img/sharp-libvips-darwin-x64": "1.3.2", + "@img/sharp-libvips-linux-arm": "1.3.2", + "@img/sharp-libvips-linux-arm64": "1.3.2", + "@img/sharp-libvips-linux-ppc64": "1.3.2", + "@img/sharp-libvips-linux-riscv64": "1.3.2", + "@img/sharp-libvips-linux-s390x": "1.3.2", + "@img/sharp-libvips-linux-x64": "1.3.2", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", + "@img/sharp-libvips-linuxmusl-x64": "1.3.2", + "@img/sharp-linux-arm": "0.35.3", + "@img/sharp-linux-arm64": "0.35.3", + "@img/sharp-linux-ppc64": "0.35.3", + "@img/sharp-linux-riscv64": "0.35.3", + "@img/sharp-linux-s390x": "0.35.3", + "@img/sharp-linux-x64": "0.35.3", + "@img/sharp-linuxmusl-arm64": "0.35.3", + "@img/sharp-linuxmusl-x64": "0.35.3", + "@img/sharp-webcontainers-wasm32": "0.35.3", + "@img/sharp-win32-arm64": "0.35.3", + "@img/sharp-win32-ia32": "0.35.3", + "@img/sharp-win32-x64": "0.35.3" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -3368,14 +3875,6 @@ "dev": true, "license": "MIT" }, - "node_modules/streamsearch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", - "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", - "engines": { - "node": ">=10.0.0" - } - }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -3390,9 +3889,9 @@ } }, "node_modules/styled-jsx": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.1.tgz", - "integrity": "sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==", + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", "license": "MIT", "dependencies": { "client-only": "0.0.1" @@ -3401,7 +3900,7 @@ "node": ">= 12.0.0" }, "peerDependencies": { - "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0" + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" }, "peerDependenciesMeta": { "@babel/core": { @@ -4231,7 +4730,8 @@ "name": "@huddle/application", "version": "0.1.0", "dependencies": { - "@huddle/core": "^0.1.0" + "@huddle/core": "^0.1.0", + "@huddle/ingest": "^0.1.0" }, "devDependencies": { "@types/node": "^20.14.0", @@ -4256,6 +4756,7 @@ "name": "@huddle/db", "version": "0.1.0", "dependencies": { + "@huddle/application": "^0.1.0", "@huddle/core": "^0.1.0", "pg": "^8.12.0" }, diff --git a/package.json b/package.json index 819da00..97bf2f5 100644 --- a/package.json +++ b/package.json @@ -14,15 +14,16 @@ "test": "vitest run", "dev": "npm run dev -w apps/web", "db:migrate": "tsx db/migrate.ts", - "seed": "tsx scripts/seed.ts", + "seed": "tsx scripts/demo-data.ts seed", + "demo:seed": "tsx scripts/demo-data.ts seed", + "demo:reset": "tsx scripts/demo-data.ts reset", "nightly": "tsx scripts/nightly.ts", - "eval": "tsx packages/eval/src/cli.ts", + "eval": "tsx scripts/portfolio-eval.ts", + "eval:portfolio": "tsx scripts/portfolio-eval.ts", + "verify:quickstart": "tsx scripts/verify-quickstart.ts", + "smoke:hosted": "tsx scripts/smoke-hosted.ts", + "audit:prod": "npm audit --omit=dev", "check:determinism": "tsx scripts/check-determinism.ts", - "verify:scenario": "tsx scripts/verify-scenario.ts", - "verify:traceability": "tsx scripts/verify-traceability.ts", - "verify:mastery-equivalence": "tsx scripts/verify-mastery-equivalence.ts", - "test:second-adapter": "vitest run packages/ingest/test/second-adapter.test.ts", - "test:idempotency": "vitest run packages/ingest/test/idempotency.test.ts", "lint": "eslint .", "format": "prettier --write .", "format:check": "prettier --check ." @@ -39,5 +40,14 @@ }, "engines": { "node": ">=22.0.0" + }, + "overrides": { + "next": { + "postcss": "8.5.25", + "sharp": "0.35.3" + } + }, + "optionalDependencies": { + "sharp": "0.35.3" } } diff --git a/packages/db/package.json b/packages/db/package.json index 6e493b2..e246a2c 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -43,6 +43,10 @@ "./activity-identity.js": { "import": "./dist/src/activity-identity.js", "types": "./dist/src/activity-identity.d.ts" + }, + "./demo-seed.js": { + "import": "./dist/src/demo-seed.js", + "types": "./dist/src/demo-seed.d.ts" } }, "scripts": { diff --git a/packages/db/src/acknowledgement.ts b/packages/db/src/acknowledgement.ts index a5817c1..5fd9909 100644 --- a/packages/db/src/acknowledgement.ts +++ b/packages/db/src/acknowledgement.ts @@ -1,2 +1,183 @@ -/** Reserved DB seam: acknowledgment grants/nonces follow immutable board-run identities. */ -export type AcknowledgementPersistenceModule = 'acknowledgement'; +import type { + AcknowledgmentLedgerPort, + AcknowledgmentTokenClaims, + AcknowledgmentView, +} from '@huddle/application'; +import { pool } from './client.js'; + +interface AcknowledgmentRow { + id: string; + finding_fingerprint: string; + acknowledged_at: Date; +} + +function sameReport( + left: AcknowledgmentTokenClaims, + right: AcknowledgmentTokenClaims, + purpose: AcknowledgmentTokenClaims['purpose'] +): boolean { + return ( + right.purpose === purpose && + left.authUserId === right.authUserId && + left.guideId === right.guideId && + left.studioId === right.studioId && + left.boardDate === right.boardDate && + left.boardRunId === right.boardRunId && + left.triageEntryId === right.triageEntryId && + left.findingFingerprint === right.findingFingerprint && + left.openingId === right.openingId + ); +} + +function view(row: AcknowledgmentRow): AcknowledgmentView { + return { + findingFingerprint: row.finding_fingerprint, + acknowledgedAt: row.acknowledged_at.toISOString(), + }; +} + +/** PostgreSQL-backed, replay-safe acknowledgment ledger. No process-local state is used. */ +export function createDbAcknowledgmentLedger(): AcknowledgmentLedgerPort { + return { + async consumeVisibleOpening(input) { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + await client.query(`SELECT pg_advisory_xact_lock(hashtextextended($1,0))`, [ + input.grant.nonce, + ]); + + const replay = await client.query( + `SELECT acknowledgement.id::text,acknowledgement.finding_fingerprint, + acknowledgement.acknowledged_at + FROM report_acknowledgement_grant_use grant_use + JOIN report_acknowledgement acknowledgement + ON acknowledgement.id=grant_use.acknowledgement_id + WHERE grant_use.grant_nonce=$1`, + [input.grant.nonce] + ); + if (replay.rows[0]) { + await client.query('COMMIT'); + return view(replay.rows[0]); + } + + if (!sameReport(input.grant, input.renewal, 'opening-renewal')) { + await client.query('ROLLBACK'); + return null; + } + + const opening = await client.query<{ application_scope_id: string }>( + `SELECT opening.application_scope_id::text + FROM report_visible_open opening + JOIN guide_auth_scope access_scope ON access_scope.id=opening.application_scope_id + WHERE opening.opening_id=$1::uuid AND opening.auth_user_id=$2::uuid + AND opening.guide_id=$3::uuid AND opening.studio_id=$4 + AND opening.board_date=$5::date AND opening.board_run_id=$6::bigint + AND opening.triage_entry_id=$7::bigint AND opening.finding_fingerprint=$8 + AND access_scope.is_synthetic IS TRUE`, + [ + input.grant.openingId, + input.grant.authUserId, + input.grant.guideId, + input.grant.studioId, + input.grant.boardDate, + input.grant.boardRunId, + input.grant.triageEntryId, + input.grant.findingFingerprint, + ] + ); + if (!opening.rows[0]) { + await client.query('ROLLBACK'); + return null; + } + + const clock = await client.query<{ now: Date }>('SELECT clock_timestamp() AS now'); + if (new Date(input.renewal.expiresAt) <= clock.rows[0]!.now) { + await client.query('ROLLBACK'); + return null; + } + const expired = new Date(input.grant.expiresAt) <= clock.rows[0]!.now; + const replacement = expired ? input.mintReplacementGrant() : null; + if ( + expired && + (!replacement || !sameReport(input.grant, replacement, 'visible-open-grant')) + ) { + await client.query('ROLLBACK'); + return null; + } + + const inserted = await client.query( + `INSERT INTO report_acknowledgement ( + application_scope_id,board_date,board_run_id,triage_entry_id, + finding_fingerprint,opening_id + ) VALUES ($1::bigint,$2::date,$3::bigint,$4::bigint,$5,$6::uuid) + ON CONFLICT (application_scope_id,board_date,finding_fingerprint) DO NOTHING + RETURNING id::text,finding_fingerprint,acknowledged_at`, + [ + opening.rows[0].application_scope_id, + input.grant.boardDate, + input.grant.boardRunId, + input.grant.triageEntryId, + input.grant.findingFingerprint, + input.grant.openingId, + ] + ); + const acknowledgment = + inserted.rows[0] ?? + ( + await client.query( + `SELECT id::text,finding_fingerprint,acknowledged_at + FROM report_acknowledgement + WHERE application_scope_id=$1::bigint AND board_date=$2::date + AND finding_fingerprint=$3`, + [ + opening.rows[0].application_scope_id, + input.grant.boardDate, + input.grant.findingFingerprint, + ] + ) + ).rows[0]; + if (!acknowledgment) { + await client.query('ROLLBACK'); + return null; + } + + if (replacement) { + await client.query( + `INSERT INTO report_acknowledgement_grant_use ( + grant_nonce,opening_id,acknowledgement_id,disposition,grant_expires_at + ) VALUES ($1,$2::uuid,$3::bigint,'expired_replaced',$4::timestamptz)`, + [input.grant.nonce, input.grant.openingId, acknowledgment.id, input.grant.expiresAt] + ); + await client.query( + `INSERT INTO report_acknowledgement_grant_use ( + grant_nonce,opening_id,acknowledgement_id,disposition,replacement_for_nonce, + grant_expires_at + ) VALUES ($1,$2::uuid,$3::bigint,'consumed',$4,$5::timestamptz)`, + [ + replacement.nonce, + replacement.openingId, + acknowledgment.id, + input.grant.nonce, + replacement.expiresAt, + ] + ); + } else { + await client.query( + `INSERT INTO report_acknowledgement_grant_use ( + grant_nonce,opening_id,acknowledgement_id,disposition,grant_expires_at + ) VALUES ($1,$2::uuid,$3::bigint,'consumed',$4::timestamptz)`, + [input.grant.nonce, input.grant.openingId, acknowledgment.id, input.grant.expiresAt] + ); + } + await client.query('COMMIT'); + return view(acknowledgment); + } catch (error) { + await client.query('ROLLBACK').catch(() => undefined); + throw error; + } finally { + client.release(); + } + }, + }; +} diff --git a/packages/db/src/boards.ts b/packages/db/src/boards.ts index c0b2705..cd8d378 100644 --- a/packages/db/src/boards.ts +++ b/packages/db/src/boards.ts @@ -60,29 +60,31 @@ export async function loadCompilerSnapshot( 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 + // A PoolClient executes one statement at a time. Serial reads preserve the transaction snapshot + // and avoid pg's deprecated concurrent-query queueing on a checked-out client. + const students = await client.query( + `SELECT id::text,first_name FROM student WHERE guide_id=$1::uuid AND is_synthetic IS TRUE ORDER BY id`, + [claim.guideId] + ); + const skills = await client.query(`SELECT id,name,grade,strand FROM skill ORDER BY id`); + const prereqs = await client.query(`SELECT skill_id,prereq_id,strength FROM skill_prereq`); + const items = await client.query( + `SELECT id,skill_id,difficulty,choices,item_type,timing_profile FROM item` + ); + const absences = await 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] + ); + const attempts = await 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 @@ -91,23 +93,22 @@ export async function loadCompilerSnapshot( 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] - ), - ]); + [claim.applicationScopeId, claim.guideId] + ); + const currentMastery = await 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] + ); + const priorMastery = await 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, diff --git a/packages/db/src/demo-seed.ts b/packages/db/src/demo-seed.ts new file mode 100644 index 0000000..fa1539e --- /dev/null +++ b/packages/db/src/demo-seed.ts @@ -0,0 +1,204 @@ +import pg from 'pg'; +import { config } from '@huddle/core'; +import { items, skillPrereqs, skills } from '@huddle/core/seed'; + +const { Pool } = pg; + +export const DEMO_GUIDE_ID = '00000000-0000-4000-8000-000000000001'; +export const DEMO_STUDIO_ID = 'synthetic-huddle-demo'; +export const DEMO_ROSTER = [ + ['11111111-1111-1111-1111-111111111111', 'synthetic-student-01', 'Avery'], + ['22222222-2222-2222-2222-222222222222', 'synthetic-student-02', 'Blake'], + ['33333333-3333-3333-3333-333333333333', 'synthetic-student-03', 'Casey'], + ['44444444-4444-4444-4444-444444444444', 'synthetic-student-04', 'Drew'], +] as const; + +export interface DemoSeedResult { + guideId: string; + studioId: string; + rosterSize: number; + reset: boolean; +} + +async function clearSyntheticScope( + client: pg.PoolClient, + applicationScopeId: string | null +): Promise { + if (!applicationScopeId) return; + const scope = [applicationScopeId]; + await client.query( + `DELETE FROM report_acknowledgement_grant_use + WHERE opening_id IN ( + SELECT opening_id FROM report_visible_open WHERE application_scope_id=$1::bigint + )`, + scope + ); + await client.query( + `DELETE FROM report_acknowledgement WHERE application_scope_id=$1::bigint`, + scope + ); + await client.query( + `DELETE FROM report_visible_open WHERE application_scope_id=$1::bigint`, + scope + ); + await client.query( + `UPDATE board_refresh_request SET state='failed', + started_at=coalesce(started_at,clock_timestamp()),completed_at=clock_timestamp(), + resulting_board_run_id=NULL,preserved_board_run_id=NULL, + failure_code='compile-failed',lease_expires_at=NULL,heartbeat_at=NULL + WHERE application_scope_id=$1::bigint`, + scope + ); + await client.query(`DELETE FROM board_head WHERE application_scope_id=$1::bigint`, scope); + await client.query( + `DELETE FROM triage_entry WHERE board_run_id IN ( + SELECT id FROM board_run WHERE application_scope_id=$1::bigint + )`, + scope + ); + await client.query( + `DELETE FROM signal WHERE board_run_id IN ( + SELECT id FROM board_run WHERE application_scope_id=$1::bigint + )`, + scope + ); + await client.query( + `DELETE FROM mastery_snapshot WHERE board_run_id IN ( + SELECT id FROM board_run WHERE application_scope_id=$1::bigint + )`, + scope + ); + await client.query(`DELETE FROM board_run_import WHERE application_scope_id=$1::bigint`, scope); + await client.query(`DELETE FROM board_run WHERE application_scope_id=$1::bigint`, scope); + await client.query( + `DELETE FROM board_refresh_request WHERE application_scope_id=$1::bigint`, + scope + ); + await client.query(`DELETE FROM attempt WHERE application_scope_id=$1::bigint`, scope); + await client.query(`DELETE FROM learning_session WHERE application_scope_id=$1::bigint`, scope); + await client.query(`DELETE FROM unmapped_activity WHERE application_scope_id=$1::bigint`, scope); + await client.query( + `DELETE FROM import_issue WHERE import_run_id IN ( + SELECT id FROM import_run WHERE application_scope_id=$1::bigint + )`, + scope + ); + await client.query(`DELETE FROM import_run WHERE application_scope_id=$1::bigint`, scope); +} + +/** Idempotently provisions only the fixed synthetic reviewer identity and reference catalog. */ +export async function seedSyntheticDemo(input: { + authUserId: string; + reset?: boolean; +}): Promise { + if (!config.DATABASE_ADMIN_URL) throw new Error('DATABASE_ADMIN_URL is required.'); + const admin = new Pool({ connectionString: config.DATABASE_ADMIN_URL }); + const client = await admin.connect(); + try { + await client.query('BEGIN'); + await client.query(`SELECT pg_advisory_xact_lock(hashtext('huddle-synthetic-demo-seed'))`); + const existingGuide = await client.query<{ is_synthetic: boolean }>( + `SELECT is_synthetic FROM guide WHERE id=$1::uuid FOR UPDATE`, + [DEMO_GUIDE_ID] + ); + if (existingGuide.rows[0] && !existingGuide.rows[0].is_synthetic) { + throw new Error('Refusing to replace a non-synthetic guide.'); + } + const unsafeStudents = await client.query<{ count: string }>( + `SELECT count(*)::text FROM student + WHERE (guide_id=$1::uuid OR id=ANY($2::uuid[])) AND is_synthetic IS NOT TRUE`, + [DEMO_GUIDE_ID, DEMO_ROSTER.map(([studentId]) => studentId)] + ); + if (Number(unsafeStudents.rows[0]?.count ?? 0) > 0) { + throw new Error('Refusing to replace non-synthetic student data.'); + } + const scope = await client.query<{ + id: string; + auth_user_id: string; + is_synthetic: boolean; + }>( + `SELECT id::text,auth_user_id::text,is_synthetic FROM guide_auth_scope + WHERE guide_id=$1::uuid AND studio_id=$2 FOR UPDATE`, + [DEMO_GUIDE_ID, DEMO_STUDIO_ID] + ); + if (scope.rows[0] && !scope.rows[0].is_synthetic) { + throw new Error('Refusing to replace a non-synthetic application scope.'); + } + if (scope.rows[0] && scope.rows[0].auth_user_id !== input.authUserId && !input.reset) { + throw new Error('Reviewer Auth identity differs; use the explicit synthetic reset command.'); + } + if (input.reset) await clearSyntheticScope(client, scope.rows[0]?.id ?? null); + + await client.query( + `INSERT INTO guide(id,display_name,is_synthetic) VALUES($1::uuid,'Huddle Demo Guide',TRUE) + ON CONFLICT(id) DO UPDATE SET display_name=EXCLUDED.display_name,is_synthetic=TRUE`, + [DEMO_GUIDE_ID] + ); + for (const [id, pseudonymousRef, firstName] of DEMO_ROSTER) { + await client.query( + `INSERT INTO student(id,guide_id,first_name,grade,is_synthetic,pseudonymous_ref) + VALUES($1::uuid,$2::uuid,$3,4,TRUE,$4) + ON CONFLICT(id) DO UPDATE SET guide_id=EXCLUDED.guide_id,first_name=EXCLUDED.first_name, + grade=EXCLUDED.grade,is_synthetic=TRUE,pseudonymous_ref=EXCLUDED.pseudonymous_ref`, + [id, DEMO_GUIDE_ID, firstName, pseudonymousRef] + ); + } + for (const skill of skills) { + await client.query( + `INSERT INTO skill(id,name,grade,strand) VALUES($1,$2,$3,$4) + ON CONFLICT(id) DO UPDATE SET name=EXCLUDED.name,grade=EXCLUDED.grade,strand=EXCLUDED.strand`, + [skill.id, skill.name, skill.grade, skill.strand] + ); + } + for (const prerequisite of skillPrereqs) { + await client.query( + `INSERT INTO skill_prereq(skill_id,prereq_id,strength) VALUES($1,$2,$3) + ON CONFLICT(skill_id,prereq_id) DO UPDATE SET strength=EXCLUDED.strength`, + [prerequisite.skillId, prerequisite.prereqId, prerequisite.strength] + ); + } + for (const item of items) { + await client.query( + `INSERT INTO item(id,skill_id,difficulty,choices,item_type,timing_profile) + VALUES($1,$2,$3,$4::jsonb,$5,$6) + ON CONFLICT(id) DO UPDATE SET skill_id=EXCLUDED.skill_id,difficulty=EXCLUDED.difficulty, + choices=EXCLUDED.choices,item_type=EXCLUDED.item_type, + timing_profile=EXCLUDED.timing_profile`, + [ + item.id, + item.skillId, + item.difficulty, + JSON.stringify(item.choices), + item.itemType, + item.timingProfile, + ] + ); + } + if (scope.rows[0]) { + await client.query( + `UPDATE guide_auth_scope SET auth_user_id=$1::uuid,is_synthetic=TRUE + WHERE id=$2::bigint`, + [input.authUserId, scope.rows[0].id] + ); + } else { + await client.query( + `INSERT INTO guide_auth_scope(auth_user_id,guide_id,studio_id,is_synthetic) + VALUES($1::uuid,$2::uuid,$3,TRUE)`, + [input.authUserId, DEMO_GUIDE_ID, DEMO_STUDIO_ID] + ); + } + await client.query('COMMIT'); + return { + guideId: DEMO_GUIDE_ID, + studioId: DEMO_STUDIO_ID, + rosterSize: DEMO_ROSTER.length, + reset: input.reset === true, + }; + } catch (error) { + await client.query('ROLLBACK').catch(() => undefined); + throw error; + } finally { + client.release(); + await admin.end(); + } +} diff --git a/packages/db/src/evidence.ts b/packages/db/src/evidence.ts index 44a410b..1b0b034 100644 --- a/packages/db/src/evidence.ts +++ b/packages/db/src/evidence.ts @@ -1,2 +1,160 @@ -/** Reserved DB seam: run-scoped evidence bundle retrieval lives here. */ -export type EvidencePersistenceModule = 'evidence'; +import type { EvidenceOpenRecord, EvidenceReadIntent, GuideAccess } from '@huddle/application'; +import type { EvidenceBundle } from '@huddle/core'; +import { pool } from './client.js'; +import { getBoardViewForGuide, type Queryable } from './scoped.js'; + +interface EvidenceIdentityRow { + board_date: string; + signal_id: string; + evidence: EvidenceBundle; + additional_causes: Array<{ signalId: number | string }>; +} + +/** Current-head evidence lookup. Prefetch and visible-open reads are both side-effect free. */ +export async function findAuthorizedEvidence( + access: GuideAccess, + input: { boardRunId: string; triageEntryId: string }, + _intent: EvidenceReadIntent, + client: Queryable = pool +): Promise { + const identity = await client.query( + `SELECT run.board_date::text, dominant.id::text AS signal_id, dominant.evidence, + entry.additional_causes + FROM board_head head + JOIN board_run run ON run.id=head.current_board_run_id + JOIN triage_entry entry ON entry.board_run_id=run.id + JOIN signal dominant ON dominant.id=entry.dominant_signal_id + AND dominant.board_run_id=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 run.id=$1::bigint AND entry.id=$2::bigint + AND head.guide_id=$3::uuid AND head.studio_id=$4 + AND access_scope.auth_user_id=$5::uuid + AND access_scope.is_synthetic IS TRUE AND g.is_synthetic IS TRUE + LIMIT 1`, + [input.boardRunId, input.triageEntryId, access.guideId, access.studioId, access.authUserId] + ); + const row = identity.rows[0]; + if (!row) return null; + + const board = await getBoardViewForGuide(access, row.board_date, client); + if (board.kind === 'not-built' || board.boardRunId !== input.boardRunId) return null; + const entry = board.entries.find((candidate) => candidate.triageEntryId === input.triageEntryId); + if (!entry) return null; + + const additionalIds = row.additional_causes.map((cause) => String(cause.signalId)); + const additionalRows = + additionalIds.length === 0 + ? [] + : ( + await client.query<{ id: string; evidence: EvidenceBundle }>( + `SELECT id::text,evidence FROM signal + WHERE board_run_id=$1::bigint AND id=ANY($2::bigint[])`, + [input.boardRunId, additionalIds] + ) + ).rows; + const additionalById = new Map(additionalRows.map((signal) => [signal.id, signal.evidence])); + const additionalEvidence = additionalIds.map((id) => { + const evidence = additionalById.get(id); + if (!evidence) return null; + return { + signalId: Number(id), + cause: evidence.finding.dominantCause, + scope: evidence.scope, + summary: evidence.finding, + computed: evidence.computed, + derived: evidence.derived, + prerequisiteCheck: evidence.prerequisiteCheck, + conflicts: evidence.conflicts, + attempts: evidence.attempts, + sessions: evidence.sessions, + }; + }); + if (additionalEvidence.some((value) => value == null)) return null; + + const evidence = row.evidence; + return { + boardDate: row.board_date, + evidence: { + kind: 'evidence', + boardRunId: input.boardRunId, + signalId: Number(row.signal_id), + entry, + summary: evidence.finding, + comparison: { + computed: evidence.computed, + derived: evidence.derived, + prerequisiteCheck: evidence.prerequisiteCheck, + conflicts: evidence.conflicts, + additionalCauses: evidence.additionalCauses, + additionalEvidence: additionalEvidence as NonNullable< + (typeof additionalEvidence)[number] + >[], + }, + exact: { attempts: evidence.attempts, sessions: evidence.sessions }, + }, + }; +} + +/** Acquires the reveal lease only while the requested immutable report is the selected head. */ +export async function acquireVisibleRevealLease( + access: GuideAccess, + input: { + boardDate: string; + boardRunId: string; + triageEntryId: string; + findingFingerprint: string; + openingId: string; + grantExpiresAt: string; + }, + client: Queryable = pool +): Promise { + const values = [ + input.openingId, + access.authUserId, + access.guideId, + access.studioId, + input.boardDate, + input.boardRunId, + input.triageEntryId, + input.findingFingerprint, + input.grantExpiresAt, + ]; + const existing = await client.query<{ opening_id: string }>( + `SELECT opening.opening_id::text + FROM report_visible_open opening + JOIN guide_auth_scope access_scope ON access_scope.id=opening.application_scope_id + WHERE opening.opening_id=$1::uuid AND opening.auth_user_id=$2::uuid + AND opening.guide_id=$3::uuid AND opening.studio_id=$4 + AND opening.board_date=$5::date AND opening.board_run_id=$6::bigint + AND opening.triage_entry_id=$7::bigint AND opening.finding_fingerprint=$8 + AND opening.grant_expires_at=$9::timestamptz + AND access_scope.is_synthetic IS TRUE`, + values + ); + if (existing.rows[0]) return true; + + const inserted = await client.query<{ opening_id: string }>( + `INSERT INTO report_visible_open ( + opening_id,application_scope_id,auth_user_id,guide_id,studio_id,board_date, + board_run_id,triage_entry_id,finding_fingerprint,grant_expires_at + ) + SELECT $1::uuid,head.application_scope_id,$2::uuid,$3::uuid,$4,$5::date, + $6::bigint,$7::bigint,$8,$9::timestamptz + FROM board_head head + JOIN triage_entry entry ON entry.id=$7::bigint + AND entry.board_run_id=head.current_board_run_id + AND entry.finding_fingerprint=$8 + 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.current_board_run_id=$6::bigint AND head.board_date=$5::date + AND head.guide_id=$3::uuid AND head.studio_id=$4 + AND access_scope.auth_user_id=$2::uuid + AND access_scope.is_synthetic IS TRUE AND g.is_synthetic IS TRUE + AND $9::timestamptz > clock_timestamp() + ON CONFLICT (opening_id) DO NOTHING + RETURNING opening_id::text`, + values + ); + return inserted.rows.length === 1; +} diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index ab1a23d..cb8a932 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -48,6 +48,9 @@ export type { RefreshRequestRow, } from './refresh.js'; export { loadCompilerSnapshot, publishCompiledBoardRun, publishEmptyBoardRun } from './boards.js'; +export { acquireVisibleRevealLease, findAuthorizedEvidence } from './evidence.js'; +export { createDbAcknowledgmentLedger } from './acknowledgement.js'; +export { DEMO_GUIDE_ID, DEMO_ROSTER, DEMO_STUDIO_ID, seedSyntheticDemo } from './demo-seed.js'; export type { CompiledBoardDraft, CompiledEntry, diff --git a/packages/db/src/scoped.ts b/packages/db/src/scoped.ts index 9575035..e53129e 100644 --- a/packages/db/src/scoped.ts +++ b/packages/db/src/scoped.ts @@ -213,13 +213,14 @@ export async function getBoardViewForGuide( const refresh = toRefreshView(refreshResult.rows[0]); const headResult = await client.query<{ id: string; + application_scope_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 + `SELECT run.id::text,head.application_scope_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 @@ -258,6 +259,7 @@ export async function getBoardViewForGuide( | null; catalog_version: string; render_version: string; + acknowledged_at: Date | null; additional_cause_count: number; additional_causes: Array<{ cause: RootCause; severity: number }>; }>( @@ -266,16 +268,21 @@ export async function getBoardViewForGuide( 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, + acknowledgement.acknowledged_at, 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 + LEFT JOIN report_acknowledgement acknowledgement + ON acknowledgement.application_scope_id=$3::bigint + AND acknowledgement.board_date=$4::date + AND acknowledgement.finding_fingerprint=entry.finding_fingerprint 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] + [head.id, scope.guideId, head.application_scope_id, head.board_date] ); const entries = entriesResult.rows.map((row) => ({ triageEntryId: row.triage_entry_id, @@ -303,7 +310,7 @@ export async function getBoardViewForGuide( catalogVersion: row.catalog_version, renderVersion: row.render_version, }, - acknowledgedAt: null, + acknowledgedAt: row.acknowledged_at?.toISOString() ?? null, additionalCauseCount: row.additional_cause_count, additionalCauses: row.additional_causes, })); diff --git a/packages/db/test/acknowledgment-privileges.test.ts b/packages/db/test/acknowledgment-privileges.test.ts new file mode 100644 index 0000000..d50fa4a --- /dev/null +++ b/packages/db/test/acknowledgment-privileges.test.ts @@ -0,0 +1,66 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import pg from 'pg'; +import type { Pool } from 'pg'; + +const { Pool: PgPool } = pg; +const migrations = join(dirname(fileURLToPath(import.meta.url)), '../../../db/migrations'); + +describe.skipIf(process.env.RUN_DB_TESTS !== '1')('acknowledgment runtime privileges', () => { + let admin: Pool; + let runtime: Pool; + + beforeAll(async () => { + const database = await import('../src/client.js'); + await database.runMigrations(migrations); + admin = new PgPool({ + connectionString: process.env.DATABASE_ADMIN_URL ?? process.env.DATABASE_URL, + }); + runtime = new PgPool({ connectionString: process.env.DATABASE_URL }); + }); + + afterAll(async () => { + if (runtime) await runtime.end(); + if (admin) await admin.end(); + }); + + it('allows ordinary reads and function execution but rejects row locks without UPDATE', async () => { + const functions = await admin.query<{ + signature: string; + security_definer: boolean; + can_execute: boolean; + }>( + `SELECT p.oid::regprocedure::text AS signature,p.prosecdef AS security_definer, + has_function_privilege('huddle_app',p.oid,'EXECUTE') AS can_execute + FROM pg_proc p + WHERE p.oid IN ( + 'pg_catalog.hashtextextended(text,bigint)'::regprocedure, + 'pg_catalog.pg_advisory_xact_lock(bigint)'::regprocedure + ) + ORDER BY signature` + ); + expect(functions.rows).toEqual([ + { + signature: 'hashtextextended(text,bigint)', + security_definer: false, + can_execute: true, + }, + { + signature: 'pg_advisory_xact_lock(bigint)', + security_definer: false, + can_execute: true, + }, + ]); + + await expect( + runtime.query('SELECT opening_id FROM report_visible_open LIMIT 0') + ).resolves.toMatchObject({ rows: [] }); + await expect( + runtime.query('SELECT opening_id FROM report_visible_open LIMIT 0 FOR UPDATE') + ).rejects.toMatchObject({ + code: '42501', + message: 'permission denied for table report_visible_open', + }); + }); +}); diff --git a/packages/db/test/evidence-acknowledgment-migration.test.ts b/packages/db/test/evidence-acknowledgment-migration.test.ts new file mode 100644 index 0000000..98f76c4 --- /dev/null +++ b/packages/db/test/evidence-acknowledgment-migration.test.ts @@ -0,0 +1,22 @@ +import { readFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const migration = join( + dirname(fileURLToPath(import.meta.url)), + '../../../db/migrations/010_evidence_acknowledgment.sql' +); + +describe('evidence acknowledgment persistence', () => { + it('keeps reveal leases, first-open acknowledgments, and grant replays in PostgreSQL', async () => { + const sql = await readFile(migration, 'utf8'); + expect(sql).toMatch(/CREATE TABLE report_visible_open/); + expect(sql).toMatch(/CREATE TABLE report_acknowledgement \(/); + expect(sql).toMatch(/UNIQUE \(application_scope_id, board_date, finding_fingerprint\)/); + expect(sql).toMatch(/CREATE TABLE report_acknowledgement_grant_use/); + expect(sql).toMatch(/grant_nonce text PRIMARY KEY/); + expect(sql).toMatch(/REVOKE UPDATE, DELETE .*huddle_app/s); + expect(sql).toMatch(/GRANT SELECT, INSERT .*huddle_app/s); + }); +}); diff --git a/packages/db/test/evidence-persistence.test.ts b/packages/db/test/evidence-persistence.test.ts new file mode 100644 index 0000000..7de8dae --- /dev/null +++ b/packages/db/test/evidence-persistence.test.ts @@ -0,0 +1,61 @@ +import type { QueryResultRow } from 'pg'; +import { describe, expect, it } from 'vitest'; +import { acquireVisibleRevealLease, findAuthorizedEvidence } from '../src/evidence.js'; + +const access = { + authUserId: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', + guideId: 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', + studioId: 'synthetic-huddle-demo', + role: 'guide' as const, + syntheticOnly: true as const, +}; + +describe('evidence persistence boundary', () => { + it('requires the selected current head before returning evidence', async () => { + const calls: string[] = []; + const result = await findAuthorizedEvidence( + access, + { boardRunId: '7', triageEntryId: '9' }, + 'prefetch', + { + async query(text: string) { + calls.push(text); + return { rows: [] as T[] }; + }, + } + ); + expect(result).toBeNull(); + expect(calls[0]).toMatch(/FROM board_head head/); + expect(calls[0]).toMatch(/head\.current_board_run_id/); + expect(calls[0]).toMatch(/access_scope\.auth_user_id=\$5::uuid/); + }); + + it('binds a reveal lease to auth scope, report identity, fingerprint, and expiry', async () => { + const calls: Array<{ text: string; values?: readonly unknown[] }> = []; + const acquired = await acquireVisibleRevealLease( + access, + { + boardDate: '2026-07-27', + boardRunId: '7', + triageEntryId: '9', + findingFingerprint: 'f'.repeat(64), + openingId: 'cccccccc-cccc-cccc-cccc-cccccccccccc', + grantExpiresAt: '2026-07-27T13:05:00.000Z', + }, + { + async query(text: string, values?: readonly unknown[]) { + calls.push({ text, values }); + return { + rows: (calls.length === 2 ? [{ opening_id: 'opening' }] : []) as unknown as T[], + }; + }, + } + ); + expect(acquired).toBe(true); + expect(calls[1]?.text).toMatch(/INSERT INTO report_visible_open/); + expect(calls[1]?.text).toMatch(/entry\.finding_fingerprint=\$8/); + expect(calls[1]?.text).toMatch(/access_scope\.auth_user_id=\$2::uuid/); + expect(calls[1]?.text).toMatch(/\$9::timestamptz > clock_timestamp\(\)/); + expect(calls[1]?.values).toHaveLength(9); + }); +}); diff --git a/packages/db/test/scoped-access.test.ts b/packages/db/test/scoped-access.test.ts index 16e4305..0da00fd 100644 --- a/packages/db/test/scoped-access.test.ts +++ b/packages/db/test/scoped-access.test.ts @@ -11,7 +11,11 @@ import { } from '../src/scoped.js'; const root = join(dirname(fileURLToPath(import.meta.url)), '../../..'); -const scopedQueryFiles = new Set(['packages/db/src/scoped.ts', 'packages/db/src/boards.ts']); +const scopedQueryFiles = new Set([ + 'packages/db/src/scoped.ts', + 'packages/db/src/boards.ts', + 'packages/db/src/demo-seed.ts', +]); const studentRead = /\b(?:FROM|JOIN)\s+student\b/i; async function sourceFiles(directory: string): Promise { diff --git a/packages/eval/fixtures/portfolio/fallback-corpus.json b/packages/eval/fixtures/portfolio/fallback-corpus.json new file mode 100644 index 0000000..4536855 --- /dev/null +++ b/packages/eval/fixtures/portfolio/fallback-corpus.json @@ -0,0 +1,1049 @@ +{ + "schemaVersion": "portfolio-fallback-corpus-v1", + "generatedBy": "npm run eval:portfolio -- --update", + "modelCalls": 0, + "accuracyPosture": "No diagnosis-accuracy claim; this gate covers fallback and grounding only.", + "entries": [ + { + "cause": "guessing", + "bundle": { + "bundleVersion": "portfolio-v1", + "behaviorFingerprint": "d2dbd3937defff47fa663307c7c647c65e3a38d36bb80b22621bc2793c1d4732", + "student": { + "id": "synthetic-eval-1", + "firstName": "Avery" + }, + "scope": { + "kind": "skill", + "skill": { + "code": "TEKS.4.2A", + "name": "Understand place-value relationships" + } + }, + "finding": { + "dominantCause": "guessing", + "severity": 0.45, + "rawConfidence": 0.8, + "finalConfidence": 0.8, + "confidenceBreakdown": { + "timingMultiplier": 1, + "winsorizationMultiplier": 1, + "conflictMultiplier": 1 + }, + "ruleId": "portfolio.guessing", + "ruleVersion": "portfolio-v1" + }, + "window": { + "timezone": "America/Chicago", + "start": "2026-07-20T05:00:00.000Z", + "end": "2026-07-27T05:00:00.000Z", + "asOf": "2026-07-27T05:00:00.000Z", + "localDays": 7 + }, + "attempts": [ + { + "attemptId": 1, + "activityId": "e796c58f4137aaeeb79ed4ec415b4973dcc4c2574cd23101d5983fc1e86cf0ca", + "ordinal": 1, + "skill": { + "code": "TEKS.4.2A", + "name": "Understand place-value relationships" + }, + "itemType": "multiple_choice", + "timingProfile": "standard_multiple_choice", + "submittedAt": "2026-07-24T14:01:00.000Z", + "isCorrect": false, + "elapsedMs": 60000, + "engagedMs": 60000, + "timingQuality": "engaged", + "chosenLabel": "B", + "misconception": null, + "hintsUsed": 0 + } + ], + "sessions": [], + "computed": { + "attemptCount": 1, + "wrongCount": 1, + "medianWrongDurationMs": 60000, + "personalCorrectBaselineMs": 60000, + "personalSessionMeanBaselineMs": null, + "distractorConcentration": 1, + "winsorizedOutCount": 0 + }, + "derived": { + "wrongOfLastN": { + "wrong": 1, + "of": 1 + }, + "speedRatio": 1, + "consecutiveWrong": 1, + "daysSinceFirstAttempt": 2 + }, + "prerequisiteCheck": null, + "conflicts": [], + "additionalCauses": [], + "abstentions": [], + "languageOptions": { + "catalogVersion": "operations-fallback-v1", + "propositions": [ + { + "id": "diagnosis.guessing.pattern", + "slotBindings": { + "studentName": { + "bundlePath": "/student/firstName", + "valueHash": "ea80ef6b3fc2a06cfe64bad1d2270ab307baffa12aa8798a8a4872494d5d6f56" + } + } + } + ], + "openers": [ + { + "id": "opener.guessing.explain-choice", + "slotBindings": { + "studentName": { + "bundlePath": "/student/firstName", + "valueHash": "ea80ef6b3fc2a06cfe64bad1d2270ab307baffa12aa8798a8a4872494d5d6f56" + } + } + } + ] + } + }, + "selection": { + "diagnosis": { + "propositions": [ + { + "id": "diagnosis.guessing.pattern", + "slotRefs": { + "studentName": "/student/firstName" + } + } + ] + }, + "opener": { + "id": "opener.guessing.explain-choice", + "slotRefs": { + "studentName": "/student/firstName" + } + } + }, + "diagnosis": "For Avery, the recorded response pattern is consistent with guessing.", + "opener": "Avery, walk me through how you chose your answer.", + "catalogVersion": "operations-fallback-v1", + "renderVersion": "operations-render-v1", + "languageFingerprint": "4ba0351eb7771b37988274b759cb86aadbc948dd318cbb2bc6306919058f3de3", + "narrationFingerprint": "725c417c1f43e2d4f5f09db60d8a7fd49dde369bdd23aa9af0cc5bb982bc937c" + }, + { + "cause": "prerequisite_gap", + "bundle": { + "bundleVersion": "portfolio-v1", + "behaviorFingerprint": "3cf05682bb6a362f2192f7c963f2706bd00acd3a0484f833cdab9fd898eefb05", + "student": { + "id": "synthetic-eval-2", + "firstName": "Blake" + }, + "scope": { + "kind": "skill", + "skill": { + "code": "TEKS.4.2A", + "name": "Understand place-value relationships" + } + }, + "finding": { + "dominantCause": "prerequisite_gap", + "severity": 0.5, + "rawConfidence": 0.8, + "finalConfidence": 0.8, + "confidenceBreakdown": { + "timingMultiplier": 1, + "winsorizationMultiplier": 1, + "conflictMultiplier": 1 + }, + "ruleId": "portfolio.prerequisite_gap", + "ruleVersion": "portfolio-v1" + }, + "window": { + "timezone": "America/Chicago", + "start": "2026-07-20T05:00:00.000Z", + "end": "2026-07-27T05:00:00.000Z", + "asOf": "2026-07-27T05:00:00.000Z", + "localDays": 7 + }, + "attempts": [ + { + "attemptId": 2, + "activityId": "8060dbaff302c327704b2a607d3f0dc522012106fcb5452cb3c4b0709b1e05e4", + "ordinal": 1, + "skill": { + "code": "TEKS.4.2A", + "name": "Understand place-value relationships" + }, + "itemType": "multiple_choice", + "timingProfile": "standard_multiple_choice", + "submittedAt": "2026-07-24T14:01:00.000Z", + "isCorrect": false, + "elapsedMs": 60000, + "engagedMs": 60000, + "timingQuality": "engaged", + "chosenLabel": "B", + "misconception": null, + "hintsUsed": 0 + } + ], + "sessions": [], + "computed": { + "attemptCount": 1, + "wrongCount": 1, + "medianWrongDurationMs": 60000, + "personalCorrectBaselineMs": 60000, + "personalSessionMeanBaselineMs": null, + "distractorConcentration": 1, + "winsorizedOutCount": 0 + }, + "derived": { + "wrongOfLastN": { + "wrong": 1, + "of": 1 + }, + "speedRatio": 1, + "consecutiveWrong": 1, + "daysSinceFirstAttempt": 2 + }, + "prerequisiteCheck": null, + "conflicts": [], + "additionalCauses": [], + "abstentions": [], + "languageOptions": { + "catalogVersion": "operations-fallback-v1", + "propositions": [ + { + "id": "diagnosis.prerequisite-gap.review", + "slotBindings": { + "studentName": { + "bundlePath": "/student/firstName", + "valueHash": "fba998af95ba8e33d3f243f1fb90676526d6e67ca28107dd8c1a5cffafc70df9" + } + } + } + ], + "openers": [ + { + "id": "opener.prerequisite-gap.example", + "slotBindings": { + "studentName": { + "bundlePath": "/student/firstName", + "valueHash": "fba998af95ba8e33d3f243f1fb90676526d6e67ca28107dd8c1a5cffafc70df9" + } + } + } + ] + } + }, + "selection": { + "diagnosis": { + "propositions": [ + { + "id": "diagnosis.prerequisite-gap.review", + "slotRefs": { + "studentName": "/student/firstName" + } + } + ] + }, + "opener": { + "id": "opener.prerequisite-gap.example", + "slotRefs": { + "studentName": "/student/firstName" + } + } + }, + "diagnosis": "For Blake, the recorded work indicates that a prerequisite needs review.", + "opener": "Blake, let’s rebuild the prerequisite with a worked example.", + "catalogVersion": "operations-fallback-v1", + "renderVersion": "operations-render-v1", + "languageFingerprint": "df41ec5e55436a5d35ac94db175d3e428a3381b93ec0656a27d7e26cbba85d04", + "narrationFingerprint": "4d2cb281c6950ff3beece0d8397dd4289c0040ee3ab448dee765a7c357f2a868" + }, + { + "cause": "grinding", + "bundle": { + "bundleVersion": "portfolio-v1", + "behaviorFingerprint": "713306bfbade9afa8c1ea52c0d11ae3ca8276eab8851c9fac2e3c6337d5d8eea", + "student": { + "id": "synthetic-eval-3", + "firstName": "Casey" + }, + "scope": { + "kind": "skill", + "skill": { + "code": "TEKS.4.2A", + "name": "Understand place-value relationships" + } + }, + "finding": { + "dominantCause": "grinding", + "severity": 0.55, + "rawConfidence": 0.8, + "finalConfidence": 0.8, + "confidenceBreakdown": { + "timingMultiplier": 1, + "winsorizationMultiplier": 1, + "conflictMultiplier": 1 + }, + "ruleId": "portfolio.grinding", + "ruleVersion": "portfolio-v1" + }, + "window": { + "timezone": "America/Chicago", + "start": "2026-07-20T05:00:00.000Z", + "end": "2026-07-27T05:00:00.000Z", + "asOf": "2026-07-27T05:00:00.000Z", + "localDays": 7 + }, + "attempts": [ + { + "attemptId": 3, + "activityId": "bae3d2c39ca51d5db3bfeb7f8bc6d050d5f0a698b4a9bd2f333cf9f00e513b68", + "ordinal": 1, + "skill": { + "code": "TEKS.4.2A", + "name": "Understand place-value relationships" + }, + "itemType": "multiple_choice", + "timingProfile": "standard_multiple_choice", + "submittedAt": "2026-07-24T14:01:00.000Z", + "isCorrect": false, + "elapsedMs": 60000, + "engagedMs": 60000, + "timingQuality": "engaged", + "chosenLabel": "B", + "misconception": null, + "hintsUsed": 0 + } + ], + "sessions": [], + "computed": { + "attemptCount": 1, + "wrongCount": 1, + "medianWrongDurationMs": 60000, + "personalCorrectBaselineMs": 60000, + "personalSessionMeanBaselineMs": null, + "distractorConcentration": 1, + "winsorizedOutCount": 0 + }, + "derived": { + "wrongOfLastN": { + "wrong": 1, + "of": 1 + }, + "speedRatio": 1, + "consecutiveWrong": 1, + "daysSinceFirstAttempt": 2 + }, + "prerequisiteCheck": null, + "conflicts": [], + "additionalCauses": [], + "abstentions": [], + "languageOptions": { + "catalogVersion": "operations-fallback-v1", + "propositions": [ + { + "id": "diagnosis.grinding.strategy", + "slotBindings": { + "studentName": { + "bundlePath": "/student/firstName", + "valueHash": "2ac22451977a4a15aededd7e991ca72beabc3452de416476d2b6460fc8115cb5" + } + } + } + ], + "openers": [ + { + "id": "opener.grinding.strategy", + "slotBindings": { + "studentName": { + "bundlePath": "/student/firstName", + "valueHash": "2ac22451977a4a15aededd7e991ca72beabc3452de416476d2b6460fc8115cb5" + } + } + } + ] + } + }, + "selection": { + "diagnosis": { + "propositions": [ + { + "id": "diagnosis.grinding.strategy", + "slotRefs": { + "studentName": "/student/firstName" + } + } + ] + }, + "opener": { + "id": "opener.grinding.strategy", + "slotRefs": { + "studentName": "/student/firstName" + } + } + }, + "diagnosis": "For Casey, the recorded attempts show repeated work without a changed outcome.", + "opener": "Casey, let’s pause and choose a different strategy.", + "catalogVersion": "operations-fallback-v1", + "renderVersion": "operations-render-v1", + "languageFingerprint": "db90acaba03bd75ef7dda35928d38fc7ad09103f56feba35ef5edee3cb6ed66d", + "narrationFingerprint": "9f72f4bb3578f4756e5cfa70d14d045c79c61eef99fa43e6368eda3d4da4b7d9" + }, + { + "cause": "hint_farming", + "bundle": { + "bundleVersion": "portfolio-v1", + "behaviorFingerprint": "ff09d0c07891ec18291bdec13c574f6dc834772cc516e4a2ed1209ecddaab6b0", + "student": { + "id": "synthetic-eval-4", + "firstName": "Drew" + }, + "scope": { + "kind": "skill", + "skill": { + "code": "TEKS.4.2A", + "name": "Understand place-value relationships" + } + }, + "finding": { + "dominantCause": "hint_farming", + "severity": 0.6, + "rawConfidence": 0.8, + "finalConfidence": 0.8, + "confidenceBreakdown": { + "timingMultiplier": 1, + "winsorizationMultiplier": 1, + "conflictMultiplier": 1 + }, + "ruleId": "portfolio.hint_farming", + "ruleVersion": "portfolio-v1" + }, + "window": { + "timezone": "America/Chicago", + "start": "2026-07-20T05:00:00.000Z", + "end": "2026-07-27T05:00:00.000Z", + "asOf": "2026-07-27T05:00:00.000Z", + "localDays": 7 + }, + "attempts": [ + { + "attemptId": 4, + "activityId": "b4ae100fd389f4ddae8b1f71fa181cf6281134ae24a328e24c70b0809f522b01", + "ordinal": 1, + "skill": { + "code": "TEKS.4.2A", + "name": "Understand place-value relationships" + }, + "itemType": "multiple_choice", + "timingProfile": "standard_multiple_choice", + "submittedAt": "2026-07-24T14:01:00.000Z", + "isCorrect": false, + "elapsedMs": 60000, + "engagedMs": 60000, + "timingQuality": "engaged", + "chosenLabel": "B", + "misconception": null, + "hintsUsed": 2 + } + ], + "sessions": [], + "computed": { + "attemptCount": 1, + "wrongCount": 1, + "medianWrongDurationMs": 60000, + "personalCorrectBaselineMs": 60000, + "personalSessionMeanBaselineMs": null, + "distractorConcentration": 1, + "winsorizedOutCount": 0 + }, + "derived": { + "wrongOfLastN": { + "wrong": 1, + "of": 1 + }, + "speedRatio": 1, + "consecutiveWrong": 1, + "daysSinceFirstAttempt": 2 + }, + "prerequisiteCheck": null, + "conflicts": [], + "additionalCauses": [], + "abstentions": [], + "languageOptions": { + "catalogVersion": "operations-fallback-v1", + "propositions": [ + { + "id": "diagnosis.hint-farming.independence", + "slotBindings": { + "studentName": { + "bundlePath": "/student/firstName", + "valueHash": "d4c50bd4bc84ea5849f0be3ce67ec437085a0293048e91304a777a037bfd447d" + } + } + } + ], + "openers": [ + { + "id": "opener.hint-farming.first-step", + "slotBindings": { + "studentName": { + "bundlePath": "/student/firstName", + "valueHash": "d4c50bd4bc84ea5849f0be3ce67ec437085a0293048e91304a777a037bfd447d" + } + } + } + ] + } + }, + "selection": { + "diagnosis": { + "propositions": [ + { + "id": "diagnosis.hint-farming.independence", + "slotRefs": { + "studentName": "/student/firstName" + } + } + ] + }, + "opener": { + "id": "opener.hint-farming.first-step", + "slotRefs": { + "studentName": "/student/firstName" + } + } + }, + "diagnosis": "For Drew, the recorded attempts rely on hints more than independent work.", + "opener": "Drew, try an opening step before opening a hint.", + "catalogVersion": "operations-fallback-v1", + "renderVersion": "operations-render-v1", + "languageFingerprint": "3b755f58dc06ca52f3f56f3a70f3d2aa5687b323b7daff20184816ee177ce989", + "narrationFingerprint": "178091e8b2ef779264ff0e0622d43ba474c753b0abab47e87371bb0180d03855" + }, + { + "cause": "no_read_retry", + "bundle": { + "bundleVersion": "portfolio-v1", + "behaviorFingerprint": "7eb13705bce00eecd28b2b8905a753103d37f79a3dd4d5f53cee2b07bb967e55", + "student": { + "id": "synthetic-eval-5", + "firstName": "Avery" + }, + "scope": { + "kind": "skill", + "skill": { + "code": "TEKS.4.2A", + "name": "Understand place-value relationships" + } + }, + "finding": { + "dominantCause": "no_read_retry", + "severity": 0.65, + "rawConfidence": 0.8, + "finalConfidence": 0.8, + "confidenceBreakdown": { + "timingMultiplier": 1, + "winsorizationMultiplier": 1, + "conflictMultiplier": 1 + }, + "ruleId": "portfolio.no_read_retry", + "ruleVersion": "portfolio-v1" + }, + "window": { + "timezone": "America/Chicago", + "start": "2026-07-20T05:00:00.000Z", + "end": "2026-07-27T05:00:00.000Z", + "asOf": "2026-07-27T05:00:00.000Z", + "localDays": 7 + }, + "attempts": [ + { + "attemptId": 5, + "activityId": "cb90ce62b06bf0428cb35aa6898307a697731d4a8973b99ab9bda764914ba93f", + "ordinal": 1, + "skill": { + "code": "TEKS.4.2A", + "name": "Understand place-value relationships" + }, + "itemType": "multiple_choice", + "timingProfile": "standard_multiple_choice", + "submittedAt": "2026-07-24T14:01:00.000Z", + "isCorrect": false, + "elapsedMs": 60000, + "engagedMs": 60000, + "timingQuality": "engaged", + "chosenLabel": "B", + "misconception": null, + "hintsUsed": 0 + } + ], + "sessions": [], + "computed": { + "attemptCount": 1, + "wrongCount": 1, + "medianWrongDurationMs": 60000, + "personalCorrectBaselineMs": 60000, + "personalSessionMeanBaselineMs": null, + "distractorConcentration": 1, + "winsorizedOutCount": 0 + }, + "derived": { + "wrongOfLastN": { + "wrong": 1, + "of": 1 + }, + "speedRatio": 1, + "consecutiveWrong": 1, + "daysSinceFirstAttempt": 2 + }, + "prerequisiteCheck": null, + "conflicts": [], + "additionalCauses": [], + "abstentions": [], + "languageOptions": { + "catalogVersion": "operations-fallback-v1", + "propositions": [ + { + "id": "diagnosis.no-read-retry.review", + "slotBindings": { + "studentName": { + "bundlePath": "/student/firstName", + "valueHash": "ea80ef6b3fc2a06cfe64bad1d2270ab307baffa12aa8798a8a4872494d5d6f56" + } + } + } + ], + "openers": [ + { + "id": "opener.no-read-retry.feedback", + "slotBindings": { + "studentName": { + "bundlePath": "/student/firstName", + "valueHash": "ea80ef6b3fc2a06cfe64bad1d2270ab307baffa12aa8798a8a4872494d5d6f56" + } + } + } + ] + } + }, + "selection": { + "diagnosis": { + "propositions": [ + { + "id": "diagnosis.no-read-retry.review", + "slotRefs": { + "studentName": "/student/firstName" + } + } + ] + }, + "opener": { + "id": "opener.no-read-retry.feedback", + "slotRefs": { + "studentName": "/student/firstName" + } + } + }, + "diagnosis": "For Avery, the recorded retries repeat a response without a review step.", + "opener": "Avery, read the feedback and name a change to make.", + "catalogVersion": "operations-fallback-v1", + "renderVersion": "operations-render-v1", + "languageFingerprint": "232bb3c67918bb1a08b3261f2cce556c812c04e992de2d7b0f7a49dd3c106929", + "narrationFingerprint": "060ab390ff690b45e9ba110518e25535a003727d24fd6e8d50acf94e4cf262e8" + }, + { + "cause": "decay", + "bundle": { + "bundleVersion": "portfolio-v1", + "behaviorFingerprint": "edaf18ccb1fd24eafaab694af0d9d083f0ae03bf3b606b460eca52715d6505ac", + "student": { + "id": "synthetic-eval-6", + "firstName": "Blake" + }, + "scope": { + "kind": "skill", + "skill": { + "code": "TEKS.4.2A", + "name": "Understand place-value relationships" + } + }, + "finding": { + "dominantCause": "decay", + "severity": 0.7, + "rawConfidence": 0.8, + "finalConfidence": 0.8, + "confidenceBreakdown": { + "timingMultiplier": 1, + "winsorizationMultiplier": 1, + "conflictMultiplier": 1 + }, + "ruleId": "portfolio.decay", + "ruleVersion": "portfolio-v1" + }, + "window": { + "timezone": "America/Chicago", + "start": "2026-07-20T05:00:00.000Z", + "end": "2026-07-27T05:00:00.000Z", + "asOf": "2026-07-27T05:00:00.000Z", + "localDays": 7 + }, + "attempts": [ + { + "attemptId": 6, + "activityId": "23fbad435aa2aa63de8ad7e2ff68562e4e6d293908b72e3bf075a93ee0db9e07", + "ordinal": 1, + "skill": { + "code": "TEKS.4.2A", + "name": "Understand place-value relationships" + }, + "itemType": "multiple_choice", + "timingProfile": "standard_multiple_choice", + "submittedAt": "2026-07-24T14:01:00.000Z", + "isCorrect": false, + "elapsedMs": 60000, + "engagedMs": 60000, + "timingQuality": "engaged", + "chosenLabel": "B", + "misconception": null, + "hintsUsed": 0 + } + ], + "sessions": [], + "computed": { + "attemptCount": 1, + "wrongCount": 1, + "medianWrongDurationMs": 60000, + "personalCorrectBaselineMs": 60000, + "personalSessionMeanBaselineMs": null, + "distractorConcentration": 1, + "winsorizedOutCount": 0 + }, + "derived": { + "wrongOfLastN": { + "wrong": 1, + "of": 1 + }, + "speedRatio": 1, + "consecutiveWrong": 1, + "daysSinceFirstAttempt": 2 + }, + "prerequisiteCheck": null, + "conflicts": [], + "additionalCauses": [], + "abstentions": [], + "languageOptions": { + "catalogVersion": "operations-fallback-v1", + "propositions": [ + { + "id": "diagnosis.decay.refresh", + "slotBindings": { + "studentName": { + "bundlePath": "/student/firstName", + "valueHash": "fba998af95ba8e33d3f243f1fb90676526d6e67ca28107dd8c1a5cffafc70df9" + } + } + } + ], + "openers": [ + { + "id": "opener.decay.example", + "slotBindings": { + "studentName": { + "bundlePath": "/student/firstName", + "valueHash": "fba998af95ba8e33d3f243f1fb90676526d6e67ca28107dd8c1a5cffafc70df9" + } + } + } + ] + } + }, + "selection": { + "diagnosis": { + "propositions": [ + { + "id": "diagnosis.decay.refresh", + "slotRefs": { + "studentName": "/student/firstName" + } + } + ] + }, + "opener": { + "id": "opener.decay.example", + "slotRefs": { + "studentName": "/student/firstName" + } + } + }, + "diagnosis": "For Blake, the recorded work indicates that an earlier skill needs review.", + "opener": "Blake, let’s revisit an earlier example before continuing.", + "catalogVersion": "operations-fallback-v1", + "renderVersion": "operations-render-v1", + "languageFingerprint": "c93151654b4457d695e68a8414b768ac1a9879ab568f4af885ec99ca1edd6bb0", + "narrationFingerprint": "4823198f972141ad77b2fdaacf46326428c9877b3d18d7302bb24db784d067d0" + }, + { + "cause": "disengagement", + "bundle": { + "bundleVersion": "portfolio-v1", + "behaviorFingerprint": "fdeb5e07e82e201db772f4e7b2d07ee9d3fc591a096b28a5afe97ba8eee42db4", + "student": { + "id": "synthetic-eval-7", + "firstName": "Casey" + }, + "scope": { + "kind": "cross-skill" + }, + "finding": { + "dominantCause": "disengagement", + "severity": 0.75, + "rawConfidence": 0.8, + "finalConfidence": 0.8, + "confidenceBreakdown": { + "timingMultiplier": 1, + "winsorizationMultiplier": 1, + "conflictMultiplier": 1 + }, + "ruleId": "portfolio.disengagement", + "ruleVersion": "portfolio-v1" + }, + "window": { + "timezone": "America/Chicago", + "start": "2026-07-20T05:00:00.000Z", + "end": "2026-07-27T05:00:00.000Z", + "asOf": "2026-07-27T05:00:00.000Z", + "localDays": 7 + }, + "attempts": [ + { + "attemptId": 7, + "activityId": "f2510b3020055ab6bf49893bee63d500b650b9e8f783994dc13a18c755c1ed8b", + "ordinal": 1, + "skill": { + "code": "TEKS.4.2A", + "name": "Understand place-value relationships" + }, + "itemType": "multiple_choice", + "timingProfile": "standard_multiple_choice", + "submittedAt": "2026-07-24T14:01:00.000Z", + "isCorrect": false, + "elapsedMs": 60000, + "engagedMs": 60000, + "timingQuality": "engaged", + "chosenLabel": "B", + "misconception": null, + "hintsUsed": 0 + } + ], + "sessions": [], + "computed": { + "attemptCount": 1, + "wrongCount": 1, + "medianWrongDurationMs": 60000, + "personalCorrectBaselineMs": 60000, + "personalSessionMeanBaselineMs": null, + "distractorConcentration": 1, + "winsorizedOutCount": 0 + }, + "derived": { + "wrongOfLastN": { + "wrong": 1, + "of": 1 + }, + "speedRatio": 1, + "consecutiveWrong": 1, + "daysSinceFirstAttempt": 2 + }, + "prerequisiteCheck": null, + "conflicts": [], + "additionalCauses": [], + "abstentions": [], + "languageOptions": { + "catalogVersion": "operations-fallback-v1", + "propositions": [ + { + "id": "diagnosis.disengagement.activity", + "slotBindings": { + "studentName": { + "bundlePath": "/student/firstName", + "valueHash": "2ac22451977a4a15aededd7e991ca72beabc3452de416476d2b6460fc8115cb5" + } + } + } + ], + "openers": [ + { + "id": "opener.disengagement.next-step", + "slotBindings": { + "studentName": { + "bundlePath": "/student/firstName", + "valueHash": "2ac22451977a4a15aededd7e991ca72beabc3452de416476d2b6460fc8115cb5" + } + } + } + ] + } + }, + "selection": { + "diagnosis": { + "propositions": [ + { + "id": "diagnosis.disengagement.activity", + "slotRefs": { + "studentName": "/student/firstName" + } + } + ] + }, + "opener": { + "id": "opener.disengagement.next-step", + "slotRefs": { + "studentName": "/student/firstName" + } + } + }, + "diagnosis": "For Casey, recent recorded activity is lower than this student’s own pattern.", + "opener": "Casey, what would make the next step feel manageable?", + "catalogVersion": "operations-fallback-v1", + "renderVersion": "operations-render-v1", + "languageFingerprint": "51c3a9ad6af2c530a0ee0a2040b95e00a84162b60dec3d311414aa36e8c444ca", + "narrationFingerprint": "9ac7815f100cc172d8b2831dcdd8ddf845d4f0d4a95b5aedba0820138b24c61c" + }, + { + "cause": "fine", + "bundle": { + "bundleVersion": "portfolio-v1", + "behaviorFingerprint": "fd047719a16795dab90623cc92ed1500b45759e94f1e7378706d188b3d5b65d3", + "student": { + "id": "synthetic-eval-8", + "firstName": "Drew" + }, + "scope": { + "kind": "skill", + "skill": { + "code": "TEKS.4.2A", + "name": "Understand place-value relationships" + } + }, + "finding": { + "dominantCause": "fine", + "severity": 0, + "rawConfidence": 0.8, + "finalConfidence": 0.8, + "confidenceBreakdown": { + "timingMultiplier": 1, + "winsorizationMultiplier": 1, + "conflictMultiplier": 1 + }, + "ruleId": "portfolio.fine", + "ruleVersion": "portfolio-v1" + }, + "window": { + "timezone": "America/Chicago", + "start": "2026-07-20T05:00:00.000Z", + "end": "2026-07-27T05:00:00.000Z", + "asOf": "2026-07-27T05:00:00.000Z", + "localDays": 7 + }, + "attempts": [ + { + "attemptId": 8, + "activityId": "be0aa68a5dead693d760480ede8a968be05fddd859ba2d5959333fdde393350c", + "ordinal": 1, + "skill": { + "code": "TEKS.4.2A", + "name": "Understand place-value relationships" + }, + "itemType": "multiple_choice", + "timingProfile": "standard_multiple_choice", + "submittedAt": "2026-07-24T14:01:00.000Z", + "isCorrect": true, + "elapsedMs": 60000, + "engagedMs": 60000, + "timingQuality": "engaged", + "chosenLabel": "A", + "misconception": null, + "hintsUsed": 0 + } + ], + "sessions": [], + "computed": { + "attemptCount": 1, + "wrongCount": 0, + "medianWrongDurationMs": null, + "personalCorrectBaselineMs": 60000, + "personalSessionMeanBaselineMs": null, + "distractorConcentration": null, + "winsorizedOutCount": 0 + }, + "derived": { + "wrongOfLastN": null, + "speedRatio": null, + "consecutiveWrong": 0, + "daysSinceFirstAttempt": 2 + }, + "prerequisiteCheck": null, + "conflicts": [], + "additionalCauses": [], + "abstentions": [], + "languageOptions": { + "catalogVersion": "operations-fallback-v1", + "propositions": [ + { + "id": "diagnosis.fine.continue", + "slotBindings": { + "studentName": { + "bundlePath": "/student/firstName", + "valueHash": "d4c50bd4bc84ea5849f0be3ce67ec437085a0293048e91304a777a037bfd447d" + } + } + } + ], + "openers": [ + { + "id": "opener.fine.continue", + "slotBindings": { + "studentName": { + "bundlePath": "/student/firstName", + "valueHash": "d4c50bd4bc84ea5849f0be3ce67ec437085a0293048e91304a777a037bfd447d" + } + } + } + ] + } + }, + "selection": { + "diagnosis": { + "propositions": [ + { + "id": "diagnosis.fine.continue", + "slotRefs": { + "studentName": "/student/firstName" + } + } + ] + }, + "opener": { + "id": "opener.fine.continue", + "slotRefs": { + "studentName": "/student/firstName" + } + } + }, + "diagnosis": "For Drew, the recorded work does not indicate an intervention.", + "opener": "Drew, keep going with the next item.", + "catalogVersion": "operations-fallback-v1", + "renderVersion": "operations-render-v1", + "languageFingerprint": "5bcd616784a0853d1b79132c0545cc629f28f50a8920efeb0b3efea99abd6595", + "narrationFingerprint": "8b0177c8a229cd63eb18b1ef612f748a0cbb687c0522542ca7f39d9a7d76cd0d" + } + ] +} diff --git a/packages/eval/test/portfolio-gate.test.ts b/packages/eval/test/portfolio-gate.test.ts new file mode 100644 index 0000000..ef927b3 --- /dev/null +++ b/packages/eval/test/portfolio-gate.test.ts @@ -0,0 +1,29 @@ +import { readFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { rootCauseValues } from '@huddle/core'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '../../..'); + +describe('portfolio evaluation gate', () => { + it('commits a complete zero-model fallback corpus with no accuracy claim', async () => { + const artifact = JSON.parse( + await readFile(join(root, 'packages/eval/fixtures/portfolio/fallback-corpus.json'), 'utf8') + ) as { + modelCalls: number; + accuracyPosture: string; + entries: Array<{ cause: string; diagnosis: string; opener: string }>; + }; + expect(artifact.modelCalls).toBe(0); + expect(artifact.accuracyPosture).toMatch(/No diagnosis-accuracy claim/); + expect(artifact.entries.map((entry) => entry.cause)).toEqual(rootCauseValues); + expect(artifact.entries.every((entry) => entry.diagnosis && entry.opener)).toBe(true); + }); + + it('keeps the portfolio runner on the deterministic catalog path, not a model client', async () => { + const source = await readFile(join(root, 'scripts/portfolio-eval.ts'), 'utf8'); + expect(source).toContain('@huddle/narrator/catalog.js'); + expect(source).not.toMatch(/@anthropic-ai|new Narrator|\.messages\.|renderBatch/); + }); +}); diff --git a/packages/ingest/test/portfolio-csv.test.ts b/packages/ingest/test/portfolio-csv.test.ts new file mode 100644 index 0000000..7a5afe0 --- /dev/null +++ b/packages/ingest/test/portfolio-csv.test.ts @@ -0,0 +1,43 @@ +import { readFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { items, skills } from '@huddle/core/seed'; +import { analyzeSyntheticCsv } from '../src/synthetic-csv-v1.js'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '../../..'); + +describe('fixed portfolio CSV', () => { + it('is a fully accepted synthetic-only corpus for the fixed roster', async () => { + const content = await readFile(join(root, 'apps/web/public/synthetic-huddle-sample.csv')); + const analysis = analyzeSyntheticCsv( + { + name: 'synthetic-huddle-sample.csv', + mediaType: 'text/csv', + sizeBytes: content.byteLength, + content, + }, + { + students: new Map( + [1, 2, 3, 4].map((number) => [ + `synthetic-student-0${number}`, + { id: `00000000-0000-0000-0000-00000000000${number}` }, + ]) + ), + skills: new Set(skills.map((skill) => skill.id)), + items: new Map(items.map((item) => [item.id, item])), + } + ); + expect(analysis.committable).toBe(true); + expect(analysis.counts).toEqual({ + received: 52, + accepted: 52, + duplicate: 0, + unmapped: 0, + rejected: 0, + }); + expect( + analysis.acceptedAttempts.every((attempt) => attempt.source === 'synthetic-csv-v1') + ).toBe(true); + }); +}); diff --git a/packages/narrator/package.json b/packages/narrator/package.json index 2342b2b..47af339 100644 --- a/packages/narrator/package.json +++ b/packages/narrator/package.json @@ -9,6 +9,10 @@ "import": "./dist/src/index.js", "default": "./dist/src/index.js", "types": "./dist/src/index.d.ts" + }, + "./catalog.js": { + "import": "./dist/src/catalog.js", + "types": "./dist/src/catalog.d.ts" } }, "scripts": { diff --git a/scripts/demo-data.ts b/scripts/demo-data.ts new file mode 100644 index 0000000..3ef5393 --- /dev/null +++ b/scripts/demo-data.ts @@ -0,0 +1,45 @@ +import '@huddle/core'; + +const help = `Huddle synthetic reviewer data + +Usage: + npm run demo:seed [-- --auth-user-id ] + npm run demo:reset -- --confirm-synthetic-only [--auth-user-id ] + +The UUID defaults to HUDDLE_DEMO_AUTH_USER_ID loaded from .env. seed is idempotent and preserves +imported activity. reset deletes only the fixed synthetic demo +scope's imports, board runs, evidence opens, and acknowledgments before restoring the roster. +`; + +function option(name: string): string | null { + const index = process.argv.indexOf(name); + return index >= 0 ? (process.argv[index + 1] ?? null) : null; +} + +async function main(): Promise { + if (process.argv.includes('--help')) { + console.log(help); + return; + } + const mode = process.argv[2]; + if (mode !== 'seed' && mode !== 'reset') throw new Error(help); + const authUserId = option('--auth-user-id') ?? process.env.HUDDLE_DEMO_AUTH_USER_ID ?? ''; + if ( + !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(authUserId) + ) { + throw new Error('A valid --auth-user-id or HUDDLE_DEMO_AUTH_USER_ID is required.'); + } + if (mode === 'reset' && !process.argv.includes('--confirm-synthetic-only')) { + throw new Error('Reset requires --confirm-synthetic-only.'); + } + const { seedSyntheticDemo } = await import('@huddle/db/demo-seed.js'); + const result = await seedSyntheticDemo({ authUserId, reset: mode === 'reset' }); + console.log( + `${result.reset ? 'Reset' : 'Seeded'} synthetic demo guide ${result.guideId} (${result.rosterSize} synthetic students).` + ); +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : 'Synthetic demo setup failed.'); + process.exitCode = 1; +}); diff --git a/scripts/portfolio-eval.ts b/scripts/portfolio-eval.ts new file mode 100644 index 0000000..a15ffa0 --- /dev/null +++ b/scripts/portfolio-eval.ts @@ -0,0 +1,203 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { createHash } from 'node:crypto'; +import type { EvidenceBundle, RootCause } from '@huddle/core'; +import { rootCauseValues } from '@huddle/core'; +import { + deterministicFallback, + validateFallbackSelection, + type NarrationSelection, +} from '@huddle/narrator/catalog.js'; + +const corpusPath = resolve('packages/eval/fixtures/portfolio/fallback-corpus.json'); +const defaultReportPath = resolve('artifacts/portfolio-eval-report.json'); +const accuracyPosture = + 'No diagnosis-accuracy claim; this gate covers fallback and grounding only.'; + +function digest(value: string): string { + return createHash('sha256').update(value).digest('hex'); +} + +function bundle(cause: RootCause, index: number): EvidenceBundle { + const studentName = ['Avery', 'Blake', 'Casey', 'Drew'][index % 4]!; + const skill = { code: 'TEKS.4.2A', name: 'Understand place-value relationships' }; + return { + bundleVersion: 'portfolio-v1', + behaviorFingerprint: digest(`portfolio-behavior-${cause}`), + student: { id: `synthetic-eval-${index + 1}`, firstName: studentName }, + scope: cause === 'disengagement' ? { kind: 'cross-skill' } : { kind: 'skill', skill }, + finding: { + dominantCause: cause, + severity: cause === 'fine' ? 0 : Number((0.45 + index * 0.05).toFixed(2)), + rawConfidence: 0.8, + finalConfidence: 0.8, + confidenceBreakdown: { + timingMultiplier: 1, + winsorizationMultiplier: 1, + conflictMultiplier: 1, + }, + ruleId: `portfolio.${cause}`, + ruleVersion: 'portfolio-v1', + }, + window: { + timezone: 'America/Chicago', + start: '2026-07-20T05:00:00.000Z', + end: '2026-07-27T05:00:00.000Z', + asOf: '2026-07-27T05:00:00.000Z', + localDays: 7, + }, + attempts: [ + { + attemptId: index + 1, + activityId: digest(`synthetic-activity-${cause}`), + ordinal: 1, + skill, + itemType: 'multiple_choice', + timingProfile: 'standard_multiple_choice', + submittedAt: '2026-07-24T14:01:00.000Z', + isCorrect: cause === 'fine', + elapsedMs: 60_000, + engagedMs: 60_000, + timingQuality: 'engaged', + chosenLabel: cause === 'fine' ? 'A' : 'B', + misconception: null, + hintsUsed: cause === 'hint_farming' ? 2 : 0, + }, + ], + sessions: [], + computed: { + attemptCount: 1, + wrongCount: cause === 'fine' ? 0 : 1, + medianWrongDurationMs: cause === 'fine' ? null : 60_000, + personalCorrectBaselineMs: 60_000, + personalSessionMeanBaselineMs: null, + distractorConcentration: cause === 'fine' ? null : 1, + winsorizedOutCount: 0, + }, + derived: { + wrongOfLastN: cause === 'fine' ? null : { wrong: 1, of: 1 }, + speedRatio: cause === 'fine' ? null : 1, + consecutiveWrong: cause === 'fine' ? 0 : 1, + daysSinceFirstAttempt: 2, + }, + prerequisiteCheck: null, + conflicts: [], + additionalCauses: [], + abstentions: [], + languageOptions: { catalogVersion: '', propositions: [], openers: [] }, + }; +} + +function buildCorpus() { + return { + schemaVersion: 'portfolio-fallback-corpus-v1', + generatedBy: 'npm run eval:portfolio -- --update', + modelCalls: 0, + accuracyPosture, + entries: rootCauseValues.map((cause, index) => { + const fallback = deterministicFallback(bundle(cause, index)); + return { + cause, + bundle: fallback.bundle, + selection: fallback.selection, + diagnosis: fallback.diagnosis, + opener: fallback.opener, + catalogVersion: fallback.catalogVersion, + renderVersion: fallback.renderVersion, + languageFingerprint: fallback.languageFingerprint, + narrationFingerprint: fallback.narrationFingerprint, + }; + }), + }; +} + +type PortfolioCorpus = ReturnType; + +function assertHardFailRegressions(corpus: PortfolioCorpus): { + unknownCatalogIdRejected: number; + unauthorizedSlotRejected: number; +} { + let unknownCatalogIdRejected = 0; + let unauthorizedSlotRejected = 0; + for (const entry of corpus.entries) { + const unknownId = structuredClone(entry.selection) as NarrationSelection; + unknownId.opener.id = 'opener.unreviewed.fabrication'; + try { + validateFallbackSelection(entry.bundle, unknownId); + } catch { + unknownCatalogIdRejected += 1; + } + const unsupportedSlot = structuredClone(entry.selection) as NarrationSelection; + unsupportedSlot.opener.slotRefs.studentName = '/computed/wrongCount'; + try { + validateFallbackSelection(entry.bundle, unsupportedSlot); + } catch { + unauthorizedSlotRejected += 1; + } + } + if ( + unknownCatalogIdRejected !== corpus.entries.length || + unauthorizedSlotRejected !== corpus.entries.length + ) { + throw new Error('Grounding injection regression did not hard-fail for every corpus entry.'); + } + return { unknownCatalogIdRejected, unauthorizedSlotRejected }; +} + +function outputOption(): string { + const index = process.argv.indexOf('--out'); + return index >= 0 && process.argv[index + 1] + ? resolve(process.argv[index + 1]!) + : defaultReportPath; +} + +async function main(): Promise { + if (process.argv.includes('--help')) { + console.log( + `Usage: npm run eval:portfolio [-- --out ]\n\nVerifies the committed fixed synthetic fallback corpus, hard-fail grounding injections, and zero-model-call posture. It makes no diagnosis-accuracy claim.` + ); + return; + } + const expected = buildCorpus(); + const canonical = `${JSON.stringify(expected, null, 2)}\n`; + if (process.argv.includes('--update')) { + await mkdir(dirname(corpusPath), { recursive: true }); + await writeFile(corpusPath, canonical); + } + const committed = await readFile(corpusPath, 'utf8'); + if (committed !== canonical) { + throw new Error( + 'Committed portfolio fallback corpus is stale; review an explicit --update diff.' + ); + } + const parsed = JSON.parse(committed) as PortfolioCorpus; + if (parsed.modelCalls !== 0 || parsed.entries.length !== rootCauseValues.length) { + throw new Error('Portfolio corpus is incomplete or does not attest zero model calls.'); + } + for (const entry of parsed.entries) validateFallbackSelection(entry.bundle, entry.selection); + const injections = assertHardFailRegressions(parsed); + const report = { + schemaVersion: 'portfolio-eval-report-v1', + passed: true, + corpusFingerprint: digest(committed), + corpusEntries: parsed.entries.length, + causeCoverage: parsed.entries.map((entry) => entry.cause), + modelCalls: 0, + deterministicFallbackByteStable: true, + groundingInjectionHardFailures: injections, + accuracyPosture, + }; + const reportPath = outputOption(); + await mkdir(dirname(reportPath), { recursive: true }); + await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`); + console.log( + `Portfolio evaluation passed: ${report.corpusEntries} fixed synthetic cases; model calls: 0.` + ); + console.log(accuracyPosture); + console.log(`Reviewable report: ${reportPath}`); +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : 'Portfolio evaluation failed.'); + process.exitCode = 1; +}); diff --git a/scripts/smoke-hosted.ts b/scripts/smoke-hosted.ts new file mode 100644 index 0000000..b019e4c --- /dev/null +++ b/scripts/smoke-hosted.ts @@ -0,0 +1,58 @@ +const help = `Usage: npm run smoke:hosted -- --base-url + +Checks public health/login assets and verifies the unauthenticated board fails closed. The captain's +final authenticated browser walkthrough remains manual by design. +`; + +function baseUrl(): URL { + const index = process.argv.indexOf('--base-url'); + const value = index >= 0 ? process.argv[index + 1] : null; + if (!value) throw new Error(help); + const url = new URL(value); + if (url.protocol !== 'https:' && !['localhost', '127.0.0.1'].includes(url.hostname)) { + throw new Error('Hosted smoke requires HTTPS (localhost is allowed).'); + } + return url; +} + +async function text(base: URL, path: string): Promise { + const response = await fetch(new URL(path, base), { redirect: 'follow' }); + if (!response.ok) throw new Error(`${path} returned HTTP ${response.status}.`); + return response.text(); +} + +async function main(): Promise { + if (process.argv.includes('--help')) { + console.log(help); + return; + } + const base = baseUrl(); + const health = JSON.parse(await text(base, '/health')) as Record; + if ( + health.ok !== true || + health.dataPolicy !== 'synthetic-only' || + health.decisionSupport !== 'deterministic' || + health.accuracyClaim !== 'none' + ) { + throw new Error('Hosted health contract is incomplete.'); + } + const login = await text(base, '/login'); + if (!login.includes('Sign in to Huddle')) + throw new Error('Reviewer sign-in page is unavailable.'); + const board = await text(base, '/board'); + if (!board.includes('Guide workspace unavailable') || /Avery|Blake|Casey|Drew/.test(board)) { + throw new Error('Unauthenticated board did not fail closed.'); + } + const sample = await text(base, '/synthetic-huddle-sample.csv'); + const rows = sample.trim().split(/\r?\n/); + if (rows.length !== 53 || rows.slice(1).some((row) => !row.startsWith('synthetic,'))) { + throw new Error('Hosted fixed CSV is missing or not entirely synthetic.'); + } + console.log(`Hosted configuration smoke passed for ${base.origin}.`); + console.log('Authenticated upload/refresh/evidence/acknowledgment walkthrough remains manual.'); +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : 'Hosted smoke failed.'); + process.exitCode = 1; +}); diff --git a/scripts/verify-quickstart.ts b/scripts/verify-quickstart.ts new file mode 100644 index 0000000..317ae60 --- /dev/null +++ b/scripts/verify-quickstart.ts @@ -0,0 +1,77 @@ +import { readFile } from 'node:fs/promises'; +import { spawnSync } from 'node:child_process'; + +const help = `Usage: npm run verify:quickstart\n\nRuns the noninteractive, credential-free portfolio checks. Database migration/seed and the final authenticated browser walkthrough remain explicit operator steps.`; + +function run(args: string[], env = process.env): void { + const result = spawnSync('npm', args, { stdio: 'inherit', env }); + if (result.status !== 0) throw new Error(`npm ${args.join(' ')} failed.`); +} + +async function main(): Promise { + if (process.argv.includes('--help')) { + console.log(help); + return; + } + const [manifestText, envExample, readme, vercel] = await Promise.all([ + readFile('package.json', 'utf8'), + readFile('.env.example', 'utf8'), + readFile('README.md', 'utf8'), + readFile('vercel.json', 'utf8'), + ]); + const manifest = JSON.parse(manifestText) as { scripts?: Record }; + for (const command of [ + 'db:migrate', + 'demo:seed', + 'demo:reset', + 'eval:portfolio', + 'smoke:hosted', + 'verify:quickstart', + ]) { + if (!manifest.scripts?.[command]) throw new Error(`Missing root command: ${command}`); + } + for (const variable of [ + 'NEXT_PUBLIC_SUPABASE_URL', + 'NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY', + 'DATABASE_ADMIN_URL', + 'DATABASE_URL', + 'HUDDLE_DEMO_AUTH_USER_ID', + 'HUDDLE_ACKNOWLEDGMENT_KEY_CURRENT', + 'HUDDLE_DEMO_BOARD_DATE', + ]) { + if (!envExample.includes(variable)) throw new Error(`.env.example is missing ${variable}.`); + } + if (!readme.includes('Two-minute reviewer walkthrough')) { + throw new Error('README is missing the reviewer walkthrough.'); + } + JSON.parse(vercel); + + run(['run', 'db:migrate', '--', '--help']); + run(['run', 'demo:seed', '--', '--help']); + run(['run', 'demo:reset', '--', '--help']); + run(['run', 'smoke:hosted', '--', '--help']); + const modelFreeEnv = { ...process.env }; + delete modelFreeEnv.ANTHROPIC_API_KEY; + run( + ['run', 'eval:portfolio', '--', '--out', '/tmp/huddle-portfolio-eval-report.json'], + modelFreeEnv + ); + run(['run', 'check:determinism'], modelFreeEnv); + run([ + 'exec', + 'vitest', + 'run', + 'packages/ingest/test/portfolio-csv.test.ts', + 'apps/web/test/demo-config.test.ts', + 'apps/web/test/health-route.test.ts', + ]); + console.log('Quickstart contract passed without credentials or model calls.'); + console.log( + 'Operator steps remaining: migrate, seed reviewer Auth UUID, then manual golden-path walkthrough.' + ); +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : 'Quickstart verification failed.'); + process.exitCode = 1; +}); diff --git a/specs/001-huddle-triage-board/plan.md b/specs/001-huddle-triage-board/plan.md index b14754d..f4dbcf9 100644 --- a/specs/001-huddle-triage-board/plan.md +++ b/specs/001-huddle-triage-board/plan.md @@ -2,10 +2,10 @@ **Branch**: `001-huddle-triage-board` | **Date**: 2026-07-27 | **Spec**: [spec.md](./spec.md) -**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. +**Status**: Focused portfolio demo implemented — the synthetic Operations path and Evidence Desk are +composed through PostgreSQL-backed board/evidence/acknowledgment adapters, with private reviewer +Auth, fixed no-model portfolio evaluation, and Vercel/Supabase packaging. Optional model narration, +real-data gates, and the full simulator/accuracy evaluation remain out of scope. **Input**: Feature specification from `/specs/001-huddle-triage-board/spec.md` @@ -245,12 +245,12 @@ repository physically names the dedicated data-access package `packages/db`; thi 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. 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. +`EvidenceReader` visible-open boundary are composed through guide-scoped PostgreSQL adapters. The +board reads only the selected immutable head; evidence opens receive signed one-use grants, acquire a +current-head reveal lease, and write replay-safe first-open acknowledgments to PostgreSQL. The fixed +portfolio corpus verifies deterministic fallback and grounding with zero model calls and no accuracy +claim. Optional model-backed narration attachment, deployment scheduler wiring, and the full +simulator/accuracy gates remain 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 b522bd1..a638b14 100644 --- a/specs/001-huddle-triage-board/quickstart.md +++ b/specs/001-huddle-triage-board/quickstart.md @@ -1,369 +1,65 @@ -# Quickstart: Huddle — Morning Triage Board +# Quickstart: Huddle portfolio demo -**Feature**: `001-huddle-triage-board` | **Status**: partial implementation command contract +**Status**: focused synthetic reviewer path implemented. The root [README](../../../README.md) is the +single operator walkthrough; this file records the feature-level command contract without duplicating +presentation prose. -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. +## Clean setup -## Prerequisites and setup - -- Node 22 LTS and npm -- 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 - -No browser bundle receives `DATABASE_URL`, service-role key, SQL client, unrestricted Supabase client, -or student-data query. Direct Data API privileges for browser roles remain revoked. RLS must be added -before real data or a multi-guide pilot. +Prerequisites: Node 22, a dedicated Supabase Postgres 16 project, and one private Supabase Auth +reviewer account. Copy `.env.example` to `.env`, set the documented server/public values, and keep +`HUDDLE_DEMO_BOARD_DATE=2026-07-27` for the reviewed fixed CSV. ```bash -npm install -cp .env.example .env -npm run db:migrate -- --help +npm ci npm run db:migrate -npm run seed -- --help -npm run seed -- --seed 42 -``` - -`seed` handles exactly the requested seed. With no `--out`, it generates and loads that seed. With -`--out`, it is generate-only unless `--load` is also passed. It never silently generates both splits; -tune/report orchestration invokes seeds 42 and 1337 separately. All student data is synthetic. - -## Command-to-task contract - -Every `npm run` command documented anywhere in this file is listed here. - -| Root script | Implementation task | Acceptance/check task | -|---|---|---| -| `db:migrate` | T009–T012/T022/T113/T117/T121/T129 | T002a/T002b, T010a–T012a/T115/T120/T125/T130 | -| `seed` | T078 | T002b, T067–T071 | -| `diff:students` | T079 | T002b, T021/T067 | -| `calibrate:winsorization` | T021a, T018b | T002b, T021b | -| `nightly` | T056 | T002b, T032/T108b/T108c | -| `dev` | T057–T059 | T002b, T032/manual board checks | -| `check:determinism` | T025/T026 | T002b, T008a | -| `verify:scenario` | T028–T033d | T002b, named scenario tasks | -| `verify:traceability` | T062 | T002b, T060/T061 | -| `eval` | T084–T091/T095 | T002b, T080–T088a | -| `narration:refresh` | T082/T082a/T082b | T002b, trusted-only cache review | -| `test:second-adapter` | T098/T106 | T002b, T098 | -| `test:idempotency` | T099/T105 | T002b, T099–T101a | -| `verify:mastery-equivalence` | T027 | T002b, T012a/T027 | -| `verify:operations` | T108–T108c | T002b, operational evidence | -| `verify:guide-flow` | T133 | T002a/T002b/T115–T131/T136 | -| `verify:quickstart` | T111 | T002b/T111/T136 | - -The non-root setup commands are covered too: `npm install` maps to T002/T008 and a clean-checkout -install in T111; `cp .env.example .env` maps to T006/T111; and the system `diff` in validation 2 maps -to T067/T111. Shell builtins such as `unset` and the documented browser URL are setup/manual steps, -not hidden application commands. - -T002b treats `--help` as a command contract and must not start a long-running server, mutate the DB, -call a model, or require credentials. `verify:quickstart` invokes noninteractive scenarios and prints -the remaining manual browser/study protocol without pretending it automated it. - -## 1. Calibration provenance - -Calibration is not full simulator generation and never uses seeds 42/1337. - -```bash -npm run calibrate:winsorization -- --help -npm run calibrate:winsorization -- --verify-committed -``` - -Expected: the minimal timing-profile/PRNG corpus regenerates byte-identically and its 99th-percentile -bounds match the reviewed committed artifact. Ingest and detector work must not start if they differ. - -## 2. Synthetic determinism - -```bash -npm run seed -- --seed 42 --out /tmp/school-a.json -npm run seed -- --seed 42 --out /tmp/school-b.json -diff /tmp/school-a.json /tmp/school-b.json && echo IDENTICAL -npm run seed -- --seed 42 --students 61 --out /tmp/school-c.json -npm run diff:students -- --help -npm run diff:students -- /tmp/school-a.json /tmp/school-c.json -``` - -Expected: byte-identical first pair; exactly one added student and zero modified students in the -second comparison. Ground truth uses Monday-to-Monday `America/Chicago` weeks and report seed 1337 -has at least 20 truth rows per cause. - -## 3. Model-free board and boundary gates - -```bash -unset ANTHROPIC_API_KEY -npm run nightly -- --help -npm run dev -- --help -# Terminal 1: start the protected local application and leave it running. +npm run demo:seed 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 ``` -`--fixture-now` is accepted only in test/verification mode; production rejects it. This makes the -historical cutoff assertion truthful instead of comparing a 2026 board to the present clock. +Then sign in at `/login`, open `/import`, upload `apps/web/public/synthetic-huddle-sample.csv`, +validate, commit, refresh, return to `/board`, open a report, expand exact evidence, and observe the +visible-open acknowledgment. All roster/activity data in this path is synthetic. -Expected: after server verification resolves exactly one synthetic guide/studio scope, the local date -has `asOf=2026-07-27T00:00:00 America/Chicago`; the window is the preceding seven local calendar days. -The complete deterministic board plus cause-specific validated fallback language atomically becomes -`board_head` before the 07:45 cutoff with root causes, severity, final confidence, stable rank, and no -lost entry. Each fallback is visibly `degraded`; generated narration may attach later but cannot delay -or change publication. Missing/invalid/ambiguous/cross-guide/non-synthetic access exposes neither -roster nor freshness and never becomes an empty success. +Explicit reset for the fixed synthetic scope: ```bash -npm run check:determinism -- --help -npm run check:determinism +npm run demo:reset -- --confirm-synthetic-only ``` -Expected: only `packages/db` declares/imports `node-postgres`; core and application are driver-free; -signal-engine reaches no DB/model/simulator traits transitively; eval scoring reaches no model; -narrator reaches no DB; ingest reaches no `packages/db` implementation/driver. Only ingest may -inspect or branch on source; `packages/db` may persist source/dedupe values opaquely through the -injected port but exposes no source-dependent behavior. Client modules reach no DB/server -implementation, credential, unrestricted Supabase data client, or public CRUD API. - -## 3a. Secure quick-demo workflow - -```bash -npm run verify:guide-flow -- --help -npm run verify:guide-flow -- --case auth -npm run verify:guide-flow -- --case import -npm run verify:guide-flow -- --case refresh -npm run verify:guide-flow -- --case evidence-desk -npm run verify:guide-flow -- --case auto-ack -npm run verify:guide-flow -- --case degraded-narration -npm run verify:guide-flow -- --case browser-boundary -npm run verify:guide-flow -- --case no-notifications -``` - -Expected by case: - -- **auth**: valid synthetic credentials resolve one server-owned guide/studio scope; missing/invalid, - ambiguous, cross-guide, non-synthetic, and incomplete-config requests fail closed before any - roster/freshness/evidence/import read. -- **import**: the fixed UTF-8 `synthetic-csv-v1` header, ≤5 MiB/10,000-row limits, and - `dataset_kind=synthetic` are enforced. Preview stores no payload/staged attempt. Commit re-submits - and re-hashes the file, reports authoritative accepted/duplicate/unmapped/rejected counts, and - preserves valid mixed rows. Same key/digest replays return the same receipt; same key with changed - bytes and real-data markers fail. -- **refresh**: `idle → queued → running → succeeded` promotes exactly one complete immutable run/head; - injected failure records `failed` and preserves the prior deterministic board projection - byte-for-byte: head/run selection, membership, ranks, causes, evidence, scopes, confidence, and - freshness. Narration may attach asynchronously but cannot alter that projection. First-build - failure is `not-built`, not all-clear. Nightly/manual requests use the same compiler and Chicago - anchor. -- **evidence-desk**: desktop ranked rail + detail and mobile queue/detail share a stable URL. The - three levels disclose summary/opener, baseline/prerequisite/conflict, then exact attempts/sessions - for the dominant and every additional cause, including each secondary finding's own computed/ - derived values, prerequisite check, and conflicts. Back restores row focus/scroll. The displayed - stale head remains inspectable; superseded/cross-guide IDs reveal nothing. -- **auto-ack**: prefetch is grant-free. Actual opening bypasses prefetched evidence and performs a - fresh authorized read that returns a signed five-minute, one-use grant plus a capability bound to - that exact opening. Once its placeholder is visible, an atomic reveal lease verifies the selected - run, entry, finding, and unexpired grant before evidence is painted; supersession before acquisition - denies the reveal, while supersession after acquisition does not revoke the authorized immutable - report. A result without the full post-round-trip safety window is refreshed. After visibility, - grant expiry or in-flight expiry atomically claims the expired source nonce and creates at most one - replacement for the same opening, including after refresh supersession, and can never authorize - another report. Sequential and concurrent losing renewal attempts return the stored acknowledgment - with zero additional writes. Opening after a prefetch older than the TTL still acknowledges. - List/prefetch/tampered/failed/unauthorized paths write zero; each consumed initial, renewed, and - reopen nonce appears once in the replay ledger; replays/reopens are idempotent; and changed evidence - requires a new acknowledgment. The executable boundary is owned by - [`contracts/application-interfaces.md`](./contracts/application-interfaces.md). -- **degraded-narration**: every cause and model failure retains membership/cause/confidence/rank, - renders a non-empty grounded cause-specific fallback, and surfaces exact degraded reason. -- **browser-boundary**: production client output contains only public Auth configuration and no - DB/service credential, SQL/driver, unrestricted Supabase client, `.from(...)` student read, or - server implementation import. -- **no-notifications**: no refresh/import/acknowledgment path sends or schedules email, SMS, push, - webhook, or in-product notification. - -Manual browser journey after the noninteractive cases: - -1. open `http://localhost:3000/login` and sign in as the provisioned synthetic guide; -2. open Import, validate the synthetic sample, inspect all four result counts, and commit; -3. choose Refresh board and observe state while the previous head remains visible; -4. select entries in the Evidence Desk, expand exact evidence, confirm degraded provenance when the - model is disabled, and return to the same rail position with acknowledgment shown. - -## 4. Deterministic diagnosis, ranking, and confidence - -```bash -npm run verify:scenario -- --help -npm run verify:scenario -- --case guessing-not-prereq -npm run verify:scenario -- --case prereq-blocked -npm run verify:scenario -- --case fine-excluded -npm run verify:scenario -- --case timing-free-discrimination -npm run verify:scenario -- --case conflict-confidence -npm run verify:scenario -- --case recorded-absence -npm run verify:scenario -- --case ranking-total-order -``` - -Expected: - -- fast wrong with strong prerequisite is `guessing`; weak prerequisite names its TEKS prerequisite; -- a genuinely fine student is classified `fine` and excluded from the board; -- with no timing, clustered misconception, scattered guessing, and repeated-no-help grinding remain - distinguishable; -- any cross-family conflict applies the 0.70 multiplier once and lists every conflict; -- recorded absence suppresses disengagement, while an unexplained gap does not imply absence; -- dominant finding and board order are severity DESC, final confidence DESC, rule ID ASC, skill ID - ASC with cross-skill last, student ID ASC; severity is never multiplied by confidence. - -## 5. Evidence and qualitative grounding - -```bash -npm run verify:traceability -- --help -npm run verify:traceability -- --board-date 2026-07-27 -``` - -Expected: every attempt/session/computed value is visible through the progressive Evidence Desk and -every generated or fallback diagnosis/opener comes from a permitted catalog ID plus exact authorized -slot bindings. Unknown propositions, unsupported “distracted”/student attributes, missing or extra -slots, unauthorized paths, stale hashes, fabricated atoms, over-length output, or an empty/vague -opener fail. Diagnosis is ≤45 words; the concrete opener is exactly one sentence/question and ≤20 -words. Cross-skill evidence invents no headline skill, and every ordered additional cause resolves to -its own exact bundle-backed computed/derived values, prerequisite check, conflicts, attempts, and -sessions. Traceability/list reads are side-effect free; visible-open acknowledgment is covered only -by `verify:guide-flow -- --case auto-ack`. - -## 6. Narration cache (trusted operator only) - -CI and untrusted PRs never call the model. A reviewer with trusted credentials deliberately refreshes -a changed cache out of band: - -```bash -npm run narration:refresh -- --help -npm run narration:refresh -- --trusted --seed 1337 -``` - -Expected: canonical bundles produce a reviewed cache/manifest keyed by bundle hash and full narration -fingerprint (prompt, catalog, output schema, model, generation parameters). Every hit revalidates. In -ordinary CI, any miss/extra/stale entry fails rather than making a direct call. - -## 7. Non-vacuous weekly evaluation +## Credential-free checks ```bash unset ANTHROPIC_API_KEY -npm run eval -- --help -npm run eval -- --seed 1337 -``` - -Expected header identifies tune seed 42, report seed 1337, the complete `behavior_fingerprint`, the -narration fingerprint, `America/Chicago`, and the Monday-morning just-completed-week snapshot. Only -Monday boards are rows. -The report shows, for every cause and tier, truth support, classified support, raw coverage, -abstention, and conditional precision/recall/F1. - -Hard gates: - -- at least 20 report truth rows per cause; -- engaged/none conditional macro-F1 at least 0.85/0.65 and fine FPR at most 0.05; -- per-cause **and aggregate** classified fraction at least 0.90/0.85/0.70/0.60 for - engaged/wall-clock/session-only/none; -- forced abstentions remain abstentions, while mass/selective abstention fails coverage; -- fixed observations/predictions and truth-only weekly permutations with seeds - `[17,29,43,71,101]`; maximum macro-F1 at most 0.25; -- zero catalog/slot/qualitative/atom grounding violations and complete non-empty cache corpus; -- compatible canonical JSON regression baseline, with Markdown derived. - -## 8. Portable ingest - -```bash -npm run test:second-adapter -- --help -npm run test:second-adapter -npm run test:idempotency -- --help -npm run test:idempotency -``` - -Expected: fixture-B changes only ingest/fixtures. Three replays produce stable attempt, session, and -unmapped counts, while reused source IDs in another application scope/student do not collide. -Successful vendor skill mapping persists the expected TEKS FK and resolves the expected item type and -timing profile. An -unknown skill persists once in `unmapped_activity` while valid mixed-batch attempts persist. A -session total/count is stored once and attempt durations stay null; winsorization runs once in the -pipeline. - -## 9. Mastery and operations - -```bash -npm run verify:mastery-equivalence -- --help -npm run verify:mastery-equivalence -- --board-date 2026-07-27 -npm run verify:operations -- --help -NODE_ENV=test npm run verify:operations -- --board-date 2026-07-27 \ - --fixture-now 2026-07-27T07:30:00-05:00 +npm run eval:portfolio +npm run verify:quickstart +npm run typecheck +npm run lint +npm run format:check +npm run check:determinism +npm test +npm run build ``` -Expected: run-keyed snapshot equals `mastery_at(board_run.asOf)`; there is no update/direct-edit -path. Publication exposes only the complete guide-scoped deterministic run selected by `board_head` -and commits it plus all entries by 07:45 `America/Chicago`. Queued/running/failed refresh keeps the -previous head; first-build failure is not successful-empty. Deterministic fallback exists at -publication. Generated narration has separate status/timing and cannot delay or alter the board. +The portfolio evaluation hard-fails stale fallback bytes, unknown catalog IDs, and unauthorized slot +bindings across its fixed eight-case corpus. Its report states `modelCalls: 0` and makes no +accuracy claim. -Operational evidence uses the fixed 60-student fully rendered board: +## Hosted package -1. five guide-role participants each record the top five in displayed order and retrieve each exact - opener; timer starts when actionable content is visible and stops at the fifth correct result; - every participant must finish in ≤60 seconds; -2. release build + warm PostgreSQL, 5 warm-up then 30 measured Playwright navigations, navigation - start until the 60-student board/top-five actionable content are visible; p95 <3 seconds; -3. DST/window tests prove seven local days, Monday-only scoring, non-Monday exclusion, and exact - mastery anchor. - -## 10. Full contract check +Vercel uses the committed `vercel.json`; Supabase remains the Auth/Postgres boundary. Migrate and seed +from a trusted operator terminal, configure only the README-listed runtime variables in Vercel, then: ```bash -npm run verify:quickstart -- --help -npm run verify:quickstart +npm run smoke:hosted -- --base-url https://YOUR-PRIVATE-DEMO.vercel.app ``` -Expected: every noninteractive validation above runs; command inventory matches this file; manual -browser/participant evidence is named rather than silently skipped. All seven constitution gates -must pass before merge. +This checks deployment/policy and anonymous fail-closed behavior. The authenticated browser journey +remains the captain's required manual acceptance step. -## Troubleshooting +## Deliberately deferred -| Symptom | Contract failure | -|---|---| -| DB driver outside `packages/db` or browser data client present | dedicated server-only data-access boundary violated | -| session-only equals none | aggregate missing or session rule abstaining incorrectly | -| attempt has divided session duration | ingest timing honesty violated | -| valid row disappears beside unknown skill | mixed-batch/unmapped handling violated | -| ranking differs on ties | comparator or NULL-last scope order missing | -| zero grounding violations with missing cache | vacuous corpus; cache completeness gate missing | -| CI makes a model call | fail-closed cache policy violated | -| high conditional F1 with low coverage passes | per-cause/tier coverage gate missing | -| permutation reruns detectors | invalid negative control; predictions must stay fixed | -| two eval rows for one student/week | a non-Monday/overlapping board was scored | -| qualitative “distracted” passes | closed proposition/slot validation missing | -| mastery equivalence flakes | ambient clock or mutable snapshot path exists | -| partial/late board visible | immutable publication/head or 07:45 cutoff gate failed | -| failed refresh hides prior board | refresh request overwrote `board_head` | -| report prefetch acknowledges | post-visible-open action boundary missing | -| model outage shows blank opener | mandatory cause-specific fallback missing | -| import preview changes attempts | validate/commit separation broken | -| authenticated browser can query data | Auth-only client/Data API revocation boundary broken | -| notification dependency appears | v1 no-notification gate broken | +Real student data, RLS expansion, retention policy, public self-service tenancy, parent/coach portals, +notifications, severity customization, the broad simulator/evaluation platform, and any accuracy- +backed claim remain gated by the [plan](./plan.md), [constitution](../../.specify/memory/constitution.md), +and [evaluation contract](./contracts/eval-harness.md). diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..fb02b8d --- /dev/null +++ b/vercel.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "framework": "nextjs", + "installCommand": "npm ci", + "buildCommand": "npm run build", + "outputDirectory": "apps/web/.next" +} From 7087feb8e2ff859d5614d7d83b85ab61ff384200 Mon Sep 17 00:00:00 2001 From: alexdancer Date: Wed, 29 Jul 2026 22:59:30 -0500 Subject: [PATCH 02/13] fix: verify hosted database TLS --- .env.example | 4 +++ README.md | 4 ++- packages/db/src/client.ts | 5 +-- packages/db/src/demo-seed.ts | 3 +- packages/db/src/pool-config.ts | 19 +++++++++++ .../db/test/acknowledgment-privileges.test.ts | 9 ++--- packages/db/test/pool-config.test.ts | 33 +++++++++++++++++++ scripts/verify-quickstart.ts | 1 + 8 files changed, 70 insertions(+), 8 deletions(-) create mode 100644 packages/db/src/pool-config.ts create mode 100644 packages/db/test/pool-config.test.ts diff --git a/.env.example b/.env.example index 3ffcbdc..e3418a8 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,10 @@ DATABASE_ADMIN_URL=postgresql://postgres:[ADMIN-PASSWORD]@db.[PROJECT-REF].supab # Restricted huddle_app runtime connection. Server-only; never prefix it with NEXT_PUBLIC. DATABASE_URL=postgresql://huddle_app:[RUNTIME-PASSWORD]@db.[PROJECT-REF].supabase.co:5432/postgres +# Public Server root CA from Supabase Database SSL Configuration, base64 encoded. The server-side +# data layer uses it for certificate and hostname verification; never expose this variable to JS. +SUPABASE_DB_CA_BASE64=base64-encoded-pem-certificate + # Operator-only UUID copied from the one synthetic reviewer account in Supabase Auth. HUDDLE_DEMO_AUTH_USER_ID=00000000-0000-4000-8000-000000000000 diff --git a/README.md b/README.md index 207cfd6..16bb387 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,8 @@ student records. npm ci cp .env.example .env # Fill the two public Auth values, DATABASE_ADMIN_URL, DATABASE_URL, -# HUDDLE_DEMO_AUTH_USER_ID, and HUDDLE_ACKNOWLEDGMENT_KEY_CURRENT. +# SUPABASE_DB_CA_BASE64, HUDDLE_DEMO_AUTH_USER_ID, and +# HUDDLE_ACKNOWLEDGMENT_KEY_CURRENT. npm run db:migrate npm run demo:seed npm run dev @@ -97,6 +98,7 @@ the evaluation contract remains deferred before any pilot or accuracy-backed cla - `NEXT_PUBLIC_SUPABASE_URL`, `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY` - server-only `DATABASE_URL` +- server-only `SUPABASE_DB_CA_BASE64` (the public Supabase Server root CA, base64 encoded) - server-only `HUDDLE_ACKNOWLEDGMENT_KEY_CURRENT` - `HUDDLE_DEMO_BOARD_DATE=2026-07-27` - `INTERNAL_REFRESH_SECRET` only if the protected nightly dispatcher will be used diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts index 3cf8a58..4ee5032 100644 --- a/packages/db/src/client.ts +++ b/packages/db/src/client.ts @@ -2,6 +2,7 @@ import { readFile, readdir } from 'node:fs/promises'; import { join, resolve } from 'node:path'; import pg from 'pg'; import { config } from '@huddle/core'; +import { databasePoolConfig } from './pool-config.js'; const { Pool } = pg; @@ -9,7 +10,7 @@ if (!config.DATABASE_URL) { throw new Error('DATABASE_URL is required when importing the database boundary'); } -export const pool = new Pool({ connectionString: config.DATABASE_URL }); +export const pool = new Pool(databasePoolConfig(config.DATABASE_URL)); export interface Migration { file: string; @@ -29,7 +30,7 @@ export async function runMigrations(migrationsDir: string): Promise { .filter((file) => file.endsWith('.sql')) .sort((a, b) => a.localeCompare(b, 'en-US')); - const adminPool = new Pool({ connectionString: config.DATABASE_ADMIN_URL }); + const adminPool = new Pool(databasePoolConfig(config.DATABASE_ADMIN_URL)); const client = await adminPool.connect(); try { await client.query(` diff --git a/packages/db/src/demo-seed.ts b/packages/db/src/demo-seed.ts index fa1539e..7b74332 100644 --- a/packages/db/src/demo-seed.ts +++ b/packages/db/src/demo-seed.ts @@ -1,6 +1,7 @@ import pg from 'pg'; import { config } from '@huddle/core'; import { items, skillPrereqs, skills } from '@huddle/core/seed'; +import { databasePoolConfig } from './pool-config.js'; const { Pool } = pg; @@ -92,7 +93,7 @@ export async function seedSyntheticDemo(input: { reset?: boolean; }): Promise { if (!config.DATABASE_ADMIN_URL) throw new Error('DATABASE_ADMIN_URL is required.'); - const admin = new Pool({ connectionString: config.DATABASE_ADMIN_URL }); + const admin = new Pool(databasePoolConfig(config.DATABASE_ADMIN_URL)); const client = await admin.connect(); try { await client.query('BEGIN'); diff --git a/packages/db/src/pool-config.ts b/packages/db/src/pool-config.ts new file mode 100644 index 0000000..0d0d7bb --- /dev/null +++ b/packages/db/src/pool-config.ts @@ -0,0 +1,19 @@ +import type { PoolConfig } from 'pg'; + +const PEM_CERTIFICATE = /^-----BEGIN CERTIFICATE-----[\s\S]+-----END CERTIFICATE-----\s*$/; + +/** Require CA-verified TLS when the hosted Supabase CA is configured. */ +export function databasePoolConfig(connectionString: string): PoolConfig { + const encodedCa = process.env.SUPABASE_DB_CA_BASE64; + if (!encodedCa) return { connectionString }; + + const ca = Buffer.from(encodedCa, 'base64').toString('utf8'); + if (!PEM_CERTIFICATE.test(ca)) { + throw new Error('SUPABASE_DB_CA_BASE64 must contain one base64-encoded PEM certificate.'); + } + + return { + connectionString, + ssl: { ca, rejectUnauthorized: true }, + }; +} diff --git a/packages/db/test/acknowledgment-privileges.test.ts b/packages/db/test/acknowledgment-privileges.test.ts index d50fa4a..ee79b05 100644 --- a/packages/db/test/acknowledgment-privileges.test.ts +++ b/packages/db/test/acknowledgment-privileges.test.ts @@ -3,6 +3,7 @@ import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import pg from 'pg'; import type { Pool } from 'pg'; +import { databasePoolConfig } from '../src/pool-config.js'; const { Pool: PgPool } = pg; const migrations = join(dirname(fileURLToPath(import.meta.url)), '../../../db/migrations'); @@ -14,10 +15,10 @@ describe.skipIf(process.env.RUN_DB_TESTS !== '1')('acknowledgment runtime privil beforeAll(async () => { const database = await import('../src/client.js'); await database.runMigrations(migrations); - admin = new PgPool({ - connectionString: process.env.DATABASE_ADMIN_URL ?? process.env.DATABASE_URL, - }); - runtime = new PgPool({ connectionString: process.env.DATABASE_URL }); + admin = new PgPool( + databasePoolConfig(process.env.DATABASE_ADMIN_URL ?? process.env.DATABASE_URL ?? '') + ); + runtime = new PgPool(databasePoolConfig(process.env.DATABASE_URL ?? '')); }); afterAll(async () => { diff --git a/packages/db/test/pool-config.test.ts b/packages/db/test/pool-config.test.ts new file mode 100644 index 0000000..d538020 --- /dev/null +++ b/packages/db/test/pool-config.test.ts @@ -0,0 +1,33 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { databasePoolConfig } from '../src/pool-config.js'; + +const TEST_CA = `-----BEGIN CERTIFICATE----- +synthetic-test-certificate +-----END CERTIFICATE----- +`; + +afterEach(() => vi.unstubAllEnvs()); + +describe('databasePoolConfig', () => { + it('keeps local database connections unchanged when no hosted CA is configured', () => { + vi.stubEnv('SUPABASE_DB_CA_BASE64', ''); + expect(databasePoolConfig('postgresql://localhost/huddle')).toEqual({ + connectionString: 'postgresql://localhost/huddle', + }); + }); + + it('requires CA-verified TLS when the hosted CA is configured', () => { + vi.stubEnv('SUPABASE_DB_CA_BASE64', Buffer.from(TEST_CA).toString('base64')); + expect(databasePoolConfig('postgresql://host/huddle')).toEqual({ + connectionString: 'postgresql://host/huddle', + ssl: { ca: TEST_CA, rejectUnauthorized: true }, + }); + }); + + it('rejects malformed hosted CA configuration', () => { + vi.stubEnv('SUPABASE_DB_CA_BASE64', Buffer.from('not a certificate').toString('base64')); + expect(() => databasePoolConfig('postgresql://host/huddle')).toThrow( + 'SUPABASE_DB_CA_BASE64 must contain one base64-encoded PEM certificate.' + ); + }); +}); diff --git a/scripts/verify-quickstart.ts b/scripts/verify-quickstart.ts index 317ae60..2a2afa7 100644 --- a/scripts/verify-quickstart.ts +++ b/scripts/verify-quickstart.ts @@ -35,6 +35,7 @@ async function main(): Promise { 'NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY', 'DATABASE_ADMIN_URL', 'DATABASE_URL', + 'SUPABASE_DB_CA_BASE64', 'HUDDLE_DEMO_AUTH_USER_ID', 'HUDDLE_ACKNOWLEDGMENT_KEY_CURRENT', 'HUDDLE_DEMO_BOARD_DATE', From 171a263f30a3a22a2d8d2712b889c3ce58785b0a Mon Sep 17 00:00:00 2001 From: alexdancer Date: Wed, 29 Jul 2026 23:20:56 -0500 Subject: [PATCH 03/13] fix: smoke protected hosted demo --- .env.example | 4 ++++ README.md | 6 ++++-- scripts/smoke-hosted.ts | 11 ++++++++--- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/.env.example b/.env.example index e3418a8..3183c89 100644 --- a/.env.example +++ b/.env.example @@ -29,6 +29,10 @@ INTERNAL_REFRESH_SECRET=replace-with-a-different-random-secret INTERNAL_REFRESH_SECRET_PREVIOUS= INTERNAL_REFRESH_URL=http://127.0.0.1:3000/internal/refresh +# Optional operator-only secret for smoke-checking a Vercel Deployment Protection wall. Never add +# this bypass value to the deployed application environment. +VERCEL_PROTECTION_BYPASS= + # Optional generated narration only. Leave unset for the reviewed deterministic-fallback demo/eval. ANTHROPIC_API_KEY= diff --git a/README.md b/README.md index 16bb387..6561016 100644 --- a/README.md +++ b/README.md @@ -108,14 +108,16 @@ credential in browser-visible variables. Run migrations and `demo:seed` from a t terminal before deployment. The reviewer-facing `/board` and `/import` operations require the Supabase account; unauthenticated requests expose no roster, evidence, or freshness. -After Vercel reports ready: +After Vercel reports ready, set `VERCEL_PROTECTION_BYPASS` only in the operator's gitignored +`.env` when Deployment Protection is enabled, then run: ```bash npm run smoke:hosted -- --base-url https://YOUR-PRIVATE-DEMO.vercel.app ``` That command checks health/policy output, sign-in, the fixed synthetic CSV, and fail-closed anonymous -board access. The captain still performs the authenticated walkthrough below; the smoke command does +board access through the protection wall. Never add the bypass value to Vercel's application +environment. The captain still performs the authenticated walkthrough below; the smoke command does not pretend to replace it. ## Two-minute reviewer walkthrough diff --git a/scripts/smoke-hosted.ts b/scripts/smoke-hosted.ts index b019e4c..4d2c434 100644 --- a/scripts/smoke-hosted.ts +++ b/scripts/smoke-hosted.ts @@ -1,7 +1,10 @@ +import 'dotenv/config'; + const help = `Usage: npm run smoke:hosted -- --base-url -Checks public health/login assets and verifies the unauthenticated board fails closed. The captain's -final authenticated browser walkthrough remains manual by design. +Checks health/login assets and verifies the unauthenticated board fails closed. For a protected +Vercel deployment, set VERCEL_PROTECTION_BYPASS only in the operator's gitignored environment. +The captain's final authenticated browser walkthrough remains manual by design. `; function baseUrl(): URL { @@ -16,7 +19,9 @@ function baseUrl(): URL { } async function text(base: URL, path: string): Promise { - const response = await fetch(new URL(path, base), { redirect: 'follow' }); + const bypass = process.env.VERCEL_PROTECTION_BYPASS; + const headers = bypass ? { 'x-vercel-protection-bypass': bypass } : undefined; + const response = await fetch(new URL(path, base), { headers, redirect: 'follow' }); if (!response.ok) throw new Error(`${path} returned HTTP ${response.status}.`); return response.text(); } From 3c51b988d9ea2a8cdffe8780a0e02d7ae0ec851a Mon Sep 17 00:00:00 2001 From: alexdancer Date: Wed, 29 Jul 2026 23:41:30 -0500 Subject: [PATCH 04/13] fix: keep reveal dependencies out of Flight payload --- .../web/app/board/acknowledge-visible-open.ts | 53 ++++++++----------- .../board/lib/visible-open-action-handler.ts | 30 +++++++++++ apps/web/app/board/page.tsx | 15 ++---- apps/web/test/evidence-desk-state.test.ts | 21 ++++---- .../test/report-reveal-server-action.test.ts | 23 ++++++++ 5 files changed, 88 insertions(+), 54 deletions(-) create mode 100644 apps/web/app/board/lib/visible-open-action-handler.ts create mode 100644 apps/web/test/report-reveal-server-action.test.ts diff --git a/apps/web/app/board/acknowledge-visible-open.ts b/apps/web/app/board/acknowledge-visible-open.ts index 1ba71fb..aa44e61 100644 --- a/apps/web/app/board/acknowledge-visible-open.ts +++ b/apps/web/app/board/acknowledge-visible-open.ts @@ -1,41 +1,30 @@ -import 'server-only'; +'use server'; -import type { EvidenceReader, GuideAccess } from '@huddle/application'; import type { VisibleAcknowledgmentAction, VisibleOpenAuthorizationAction, } from './visible-open-acknowledgment'; +import { resolveGuideAccess } from '../../lib/guide-access'; +import { evidenceReaderForRequest } from '../../lib/evidence-desk-operations'; +import { + runAcknowledgeVisibleOpen, + runAuthorizeVisibleOpen, +} from './lib/visible-open-action-handler'; -/** - * The composition root binds the request's server-resolved reader. Browser input contains only the - * two opaque credentials; scope, report IDs, and finding fingerprints are never action parameters. - */ -export function createAcknowledgeVisibleOpenAction(dependencies: { - resolveAccess(): Promise; - evidenceReader: EvidenceReader; -}): VisibleAcknowledgmentAction { - return async ({ acknowledgmentGrant, openingRenewalToken }) => { - 'use server'; - const access = await dependencies.resolveAccess(); - if (!access) return { kind: 'not-found' }; - return dependencies.evidenceReader.acknowledgeVisibleOpen(access, { - acknowledgmentGrant, - openingRenewalToken, - }); - }; +/** Module-level action captures no request dependencies in the React Flight payload. */ +export async function acknowledgeVisibleOpenAction( + input: Parameters[0] +): ReturnType { + const evidenceReader = evidenceReaderForRequest(); + if (!evidenceReader) return { kind: 'not-found' }; + return runAcknowledgeVisibleOpen({ resolveAccess: resolveGuideAccess, evidenceReader }, input); } -export function createAuthorizeVisibleOpenAction(dependencies: { - resolveAccess(): Promise; - evidenceReader: EvidenceReader; -}): VisibleOpenAuthorizationAction { - return async ({ acknowledgmentGrant, openingRenewalToken }) => { - 'use server'; - const access = await dependencies.resolveAccess(); - if (!access) return { kind: 'not-found' }; - return dependencies.evidenceReader.authorizeVisibleOpen(access, { - acknowledgmentGrant, - openingRenewalToken, - }); - }; +/** Module-level action resolves auth and reconstructs the fail-closed reader on every POST. */ +export async function authorizeVisibleOpenAction( + input: Parameters[0] +): ReturnType { + const evidenceReader = evidenceReaderForRequest(); + if (!evidenceReader) return { kind: 'not-found' }; + return runAuthorizeVisibleOpen({ resolveAccess: resolveGuideAccess, evidenceReader }, input); } diff --git a/apps/web/app/board/lib/visible-open-action-handler.ts b/apps/web/app/board/lib/visible-open-action-handler.ts new file mode 100644 index 0000000..d0e55ca --- /dev/null +++ b/apps/web/app/board/lib/visible-open-action-handler.ts @@ -0,0 +1,30 @@ +import 'server-only'; + +import type { EvidenceReader, GuideAccess } from '@huddle/application'; +import type { + VisibleAcknowledgmentAction, + VisibleOpenAuthorizationAction, +} from '../visible-open-acknowledgment'; + +export interface VisibleOpenActionDependencies { + resolveAccess(): Promise; + evidenceReader: EvidenceReader; +} + +export async function runAcknowledgeVisibleOpen( + dependencies: VisibleOpenActionDependencies, + input: Parameters[0] +): ReturnType { + const access = await dependencies.resolveAccess(); + if (!access) return { kind: 'not-found' }; + return dependencies.evidenceReader.acknowledgeVisibleOpen(access, input); +} + +export async function runAuthorizeVisibleOpen( + dependencies: VisibleOpenActionDependencies, + input: Parameters[0] +): ReturnType { + const access = await dependencies.resolveAccess(); + if (!access) return { kind: 'not-found' }; + return dependencies.evidenceReader.authorizeVisibleOpen(access, input); +} diff --git a/apps/web/app/board/page.tsx b/apps/web/app/board/page.tsx index 35fc6f5..5c2faac 100644 --- a/apps/web/app/board/page.tsx +++ b/apps/web/app/board/page.tsx @@ -2,8 +2,8 @@ import { resolveGuideAccess } from '../../lib/guide-access'; import { configuredBoardDate } from '../../lib/demo-config'; import { boardReader, evidenceReaderForRequest } from '../../lib/evidence-desk-operations'; import { - createAcknowledgeVisibleOpenAction, - createAuthorizeVisibleOpenAction, + acknowledgeVisibleOpenAction, + authorizeVisibleOpenAction, } from './acknowledge-visible-open'; import { EvidenceDesk } from './evidence-desk'; import { readEvidenceDeskState } from './lib/evidence-desk-state'; @@ -33,20 +33,13 @@ export default async function BoardPage({ triageEntryId: query.entry, } ); - const actionDependencies = evidenceReader - ? { resolveAccess: resolveGuideAccess, evidenceReader } - : null; return ( <> ); diff --git a/apps/web/test/evidence-desk-state.test.ts b/apps/web/test/evidence-desk-state.test.ts index 42baafe..5dd9cc4 100644 --- a/apps/web/test/evidence-desk-state.test.ts +++ b/apps/web/test/evidence-desk-state.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it, vi } from 'vitest'; import type { BoardReader, BoardView, EvidenceReader, GuideAccess } from '@huddle/application'; import { - createAcknowledgeVisibleOpenAction, - createAuthorizeVisibleOpenAction, -} from '../app/board/acknowledge-visible-open'; + runAcknowledgeVisibleOpen, + runAuthorizeVisibleOpen, +} from '../app/board/lib/visible-open-action-handler'; import { readEvidenceDeskState } from '../app/board/lib/evidence-desk-state'; const access: GuideAccess = { @@ -134,14 +134,13 @@ describe('Evidence Desk shell-state adapter', () => { acknowledgedAt: '2026-07-29T08:01:00.000Z', })), }; - const action = createAcknowledgeVisibleOpenAction({ - resolveAccess: async () => access, - evidenceReader, - }); - const authorizationAction = createAuthorizeVisibleOpenAction({ - resolveAccess: async () => access, - evidenceReader, - }); + const dependencies = { resolveAccess: async () => access, evidenceReader }; + const action = (input: { acknowledgmentGrant: string; openingRenewalToken: string }) => + runAcknowledgeVisibleOpen(dependencies, input); + const authorizationAction = (input: { + acknowledgmentGrant: string; + openingRenewalToken: string; + }) => runAuthorizeVisibleOpen(dependencies, input); await expect( authorizationAction({ acknowledgmentGrant: 'opaque-grant', diff --git a/apps/web/test/report-reveal-server-action.test.ts b/apps/web/test/report-reveal-server-action.test.ts new file mode 100644 index 0000000..852e5c6 --- /dev/null +++ b/apps/web/test/report-reveal-server-action.test.ts @@ -0,0 +1,23 @@ +import { readFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); + +describe('hosted report reveal server-action boundary', () => { + it('passes only module-level server actions to the hydrated reveal component', async () => { + const [actions, page] = await Promise.all([ + readFile(join(root, 'app/board/acknowledge-visible-open.ts'), 'utf8'), + readFile(join(root, 'app/board/page.tsx'), 'utf8'), + ]); + + expect(actions.trimStart().startsWith("'use server';")).toBe(true); + expect(actions).toContain('export async function authorizeVisibleOpenAction'); + expect(actions).toContain('export async function acknowledgeVisibleOpenAction'); + expect(actions).not.toMatch(/create(?:Authorize|Acknowledge)VisibleOpenAction/); + expect(page).toContain('authorizationAction={authorizeVisibleOpenAction}'); + expect(page).toContain('action={acknowledgeVisibleOpenAction}'); + expect(page).not.toContain('actionDependencies'); + }); +}); From 56e590e6d3a436207efd9c739a5d8bb68f24bd43 Mon Sep 17 00:00:00 2001 From: alexdancer Date: Thu, 30 Jul 2026 00:27:03 -0500 Subject: [PATCH 05/13] feat: simplify teacher demo experience --- apps/web/app/board/evidence-desk.tsx | 570 ++++++------ apps/web/app/board/page.tsx | 20 +- .../app/board/visible-open-acknowledgment.tsx | 66 +- apps/web/app/globals.css | 878 ++++++++++++++++-- apps/web/app/import/import-workflow.tsx | 105 +-- apps/web/app/import/page.tsx | 16 +- apps/web/app/layout.tsx | 18 +- apps/web/app/login/page.tsx | 25 +- apps/web/test/evidence-desk-rendering.test.ts | 72 +- 9 files changed, 1283 insertions(+), 487 deletions(-) diff --git a/apps/web/app/board/evidence-desk.tsx b/apps/web/app/board/evidence-desk.tsx index 83039db..75ab300 100644 --- a/apps/web/app/board/evidence-desk.tsx +++ b/apps/web/app/board/evidence-desk.tsx @@ -31,6 +31,7 @@ const causeLabels: Record = { export function humanizeCause(cause: BoardEntryView['cause']): string { return causeLabels[cause]; } + export function priorityBand( severity: number ): 'Urgent priority' | 'Elevated priority' | 'Watch priority' { @@ -38,52 +39,88 @@ export function priorityBand( if (severity >= 0.5) return 'Elevated priority'; return 'Watch priority'; } + export function confidenceLabel(confidence: number): 'High' | 'Medium' | 'Low' { if (confidence >= 0.8) return 'High'; if (confidence >= 0.55) return 'Medium'; return 'Low'; } + +function priorityIcon(severity: number): string { + if (severity >= 0.75) return '◆'; + if (severity >= 0.5) return '▲'; + return '●'; +} + function formatMetric(value: number | null): string { - return value == null ? 'Not available (null)' : String(value); + return value == null ? 'Not available' : String(value); +} + +function exactTiming(value: number | null): string { + return value == null ? 'Not recorded' : `${value} ms`; } -function timing(value: number | null): string { - return value == null ? 'Not recorded (null)' : `${value} ms`; + +function readableTiming(value: number | null): string { + if (value == null) return 'Not recorded'; + if (value < 1_000) return `${value} ms`; + const seconds = value / 1_000; + if (seconds < 60) return `${Number(seconds.toFixed(3))} sec`; + const minutes = Math.floor(seconds / 60); + const remainder = Number((seconds % 60).toFixed(3)); + return remainder ? `${minutes}m ${remainder}s` : `${minutes}m`; } + +function formatTimestamp(value: string): string { + const date = new Date(value); + if (Number.isNaN(date.valueOf())) return value; + return new Intl.DateTimeFormat('en-US', { + timeZone: 'America/Chicago', + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + timeZoneName: 'short', + }).format(date); +} + function scopeLabel(scope: BoardEntryView['scope']): string { return scope.kind === 'skill' ? `${scope.skill.code} · ${scope.skill.name}` : 'Cross-skill evidence'; } + +function scopeShortLabel(scope: BoardEntryView['scope']): string { + return scope.kind === 'skill' ? scope.skill.code : 'Across skills'; +} + export function refreshDescription(refresh: RefreshView, hasCommittedBoard: boolean): string { switch (refresh.state) { case 'idle': - return hasCommittedBoard - ? 'Refresh idle; no refresh is in progress' - : 'Refresh idle; no completed board is available'; + return hasCommittedBoard ? 'Refresh ready' : 'No completed board yet'; case 'queued': - return `Refresh queued at ${refresh.requestedAt} · request ${refresh.requestId}${hasCommittedBoard ? '; the committed board remains usable' : '; no completed board is available yet'}`; + return `Refresh queued ${formatTimestamp(refresh.requestedAt)}${hasCommittedBoard ? '; current board stays available' : ''}`; case 'running': - return `Refresh running · requested at ${refresh.requestedAt} · request ${refresh.requestId}${hasCommittedBoard ? '; the committed board remains usable' : '; no completed board is available yet'}`; + return `Refreshing since ${formatTimestamp(refresh.requestedAt)}${hasCommittedBoard ? '; current board stays available' : ''}`; case 'succeeded': - return `Refresh succeeded at ${refresh.completedAt} · request ${refresh.requestId} · board ${refresh.boardRunId}`; + return `Last refresh completed ${formatTimestamp(refresh.completedAt)}`; case 'failed': - return `Refresh failed at ${refresh.completedAt} · ${refresh.failureCode} · request ${refresh.requestId}${refresh.preservedBoardRunId ? `; board ${refresh.preservedBoardRunId} remains usable` : '; no completed board is available'}`; + return `Refresh failed ${formatTimestamp(refresh.completedAt)}${refresh.preservedBoardRunId ? '; current board preserved' : '; no completed board available'}`; } } + function StatusChip({ entry }: { entry: BoardEntryView }) { + const band = priorityBand(entry.severity); return ( -
- - {priorityBand(entry.severity)} - - - Evidence confidence: {confidenceLabel(entry.finalConfidence)} +
+ + {band} + #{entry.rank} today + {confidenceLabel(entry.finalConfidence)} confidence
); } + function Freshness({ state, }: { @@ -92,19 +129,19 @@ function Freshness({ { kind: 'board' | 'board-updated' | 'report-unavailable' } >['board']; }) { - const freshness = - state.kind === 'stale' ? 'Last successful board — stale' : 'Committed board ready'; + const freshness = state.kind === 'stale' ? 'Last successful board' : 'Board ready'; const narration = state.narration.status === 'degraded' - ? `Narration partially available · ${state.narration.degradedCount} degraded report${state.narration.degradedCount === 1 ? '' : 's'}` - : 'Narration complete'; + ? `${state.narration.degradedCount} deterministic fallback${state.narration.degradedCount === 1 ? '' : 's'}` + : 'Conversation openers ready'; return (
- + {freshness} - As of {state.asOf} · America/Chicago · synthetic guide workspace - {narration} - {refreshDescription(state.refresh, true)} + As of {formatTimestamp(state.asOf)} · synthetic guide workspace + + {narration} · {refreshDescription(state.refresh, true)} +
); } @@ -122,42 +159,48 @@ function Rail({ return ( ); @@ -179,104 +222,90 @@ type EvidenceDisclosure = Pick< function AttemptTable({ attempts }: { attempts: EvidenceDisclosure['attempts'] }) { return ( - - - - - - - - - - - - - {attempts.map((attempt) => ( - - - - - - - +
+
Attempt identityWhenSkillItem metadataOutcomeTiming
- Attempt ID {attempt.attemptId ?? 'null'} -
- Activity {attempt.activityId} -
- Ordinal {attempt.ordinal} -
{attempt.submittedAt} - {attempt.skill.code} -
- {attempt.skill.name} -
- {attempt.itemType} -
- {attempt.timingProfile} -
- {attempt.isCorrect ? 'Correct' : 'Incorrect'} -
- Chosen label: {attempt.chosenLabel ?? 'null'} -
- Misconception: {attempt.misconception ?? 'null'} -
- Hints used: {attempt.hintsUsed} -
- Wall-clock: {timing(attempt.elapsedMs)} -
- Engaged: {timing(attempt.engagedMs)} -
- Quality: {attempt.timingQuality} -
+ + + + + + - ))} - -
WhenEvidenceResultTiming
+ + + {attempts.map((attempt) => ( + + {attempt.submittedAt} + + {attempt.skill.code} · {attempt.skill.name} + + Attempt ID {attempt.attemptId ?? 'null'} · Activity {attempt.activityId} · Ordinal{' '} + {attempt.ordinal} + + + {attempt.itemType} · {attempt.timingProfile} + + + + {attempt.isCorrect ? 'Correct' : 'Incorrect'} + Chosen label: {attempt.chosenLabel ?? 'null'} + Misconception: {attempt.misconception ?? 'null'} + Hints used: {attempt.hintsUsed} + + + Wall-clock: {exactTiming(attempt.elapsedMs)} + Engaged: {exactTiming(attempt.engagedMs)} + Quality: {attempt.timingQuality} + + + ))} + + +
); } function SessionTable({ sessions }: { sessions: EvidenceDisclosure['sessions'] }) { + if (!sessions.length) return null; return ( - - - - - - - - - - - - - {sessions.map((session) => ( - - - - - - - +
+
Session IDStartedEndedTotal elapsedSource attempt countTiming quality
{session.sessionId}{session.startedAt}{session.endedAt}{timing(session.totalElapsedMs)}{session.vendorAttemptCount ?? 'null'}{session.timingQuality}
+ + + + + + - ))} - -
Session IDWindowTotal elapsedSource quality
+ + + {sessions.map((session) => ( + + {session.sessionId} + + {session.startedAt} + to {session.endedAt} + + {exactTiming(session.totalElapsedMs)} + + {session.vendorAttemptCount ?? 'No source attempt count'} + {session.timingQuality} + + + ))} + + + ); } -export function EvidenceDetails({ - label, - evidence, -}: { - label: string; - evidence: EvidenceDisclosure; -}) { +function RuleAndQuality({ evidence }: { evidence: EvidenceDisclosure }) { const { summary, computed, derived } = evidence; return ( -
- {label} -
-

Finding identity and confidence

-
+
+ Rule and quality details +
+
Signal ID
{evidence.signalId}
@@ -300,107 +329,93 @@ export function EvidenceDetails({
{summary.severity}
-
Raw confidence
-
{summary.rawConfidence}
-
-
-
Final confidence
-
{summary.finalConfidence}
-
-
-
Timing multiplier
-
{summary.confidenceBreakdown.timingMultiplier}
-
-
-
Winsorization multiplier
-
{summary.confidenceBreakdown.winsorizationMultiplier}
-
-
-
Conflict multiplier
-
{summary.confidenceBreakdown.conflictMultiplier}
-
-
- -

Computed values

-
-
-
Attempt count
-
{computed.attemptCount}
-
-
-
Wrong count
-
{computed.wrongCount}
-
-
-
Median wrong duration
-
{timing(computed.medianWrongDurationMs)}
+
Confidence
+
+ {summary.rawConfidence} raw · {summary.finalConfidence} final +
-
Personal correct baseline
-
{timing(computed.personalCorrectBaselineMs)}
+
Confidence multipliers
+
+ timing {summary.confidenceBreakdown.timingMultiplier} · winsorization{' '} + {summary.confidenceBreakdown.winsorizationMultiplier} · conflict{' '} + {summary.confidenceBreakdown.conflictMultiplier} +
-
Personal session-mean baseline
-
{timing(computed.personalSessionMeanBaselineMs)}
+
Attempts
+
+ {computed.wrongCount} wrong of {computed.attemptCount} · {computed.winsorizedOutCount}{' '} + outside timing bound +
-
Distractor concentration
-
{formatMetric(computed.distractorConcentration)}
+
Durations
+
+ wrong median {exactTiming(computed.medianWrongDurationMs)} · personal correct{' '} + {exactTiming(computed.personalCorrectBaselineMs)} · session mean{' '} + {exactTiming(computed.personalSessionMeanBaselineMs)} +
-
Winsorized-out count
-
{computed.winsorizedOutCount}
+
Pattern values
+
+ distractor {formatMetric(computed.distractorConcentration)} · speed{' '} + {formatMetric(derived.speedRatio)} · consecutive wrong{' '} + {formatMetric(derived.consecutiveWrong)} · days observed{' '} + {formatMetric(derived.daysSinceFirstAttempt)} +
-
- -

Derived values

-
-
Wrong-of-last-N
+
Wrong of last N
{derived.wrongOfLastN ? `${derived.wrongOfLastN.wrong} of ${derived.wrongOfLastN.of}` - : 'Not available (null)'} + : 'Not available'}
-
Speed ratio
-
{formatMetric(derived.speedRatio)}
-
-
-
Consecutive wrong
-
{formatMetric(derived.consecutiveWrong)}
-
-
-
Days since first attempt
-
{formatMetric(derived.daysSinceFirstAttempt)}
+
Prerequisite check
+
+ {evidence.prerequisiteCheck + ? `${evidence.prerequisiteCheck.skillCode} · ${evidence.prerequisiteCheck.skillName} · mastery ${formatMetric(evidence.prerequisiteCheck.masteryValue)} · known ${String(evidence.prerequisiteCheck.isKnown)} · ${evidence.prerequisiteCheck.verdict}` + : 'Not applicable'} +
- -

Prerequisite check

-

- {evidence.prerequisiteCheck - ? `${evidence.prerequisiteCheck.skillCode} · ${evidence.prerequisiteCheck.skillName} · mastery ${formatMetric(evidence.prerequisiteCheck.masteryValue)} · known ${String(evidence.prerequisiteCheck.isKnown)} · ${evidence.prerequisiteCheck.verdict}` - : 'No prerequisite check applies.'} -

- -

Conflict adjustments

-
    - {evidence.conflicts.length ? ( - evidence.conflicts.map((conflict) => ( +
    Conflict adjustments
    + {evidence.conflicts.length ? ( +
      + {evidence.conflicts.map((conflict) => (
    • {conflict.family} · {humanizeCause(conflict.suggestedCause)} · {conflict.ruleId}
    • - )) - ) : ( -
    • No conflict adjustments.
    • - )} -
    + ))} +
+ ) : ( +

No conflict adjustments.

+ )} +
+
+ ); +} +export function EvidenceDetails({ + label, + evidence, +}: { + label: string; + evidence: EvidenceDisclosure; +}) { + return ( +
+ {label} +

Exact contributing attempts

-

Exact contributing sessions

+ {evidence.sessions.length ?

Exact contributing sessions

: null} +
); @@ -418,6 +433,27 @@ export function narrationProvenance(entry: BoardEntryView): string { return `${mode} · ${status} · catalog ${entry.narration.catalogVersion} · renderer ${entry.narration.renderVersion}`; } +function NarrationTrust({ entry }: { entry: BoardEntryView }) { + const fallback = entry.narration.mode === 'deterministic-fallback'; + return ( + + ); +} + function Workspace({ evidence, grant, @@ -438,32 +474,33 @@ function Workspace({ const report = (
-

Evidence report · rank {entry.rank}

- -

{entry.student.firstName}

-

{scopeLabel(entry.scope)}

+
+ +

+ {entry.student.firstName} · {scopeShortLabel(entry.scope)} +

+

{entry.scope.kind === 'skill' ? entry.scope.skill.name : 'Cross-skill evidence'}

+
-

Why this is ranked now

+

Why Huddle ranked this

{entry.diagnosis}

- Try this opener + Conversation opener
{entry.opener}
-
- {narrationProvenance(entry)} -
-
-

Compare with this student’s own pattern

+ +
+

Compared with {entry.student.firstName}’s own pattern

Personal correct baseline
-
{timing(comparison.computed.personalCorrectBaselineMs)}
+
{readableTiming(comparison.computed.personalCorrectBaselineMs)}
Recent wrong duration
-
{timing(comparison.computed.medianWrongDurationMs)}
+
{readableTiming(comparison.computed.medianWrongDurationMs)}
Consecutive wrong
@@ -472,7 +509,7 @@ function Workspace({
-
-

Additional causes with complete evidence

- {comparison.additionalEvidence.length ? ( - comparison.additionalEvidence.map((additional) => ( -
- - {humanizeCause(additional.cause)} · {priorityBand(additional.summary.severity)} ·{' '} - {confidenceLabel(additional.summary.finalConfidence)} evidence confidence - -
- -
-
- )) - ) : ( -

No additional causes were retained for this report.

- )} -
+ {comparison.additionalEvidence.length ? ( +
+

Additional evidence

+ {comparison.additionalEvidence.map((additional) => ( + + ))} +
+ ) : null}
); return ( @@ -541,7 +569,10 @@ export function EvidenceDesk({ return (

Guide workspace unavailable

-

Sign in with an authorized synthetic guide account to continue.

+

Sign in with the private synthetic guide account to continue.

+ + Sign in +
); if (state.kind === 'unavailable') @@ -555,8 +586,11 @@ export function EvidenceDesk({ return (

Today’s board is not built yet

-

A complete board has not been committed.

+

Import the fixed synthetic CSV, then refresh the board.

{refreshDescription(state.refresh, false)}

+ + Import synthetic activity +
); const selected = state.kind === 'board' ? state.open?.evidence.entry.triageEntryId : undefined; @@ -565,16 +599,15 @@ export function EvidenceDesk({ {state.kind === 'board' && !state.open ? : null}
-

Huddle · synthetic-only

-

Evidence Desk

-

A truthful ranked guide workspace with exact evidence available on demand.

+

Evidence desk

+

A persistent queue for guides who inspect several reports each morning.

{state.kind === 'board-updated' ? (
Board updated; select the current report. - Your previous report was not opened or acknowledged. + Your previous report was not opened or marked seen.
) : null} {state.kind === 'report-unavailable' ? ( @@ -586,9 +619,7 @@ export function EvidenceDesk({ {state.board.kind === 'successful-empty' ? (

No students need attention in this completed run

-

- This is a successful empty result, not a loading, authorization, or service failure. -

+

This is a completed empty board, not a loading, authorization, or service failure.

) : (
@@ -605,10 +636,11 @@ export function EvidenceDesk({ /> ) : (
-

Select a report

+ ◆ Start here +

Choose a student

- Open a ranked report to review its evidence, baseline comparison, and concrete - first move. + Review why they were ranked, compare with their own pattern, and open exact + evidence without losing the queue.

)} diff --git a/apps/web/app/board/page.tsx b/apps/web/app/board/page.tsx index 5c2faac..3fb0c01 100644 --- a/apps/web/app/board/page.tsx +++ b/apps/web/app/board/page.tsx @@ -11,13 +11,6 @@ import { readEvidenceDeskState } from './lib/evidence-desk-state'; export const metadata = { title: 'Huddle Evidence Desk' }; export const dynamic = 'force-dynamic'; -const deskStyles = ` -:root { --ink:#251e20; --muted:#62595d; --line:#e3dcde; --surface:#fffafb; --brand:#9a2148; --brand-soft:#fdebf0; --blue:#1757a6; --blue-soft:#edf5ff; --green:#176c4b; --warn:#8b4f00; } -* { box-sizing:border-box; } body { margin:0; color:var(--ink); background:#fff; font-family:Inter,ui-sans-serif,system-ui,sans-serif; } :focus-visible { outline:3px solid #2767bd; outline-offset:3px; } .evidence-desk,.desk-state { width:min(1380px,calc(100% - 32px)); margin:0 auto; padding:32px 0 56px; } .desk-header { display:flex; justify-content:space-between; gap:20px; align-items:end; } .desk-header h1,.desk-state h1 { margin:0; font-size:clamp(2rem,4vw,3rem); letter-spacing:-.04em; } .desk-header p { margin:6px 0 0; color:var(--muted); } .eyebrow { color:var(--brand)!important; margin:0 0 6px!important; font-size:.78rem; font-weight:800; letter-spacing:.08em; text-transform:uppercase; } .desk-freshness,.recovery { display:flex; flex-wrap:wrap; align-items:center; gap:8px 15px; margin:24px 0; padding:13px 15px; border:1px solid var(--line); border-radius:12px; background:var(--surface); color:var(--muted); font-size:.9rem; } .desk-freshness strong { color:var(--ink); } .desk-freshness > span:first-child { color:var(--green); } .recovery { background:var(--blue-soft); border-color:#b6d0ee; color:#244a78; } .recovery strong { color:var(--ink); } .desk-grid { display:grid; grid-template-columns:minmax(310px,.72fr) minmax(0,1.45fr); min-height:670px; border:1px solid var(--line); border-radius:16px; overflow:hidden; } .evidence-rail { background:var(--surface); border-right:1px solid var(--line); } .rail-heading { padding:20px; border-bottom:1px solid var(--line); } .rail-heading h2 { margin:0; font-size:1.25rem; } .rail-heading p:not(.eyebrow) { margin:5px 0 0; color:var(--muted); font-size:.88rem; } .evidence-rail ol { margin:0; padding:0; list-style:none; } .evidence-rail li { border-bottom:1px solid var(--line); } .rail-link { min-height:100px; display:grid; grid-template-columns:32px minmax(0,1fr) 18px; gap:10px; padding:15px; color:inherit; text-decoration:none; } .rail-link:hover,.rail-link[aria-current=page] { background:#fff; } .rail-rank { color:var(--brand); font-weight:850; font-variant-numeric:tabular-nums; } .rail-copy { display:grid; gap:3px; } .rail-copy small { color:var(--muted); } .rail-copy .seen { color:var(--green); font-weight:750; } .workspace-slot { min-width:0; background:#fff; } .workspace-empty { display:grid; place-content:center; min-height:100%; max-width:35rem; padding:30px; } .workspace-empty h2 { margin:0 0 8px; font-size:1.7rem; } .workspace-empty p { margin:0; color:var(--muted); } .evidence-workspace { padding:28px; } .return-link { display:inline-block; margin-bottom:24px; color:var(--blue); font-weight:750; } .evidence-workspace h2 { margin:8px 0 0; font-size:2.2rem; letter-spacing:-.035em; } .evidence-workspace h3 { margin:26px 0 10px; font-size:1.05rem; } .scope { margin:5px 0 0; color:var(--muted); } .finding-chips { display:flex; flex-wrap:wrap; gap:8px; } .priority,.confidence { display:inline-flex; align-items:center; gap:5px; border-radius:99px; padding:4px 9px; font-size:.78rem; font-weight:800; } .priority-urgent { background:#ffe9e9; color:#9c2632; } .priority-elevated { background:#fff1dd; color:var(--warn); } .priority-watch { background:var(--blue-soft); color:var(--blue); } .confidence { background:#f1efef; color:#554b4f; } .finding-intro { padding:18px; margin-top:22px; border:1px solid var(--line); border-radius:12px; background:var(--surface); } .finding-intro h3,.finding-intro p { margin-top:0; } blockquote { margin:16px 0 0; padding:13px; border-left:4px solid var(--brand); background:#fff; } .provenance { margin-top:15px; padding:11px; border-radius:10px; background:var(--blue-soft); color:#274a74; font-size:.86rem; } .metrics { display:grid; grid-template-columns:repeat(3,1fr); gap:1px; border:1px solid var(--line); border-radius:12px; overflow:hidden; background:var(--line); } .metrics div { padding:13px; background:#fff; } .metrics dt { color:var(--muted); font-size:.8rem; } .metrics dd { margin:5px 0 0; font-size:1.05rem; font-weight:800; } .evidence-disclosure,.additional { margin-top:15px; border-top:1px solid var(--line); } summary { cursor:pointer; padding:15px 0; font-weight:800; } .disclosure-body { padding:0 0 18px; } .exact-table { width:100%; border-collapse:collapse; font-size:.83rem; } .exact-table th,.exact-table td { padding:9px 7px; text-align:left; border-bottom:1px solid var(--line); vertical-align:top; } .exact-table th { color:var(--muted); } .facts { padding-left:20px; color:var(--muted); } .acknowledgment { padding:12px; border-radius:10px; background:#edf9f2; color:var(--green); font-weight:750; } .desk-state { max-width:740px; text-align:center; padding-top:14vh; } .desk-state p { color:var(--muted); } .sr-only { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; } -@media(max-width:760px) { .evidence-desk,.desk-state { width:100%; padding:20px 12px 40px; } .desk-grid { display:block; border-left:0; border-right:0; border-radius:0; } .evidence-rail { border-right:0; } .desk-grid.has-selection .evidence-rail { display:none; } .workspace-slot { display:none; } .desk-grid.has-selection .workspace-slot { display:block; } .evidence-workspace { padding:18px 12px; } .metrics { grid-template-columns:1fr; } .exact-table { display:block; overflow-x:auto; white-space:nowrap; } .desk-header h1 { font-size:2.1rem; } } -@media(prefers-reduced-motion:reduce) { *,*::before,*::after { scroll-behavior:auto!important; transition:none!important; animation:none!important; } } -`; - export default async function BoardPage({ searchParams, }: { @@ -34,13 +27,10 @@ export default async function BoardPage({ } ); return ( - <> - - - + ); } diff --git a/apps/web/app/board/visible-open-acknowledgment.tsx b/apps/web/app/board/visible-open-acknowledgment.tsx index a2904b8..5f5a6b7 100644 --- a/apps/web/app/board/visible-open-acknowledgment.tsx +++ b/apps/web/app/board/visible-open-acknowledgment.tsx @@ -22,7 +22,18 @@ export function remainingRevealWindow(validForMs: number, roundTripMs: number): } export function acknowledgmentLabel(acknowledgment: AcknowledgmentView): string { - return `✓ Seen ${acknowledgment.acknowledgedAt}`; + const date = new Date(acknowledgment.acknowledgedAt); + const seenAt = Number.isNaN(date.valueOf()) + ? acknowledgment.acknowledgedAt + : new Intl.DateTimeFormat('en-US', { + timeZone: 'America/Chicago', + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + timeZoneName: 'short', + }).format(date); + return `✓ Seen ${seenAt}`; } function elementIsVisible(element: HTMLElement): boolean { @@ -212,49 +223,22 @@ export function VisibleOpenAcknowledgment({ shouldExpose, ]); + let statusMessage = 'Opening report…'; + if (status.kind === 'seen') statusMessage = acknowledgmentLabel(status.acknowledgment); + if (status.kind === 'pending') statusMessage = 'Report open · marking as seen…'; + if (status.kind === 'refreshing') statusMessage = 'Refreshing report authorization…'; + if (status.kind === 'reveal-unavailable') + statusMessage = + 'This report cannot be shown because its reveal authorization could not be confirmed.'; + if (status.kind === 'acknowledgment-unavailable') + statusMessage = 'This report was shown, but its acknowledgment could not be confirmed.'; + return (
+

+ {statusMessage} +

{shouldExpose ? children : null} - {status.kind === 'seen' ? ( -

- {acknowledgmentLabel(status.acknowledgment)} -

- ) : null} - {status.kind === 'reveal-unavailable' ? ( -

- This report cannot be shown because its reveal authorization could not be confirmed. -

- ) : null} - {status.kind === 'acknowledgment-unavailable' ? ( -

- This report was shown, but its acknowledgment could not be confirmed. -

- ) : null} - {status.kind === 'pending' ? ( -

- Marking this visible report as seen… -

- ) : null} - {status.kind === 'refreshing' ? ( -

- Refreshing report authorization before showing evidence… -

- ) : null} - {status.kind === 'authorizing' ? ( -

- Confirming report authorization before showing evidence… -

- ) : null} - {status.kind === 'authorized' ? ( -

- Report authorized; waiting for it to become visible… -

- ) : null} - {status.kind === 'waiting' ? ( -

- Waiting for this report to become visible… -

- ) : null}
); } diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index a4dca0c..c1cfb13 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -1,193 +1,316 @@ :root { + --huddle-bg: #ffffff; + --huddle-surface: #faf7f8; + --huddle-surface-strong: #f3edef; --huddle-ink: #241c1f; --huddle-muted: #675c61; --huddle-brand: #9a2148; --huddle-brand-dark: #771735; - --huddle-soft: #fff5f7; - --huddle-line: #e7dce0; + --huddle-line: #e4dadd; --huddle-blue: #1757a6; + --huddle-blue-soft: #edf5ff; + --huddle-green: #176c4b; + --huddle-green-soft: #edf9f2; + --huddle-warn: #8b4f00; } * { box-sizing: border-box; } + +html { + min-width: 300px; +} + body { margin: 0; color: var(--huddle-ink); - background: #fffdfd; - font-family: Inter, ui-sans-serif, system-ui, sans-serif; + background: var(--huddle-bg); + font-family: + Inter, + ui-sans-serif, + system-ui, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + sans-serif; + font-size: 15px; + line-height: 1.45; +} + +h1, +h2, +h3 { + line-height: 1.18; + text-wrap: balance; +} + +p { + text-wrap: pretty; } + a { color: var(--huddle-blue); } + button, input { font: inherit; } + button { cursor: pointer; } + +button:disabled { + cursor: not-allowed; + opacity: 0.6; +} + :focus-visible { outline: 3px solid #2767bd; outline-offset: 3px; } + .app-header { + position: sticky; + top: 0; + z-index: 20; + min-height: 64px; display: flex; align-items: center; - justify-content: space-between; - gap: 20px; - min-height: 66px; - padding: 0 max(20px, calc((100vw - 1180px) / 2)); + gap: 24px; + padding: 9px max(18px, calc((100vw - 1320px) / 2)); border-bottom: 1px solid var(--huddle-line); - background: rgba(255, 255, 255, 0.96); + background: rgba(255, 255, 255, 0.97); } + .app-brand { + display: inline-flex; + align-items: center; + gap: 10px; color: var(--huddle-ink); - font-size: 1.1rem; - font-weight: 900; - letter-spacing: -0.03em; + font-size: 1.08rem; + font-weight: 850; + letter-spacing: -0.025em; text-decoration: none; } -.app-brand span { - color: var(--huddle-brand); + +.app-logo { + width: 30px; + height: 30px; + display: inline-grid; + place-items: center; + border-radius: 8px; + background: var(--huddle-brand); + color: white; + font-size: 0.9rem; } + .app-nav { display: flex; - flex-wrap: wrap; - gap: 18px; + gap: 20px; } + .app-nav a { color: var(--huddle-muted); font-size: 0.9rem; - font-weight: 750; + font-weight: 700; text-decoration: none; } + .app-nav a:hover { color: var(--huddle-brand); } + +.app-scope { + margin: 0 0 0 auto; + color: var(--huddle-muted); + font-size: 0.76rem; + line-height: 1.25; + text-align: right; +} + +.app-scope strong { + display: block; + color: var(--huddle-ink); +} + .site-main { width: min(1120px, calc(100% - 32px)); margin: 0 auto; padding: 64px 0; } + .hero { + min-height: 62vh; display: grid; grid-template-columns: 1.35fr 0.8fr; gap: 48px; align-items: center; - min-height: 62vh; } + .eyebrow-global { margin: 0 0 10px; color: var(--huddle-brand); font-size: 0.78rem; - font-weight: 900; - letter-spacing: 0.1em; + font-weight: 850; + letter-spacing: 0.08em; text-transform: uppercase; } -.hero h1, -.card h1 { + +.hero h1 { + max-width: 13ch; margin: 0; font-size: clamp(2.3rem, 6vw, 4.8rem); line-height: 0.98; - letter-spacing: -0.055em; + letter-spacing: -0.04em; } + .hero h1 span { color: var(--huddle-brand); } + .hero-copy { max-width: 43rem; margin: 24px 0; color: var(--huddle-muted); - font-size: 1.15rem; + font-size: 1.12rem; line-height: 1.65; } -.actions { + +.actions, +.workflow-actions { display: flex; flex-wrap: wrap; - gap: 12px; + gap: 10px; } + .button, button { + min-height: 44px; display: inline-flex; justify-content: center; align-items: center; - min-height: 44px; - padding: 10px 16px; + padding: 9px 15px; border: 1px solid var(--huddle-brand); border-radius: 9px; background: var(--huddle-brand); color: white; - font-weight: 800; + font-weight: 750; text-decoration: none; } + .button:hover, button:hover { background: var(--huddle-brand-dark); } + .button.secondary { background: white; color: var(--huddle-brand); } -.promise-card, -.card { - border: 1px solid var(--huddle-line); - border-radius: 18px; - background: white; - box-shadow: 0 20px 60px rgba(55, 25, 35, 0.08); -} + .promise-card { padding: 26px; + border: 1px solid var(--huddle-line); + border-radius: 14px; + background: white; } + .promise-card h2 { margin-top: 0; } + .promise-list { display: grid; - gap: 16px; + gap: 15px; padding: 0; list-style: none; } + .promise-list li { - padding-left: 26px; position: relative; + padding-left: 24px; color: var(--huddle-muted); - line-height: 1.45; } + .promise-list li::before { content: '✓'; position: absolute; left: 0; - color: #176c4b; - font-weight: 900; + color: var(--huddle-green); + font-weight: 850; } -.card { - max-width: 760px; - margin: 34px auto; - padding: clamp(24px, 5vw, 48px); + +.auth-page { + min-height: calc(100vh - 64px); + display: grid; + grid-template-columns: minmax(300px, 0.85fr) minmax(360px, 1.15fr); } -.card h1 { - font-size: clamp(2rem, 5vw, 3.4rem); + +.auth-intro { + min-height: 560px; + display: flex; + flex-direction: column; + justify-content: center; + padding: clamp(36px, 7vw, 90px); + background: var(--huddle-brand); + color: white; } -.card p { + +.auth-intro > p { + margin: 0 0 24px; + font-weight: 750; +} + +.auth-intro h1 { + max-width: 10ch; + margin: 0 0 16px; + font-size: 2.9rem; + letter-spacing: -0.035em; +} + +.auth-intro span { + max-width: 34ch; + font-size: 1.05rem; +} + +.auth-main { + display: grid; + place-items: center; + padding: clamp(28px, 7vw, 80px); +} + +.auth-box { + width: min(100%, 430px); +} + +.auth-box h2 { + margin: 0 0 8px; + font-size: 1.65rem; +} + +.auth-box > p { color: var(--huddle-muted); - line-height: 1.55; } -.form-stack, -.import-workflow { + +.form-stack { display: grid; gap: 15px; - margin-top: 28px; + margin-top: 26px; } + .form-stack label, -.import-workflow label { +.upload-row label { display: grid; gap: 7px; color: var(--huddle-ink); - font-weight: 750; + font-weight: 700; } + .form-stack input, -.import-workflow input { +.upload-row input { width: 100%; min-height: 46px; padding: 10px 12px; @@ -195,48 +318,657 @@ button:hover { border-radius: 8px; background: white; } -.import-workflow dl { + +.policy-note { + padding: 12px 14px; + border: 1px solid #b8d1ee; + border-radius: 9px; + background: var(--huddle-blue-soft); + color: #234a78 !important; +} + +.workflow-page { + width: min(820px, calc(100% - 32px)); + margin: 0 auto; + padding: 52px 0 70px; +} + +.import-workflow { + display: grid; + gap: 18px; +} + +.workflow-heading h1 { + margin: 0 0 8px; + font-size: 2rem; + letter-spacing: -0.025em; +} + +.workflow-heading p { + max-width: 62ch; + margin: 0; + color: var(--huddle-muted); +} + +.sample-download { + width: max-content; + font-weight: 700; +} + +.upload-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; + align-items: flex-end; + padding: 20px 0; + border-top: 1px solid var(--huddle-line); + border-bottom: 1px solid var(--huddle-line); +} + +.import-result { + padding-top: 8px; +} + +.import-result h2 { + font-size: 1.15rem; +} + +.import-result dl { display: grid; grid-template-columns: 1fr auto; gap: 8px 20px; margin: 0; - padding: 18px; + padding: 16px 0; + border-top: 1px solid var(--huddle-line); + border-bottom: 1px solid var(--huddle-line); +} + +.import-result dt { + color: var(--huddle-muted); +} + +.import-result dd { + margin: 0; + font-weight: 800; + font-variant-numeric: tabular-nums; +} + +.evidence-desk, +.desk-state { + width: min(1320px, calc(100% - 32px)); + margin: 0 auto; + padding: 30px 0 56px; +} + +.desk-header { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 20px; +} + +.desk-header h1, +.desk-state h1 { + margin: 0; + font-size: 2rem; + letter-spacing: -0.025em; +} + +.desk-header p { + margin: 6px 0 0; + color: var(--huddle-muted); +} + +.desk-freshness, +.recovery { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 7px 13px; + margin: 22px 0 18px; + padding: 11px 14px; + border: 1px solid var(--huddle-line); + border-radius: 10px; + background: var(--huddle-surface); + color: var(--huddle-muted); + font-size: 0.82rem; +} + +.desk-freshness strong, +.recovery strong { + color: var(--huddle-ink); +} + +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--huddle-green); +} + +.status-dot-warning { + background: var(--huddle-warn); +} + +.freshness-push { + margin-left: auto; +} + +.recovery { + background: var(--huddle-blue-soft); + color: #244a78; +} + +.desk-grid { + min-height: 620px; + display: grid; + grid-template-columns: minmax(330px, 0.78fr) minmax(0, 1.4fr); + overflow: hidden; + border: 1px solid var(--huddle-line); border-radius: 12px; - background: var(--huddle-soft); } -.import-workflow dt { + +.evidence-rail { + min-width: 0; + border-right: 1px solid var(--huddle-line); + background: var(--huddle-surface); +} + +.rail-heading { + padding: 18px; + border-bottom: 1px solid var(--huddle-line); +} + +.rail-heading h2 { + margin: 0 0 4px; + font-size: 1rem; +} + +.rail-heading p { + margin: 0; color: var(--huddle-muted); + font-size: 0.82rem; } -.import-workflow dd { + +.evidence-rail ol { margin: 0; + padding: 0; + list-style: none; +} + +.evidence-rail li { + border-bottom: 1px solid var(--huddle-line); +} + +.rail-link { + width: 100%; + min-height: 92px; + display: grid; + grid-template-columns: 32px minmax(0, 1fr) auto; + gap: 10px; + padding: 14px; + color: var(--huddle-ink); + text-decoration: none; +} + +.rail-link:hover, +.rail-link[aria-current='page'] { + background: white; +} + +.rail-rank { + color: var(--huddle-brand); font-weight: 850; font-variant-numeric: tabular-nums; } -.policy-note { - padding: 13px 15px; - border: 1px solid #b8d1ee; + +.rail-copy { + min-width: 0; + display: grid; + gap: 2px; +} + +.rail-copy > span, +.rail-copy small { + overflow-wrap: anywhere; +} + +.rail-copy small { + color: var(--huddle-muted); +} + +.rail-copy .seen { + color: var(--huddle-green); + font-weight: 750; +} + +.rail-chevron { + align-self: center; + color: var(--huddle-muted); + font-size: 1.2rem; +} + +.workspace-slot { + min-width: 0; + background: white; +} + +.workspace-empty { + min-height: 100%; + display: grid; + align-content: center; + justify-items: start; + max-width: 42rem; + padding: clamp(30px, 6vw, 70px); +} + +.workspace-empty h2 { + max-width: 15ch; + margin: 16px 0 8px; + font-size: 2rem; +} + +.workspace-empty p { + max-width: 54ch; + margin: 0; + color: var(--huddle-muted); +} + +.evidence-workspace { + padding: 22px; +} + +.return-link { + display: inline-block; + margin-bottom: 20px; + color: var(--huddle-blue); + font-weight: 700; +} + +.report-heading { + margin-bottom: 24px; +} + +.report-status-line { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +.priority, +.confidence, +.rank-label { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 3px 8px; + border-radius: 999px; + font-size: 0.76rem; + font-weight: 780; +} + +.priority-urgent { + background: #ffe9e9; + color: #942532; +} + +.priority-elevated { + background: #fff1dd; + color: var(--huddle-warn); +} + +.priority-watch { + background: var(--huddle-blue-soft); + color: var(--huddle-blue); +} + +.confidence, +.rank-label { + background: #f1efef; + color: #554b4f; +} + +.report-heading h2 { + margin: 12px 0 4px; + font-size: 2rem; + letter-spacing: -0.025em; +} + +.report-heading p { + margin: 0; + color: var(--huddle-muted); +} + +.finding-intro, +.comparison-section { + padding: 20px 0; + border-top: 1px solid var(--huddle-line); +} + +.finding-intro h3, +.comparison-section h3, +.additional-evidence h3 { + margin: 0 0 10px; + font-size: 1.05rem; +} + +.finding-intro > p { + max-width: 70ch; +} + +blockquote { + margin: 15px 0 0; + padding: 15px; + border: 1px solid var(--huddle-line); border-radius: 10px; - background: #edf5ff; - color: #234a78 !important; + background: var(--huddle-surface); + font-size: 1rem; } -.app-footer { - padding: 28px 20px 42px; + +.provenance { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 10px; + align-items: start; + padding: 12px 14px; + border-radius: 9px; + background: var(--huddle-blue-soft); + color: #274a74; + font-size: 0.8rem; +} + +.provenance strong, +.provenance small { + display: block; +} + +.provenance small { + margin-top: 2px; +} + +.provenance details summary { + padding: 0; + font-size: 0.76rem; + white-space: nowrap; +} + +.provenance details p { + grid-column: 1 / -1; + margin: 8px 0 0; + overflow-wrap: anywhere; +} + +.metrics { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1px; + overflow: hidden; + margin: 0; + border: 1px solid var(--huddle-line); + border-radius: 10px; + background: var(--huddle-line); +} + +.metrics div { + padding: 13px; + background: white; +} + +.metrics dt, +.rule-facts dt { + color: var(--huddle-muted); + font-size: 0.78rem; +} + +.metrics dd { + margin: 5px 0 0; + font-size: 1.05rem; + font-weight: 800; +} + +.evidence-disclosure { border-top: 1px solid var(--huddle-line); +} + +.evidence-disclosure > summary, +.rule-disclosure > summary { + padding: 16px 0; + cursor: pointer; + font-weight: 780; +} + +.disclosure-body { + padding-bottom: 18px; +} + +.disclosure-body h4 { + margin: 18px 0 8px; + font-size: 0.9rem; +} + +.disclosure-body h4:first-child { + margin-top: 0; +} + +.table-scroll { + width: 100%; + overflow-x: auto; +} + +.exact-table { + width: 100%; + min-width: 680px; + border-collapse: collapse; + font-size: 0.79rem; +} + +.exact-table th, +.exact-table td { + padding: 9px 7px; + border-bottom: 1px solid var(--huddle-line); + text-align: left; + vertical-align: top; +} + +.exact-table th { color: var(--huddle-muted); - text-align: center; +} + +.exact-table small { + display: block; + margin-top: 3px; + color: var(--huddle-muted); +} + +.rule-disclosure { + margin-top: 14px; + border-top: 1px solid var(--huddle-line); +} + +.rule-facts { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0 20px; + margin: 0; +} + +.rule-facts div { + padding: 10px 0; + border-bottom: 1px solid var(--huddle-line); +} + +.rule-facts dd { + margin: 3px 0 0; + overflow-wrap: anywhere; +} + +.rule-body h5 { + margin: 18px 0 6px; + font-size: 0.82rem; +} + +.facts { + margin: 0; + padding-left: 18px; + color: var(--huddle-muted); +} + +.additional-evidence { + padding-top: 20px; +} + +.acknowledgment { + margin: 18px 22px 0; + padding: 10px 12px; + border-radius: 9px; + background: var(--huddle-green-soft); + color: var(--huddle-green); font-size: 0.82rem; + font-weight: 700; +} + +.desk-state { + max-width: 700px; + padding-top: 14vh; + text-align: center; +} + +.desk-state p { + color: var(--huddle-muted); } + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + @media (max-width: 760px) { .app-header { - align-items: flex-start; - flex-direction: column; - padding: 15px 16px; + gap: 14px; + padding: 10px 14px; + } + + .app-nav { + margin-left: auto; + gap: 14px; + } + + .app-scope { + display: none; + } + + .site-main { + padding-top: 36px; } + .hero { + min-height: auto; grid-template-columns: 1fr; + } + + .auth-page { + display: block; + } + + .auth-intro { min-height: auto; + padding: 34px 20px; } - .site-main { - padding-top: 36px; + + .auth-intro h1 { + max-width: 14ch; + font-size: 2rem; + } + + .auth-main { + display: block; + padding: 34px 20px 50px; + } + + .workflow-page { + width: min(100% - 24px, 620px); + padding-top: 30px; + } + + .upload-row { + grid-template-columns: 1fr; + } + + .upload-row button { + width: 100%; + } + + .evidence-desk, + .desk-state { + width: 100%; + padding: 22px 12px 40px; + } + + .freshness-push { + width: 100%; + margin-left: 21px; + } + + .desk-grid { + display: block; + min-height: 0; + border-right: 0; + border-left: 0; + border-radius: 0; + } + + .evidence-rail { + border-right: 0; + } + + .desk-grid.has-selection .evidence-rail { + display: none; + } + + .workspace-slot { + display: none; + } + + .desk-grid.has-selection .workspace-slot { + display: block; + } + + .evidence-workspace { + padding: 18px 14px; + } + + .metrics, + .rule-facts { + grid-template-columns: 1fr; + } + + .provenance { + grid-template-columns: auto minmax(0, 1fr); + } + + .provenance details { + grid-column: 2; + } + + .acknowledgment { + margin: 14px 14px 0; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + transition: none !important; + animation: none !important; } } diff --git a/apps/web/app/import/import-workflow.tsx b/apps/web/app/import/import-workflow.tsx index da373ea..3ffdebb 100644 --- a/apps/web/app/import/import-workflow.tsx +++ b/apps/web/app/import/import-workflow.tsx @@ -47,66 +47,67 @@ export function ImportWorkflow() { const result = receipt ?? preview; return (
-

Import synthetic activity

-

- Use the fixed reviewed CSV only. Validation never publishes activity; commit rechecks the - same bytes, and refresh is a separate action. -

-

- The adapter requires dataset_kind=synthetic and a fixed pseudonymous roster. -

-

- - Download the synthetic CSV sample - -

- - { - setPreview(null); - setReceipt(null); - setError(null); - setRefreshMessage(null); - }} - /> - - {preview && !receipt ? ( - - ) : null} - {receipt ? ( - - ) : null} +
+
+ {preview && !receipt ? ( + + ) : null} + {receipt ? ( + + ) : null} +
{error ?

{error}

: null} {refreshMessage ? (

- {refreshMessage} Open the ranked Evidence Desk. + {refreshMessage} Open the morning board.

) : null} {result ? ( - <> -

{receipt ? 'Import committed' : 'Validation preview'}

+
+

{receipt ? 'Import committed' : 'Validation preview'}

Received
{result.counts.received}
@@ -126,7 +127,7 @@ export function ImportWorkflow() { ))} - +
) : null}
); diff --git a/apps/web/app/import/page.tsx b/apps/web/app/import/page.tsx index 09965c4..4a28a3e 100644 --- a/apps/web/app/import/page.tsx +++ b/apps/web/app/import/page.tsx @@ -7,24 +7,20 @@ export default async function ImportPage() { const access = await resolveGuideAccess(); if (!access) { return ( -
-
-

Private reviewer access

+
+

Import unavailable

-

Sign in with the provisioned synthetic guide account before importing activity.

+

Sign in with the private synthetic guide account before importing activity.

- Reviewer sign in + Sign in
); } return ( -
-
-

Synthetic-only operations

- -
+
+
); } diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 967cf18..962f490 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -11,19 +11,21 @@ export default function RootLayout({ children }: { children: React.ReactNode })
- Huddle + + Huddle +

+ Synthetic Grade 4 + Private reviewer demo +

{children} -
- Private portfolio demo · synthetic student data only · deterministic decision support · no - accuracy claim -
); diff --git a/apps/web/app/login/page.tsx b/apps/web/app/login/page.tsx index e4b1b14..d2ea3f0 100644 --- a/apps/web/app/login/page.tsx +++ b/apps/web/app/login/page.tsx @@ -4,16 +4,21 @@ export const metadata = { title: 'Reviewer sign in — Huddle' }; export default function LoginPage() { return ( -
-
-

Private reviewer access

-

Sign in to Huddle

-

- Use the provisioned synthetic guide account. Authentication is handled by Supabase; board - data is read only by the server through the scoped PostgreSQL data layer. -

-

No real student data belongs in this demo.

- +
+
+

Private synthetic demo

+

Know who needs you first.

+ A focused morning board for guides, built from evidence rather than guesswork. +
+
+
+

Sign in to your guide board

+

Use the private reviewer account provided with this demo.

+ +

+ Synthetic student activity only. Real student data is rejected. +

+
); diff --git a/apps/web/test/evidence-desk-rendering.test.ts b/apps/web/test/evidence-desk-rendering.test.ts index d8f63bf..f7a8316 100644 --- a/apps/web/test/evidence-desk-rendering.test.ts +++ b/apps/web/test/evidence-desk-rendering.test.ts @@ -129,11 +129,11 @@ describe('Evidence Desk truthful presentation', () => { }, ]; expect(states.map((state) => refreshDescription(state, false))).toEqual([ - 'Refresh idle; no completed board is available', - 'Refresh queued at queued-at · request q-1; no completed board is available yet', - 'Refresh running · requested at running-at · request r-1; no completed board is available yet', - 'Refresh succeeded at succeeded-at · request s-1 · board run-2', - 'Refresh failed at failed-at · compile-failed · request f-1; no completed board is available', + 'No completed board yet', + 'Refresh queued queued-at', + 'Refreshing since running-at', + 'Last refresh completed succeeded-at', + 'Refresh failed failed-at; no completed board available', ]); }); @@ -171,8 +171,62 @@ describe('Evidence Desk truthful presentation', () => { open: null, }; const html = renderToStaticMarkup(createElement(EvidenceDesk, { state })); - expect(html).toContain('Narration partially available · 2 degraded reports'); - expect(html).toContain('Refresh running · requested at 08:03 · request refresh-2'); + expect(html).toContain('2 deterministic fallbacks'); + expect(html).toContain('Refreshing since 08:03; current board stays available'); + }); + + it('keeps the ranked rail focused on the guide decision instead of implementation metadata', () => { + const entry: BoardEntryView = { + triageEntryId: 'entry-1', + findingFingerprint: 'finding-1', + student: { id: 'student-1', firstName: 'Avery' }, + rank: 1, + cause: 'guessing', + scope: { kind: 'skill', skill: { code: '4.4A', name: 'Add and subtract' } }, + severity: 0.81, + finalConfidence: 0.5796, + diagnosis: 'Evidence-backed diagnosis.', + opener: 'Show me your first step.', + narration: { + mode: 'deterministic-fallback', + status: 'degraded', + degradedReason: 'model-unavailable', + catalogVersion: 'catalog-3', + renderVersion: 'renderer-8', + }, + acknowledgedAt: null, + additionalCauseCount: 2, + }; + const state: EvidenceDeskState = { + kind: 'board', + board: { + kind: 'ready', + boardRunId: 'run-1', + requestedBoardDate: '2026-07-29', + boardDate: '2026-07-29', + asOf: '2026-07-29T08:00:00.000Z', + timezone: 'America/Chicago', + completedAt: '2026-07-29T08:00:00.000Z', + inputReceiptSetFingerprint: 'receipts', + entries: [entry], + refresh: { + state: 'succeeded', + requestId: 'internal-request-2', + completedAt: '2026-07-29T08:03:00.000Z', + boardRunId: 'run-1', + }, + narration: { status: 'degraded', degradedCount: 1 }, + }, + open: null, + }; + + const html = renderToStaticMarkup(createElement(EvidenceDesk, { state })); + expect(html).toContain('Today’s attention queue'); + expect(html).toContain('Guessing pattern · 4.4A'); + expect(html).toContain('Urgent · Medium confidence · 2 more causes'); + expect(html).toContain('Not yet seen'); + expect(html).not.toContain('internal-request-2'); + expect(html).not.toContain('2026-07-29T08:00:00.000Z'); }); it('provides a focusable successful-empty restoration target', () => { @@ -205,7 +259,7 @@ describe('Evidence Desk truthful presentation', () => { findingFingerprint: 'finding-1', acknowledgedAt: '2026-07-29T08:01:00.000Z', }) - ).toBe('✓ Seen 2026-07-29T08:01:00.000Z'); + ).toBe('✓ Seen Jul 29, 3:01 AM CDT'); }); it('does not server-render evidence before document and report visibility are established', () => { @@ -219,7 +273,7 @@ describe('Evidence Desk truthful presentation', () => { children: createElement('strong', null, 'private evidence'), }) ); - expect(html).toContain('Waiting for this report to become visible'); + expect(html).toContain('Opening report…'); expect(html).not.toContain('private evidence'); }); From e7c5946a661f62c5529cb62760226fc3c65bb413 Mon Sep 17 00:00:00 2001 From: alexdancer Date: Thu, 30 Jul 2026 00:29:23 -0500 Subject: [PATCH 06/13] test: align hosted smoke with guide sign-in --- scripts/smoke-hosted.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/smoke-hosted.ts b/scripts/smoke-hosted.ts index 4d2c434..201337c 100644 --- a/scripts/smoke-hosted.ts +++ b/scripts/smoke-hosted.ts @@ -42,7 +42,7 @@ async function main(): Promise { throw new Error('Hosted health contract is incomplete.'); } const login = await text(base, '/login'); - if (!login.includes('Sign in to Huddle')) + if (!login.includes('Sign in to your guide board')) throw new Error('Reviewer sign-in page is unavailable.'); const board = await text(base, '/board'); if (!board.includes('Guide workspace unavailable') || /Avery|Blake|Casey|Drew/.test(board)) { From 1d3a018d88d99d905e06671295f5798104a07977 Mon Sep 17 00:00:00 2001 From: alexdancer Date: Thu, 30 Jul 2026 00:33:26 -0500 Subject: [PATCH 07/13] test: preserve TLS in credentialed database checks --- AGENTS.md | 3 +++ packages/db/test/mastery-equivalence.test.ts | 7 ++++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b177df6..32881d4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,6 +10,9 @@ student data is synthetic only. The enforced package/data boundaries and validat live in `scripts/check-determinism.ts`, `eslint.config.js`, and `.github/workflows/ci.yml`. +For credentialed local DB tests, preserve dotenv values exactly: +`RUN_DB_TESTS=1 node --env-file=.env node_modules/vitest/vitest.mjs run`. Do not shell-source `.env`. + ## Maintaining this file Keep this file for knowledge useful to almost every future agent session in this project. diff --git a/packages/db/test/mastery-equivalence.test.ts b/packages/db/test/mastery-equivalence.test.ts index 8bf457d..7bae387 100644 --- a/packages/db/test/mastery-equivalence.test.ts +++ b/packages/db/test/mastery-equivalence.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import pg from 'pg'; import type { Pool, PoolClient } from 'pg'; import { fileURLToPath } from 'node:url'; +import { databasePoolConfig } from '../src/pool-config.js'; import { dirname, join } from 'node:path'; const { Pool: PgPool } = pg; @@ -20,9 +21,9 @@ describe.skipIf(process.env.RUN_DB_TESTS !== '1')('mastery equivalence', () => { beforeAll(async () => { const database = await import('../src/client.js'); await database.runMigrations(join(__dirname, '../../../db/migrations')); - pool = new PgPool({ - connectionString: process.env.DATABASE_ADMIN_URL ?? process.env.DATABASE_URL, - }); + pool = new PgPool( + databasePoolConfig(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, is_synthetic) VALUES ($1, $2, true)', [ From eef67eac5738317eeb7645c307db3becbe62b590 Mon Sep 17 00:00:00 2001 From: alexdancer Date: Thu, 30 Jul 2026 00:54:15 -0500 Subject: [PATCH 08/13] no-mistakes(review): Restore report trust and private deployment guarantees --- .env.example | 5 ++- README.md | 22 ++++++++--- apps/web/app/board/evidence-desk.tsx | 36 ++++++++++++----- .../app/board/evidence/[entry_id]/page.tsx | 11 ++++-- apps/web/test/evidence-desk-rendering.test.ts | 39 +++++++++++++++++++ .../test/report-reveal-server-action.test.ts | 8 ++++ scripts/smoke-hosted.ts | 4 +- scripts/verify-quickstart.ts | 13 ++++++- specs/001-huddle-triage-board/quickstart.md | 13 ++++++- 9 files changed, 125 insertions(+), 26 deletions(-) diff --git a/.env.example b/.env.example index 3183c89..fb3e14d 100644 --- a/.env.example +++ b/.env.example @@ -29,8 +29,9 @@ INTERNAL_REFRESH_SECRET=replace-with-a-different-random-secret INTERNAL_REFRESH_SECRET_PREVIOUS= INTERNAL_REFRESH_URL=http://127.0.0.1:3000/internal/refresh -# Optional operator-only secret for smoke-checking a Vercel Deployment Protection wall. Never add -# this bypass value to the deployed application environment. +# Operator-only secret for smoke-checking the required Vercel Deployment Protection wall. Leave +# blank for local development and never add this bypass value to the deployed application +# environment. VERCEL_PROTECTION_BYPASS= # Optional generated narration only. Leave unset for the reviewed deterministic-fallback demo/eval. diff --git a/README.md b/README.md index 6561016..5bd8981 100644 --- a/README.md +++ b/README.md @@ -108,17 +108,29 @@ credential in browser-visible variables. Run migrations and `demo:seed` from a t terminal before deployment. The reviewer-facing `/board` and `/import` operations require the Supabase account; unauthenticated requests expose no roster, evidence, or freshness. -After Vercel reports ready, set `VERCEL_PROTECTION_BYPASS` only in the operator's gitignored -`.env` when Deployment Protection is enabled, then run: +Private hosting is required for this demo. In Vercel **Project Settings → Deployment Protection**, +select **All Deployments** with **Vercel Authentication** before release, and add the reviewer to the +Vercel project or team. Do not ship from a plan or project configuration that leaves the production +domain public. Send the reviewer the separate Supabase guide credentials through a private channel. + +Deploy from the repository root with the pinned CLI version so the committed `vercel.json` and +workspace build are used consistently: + +```bash +npx --yes vercel@48.8.0 deploy --prod +``` + +Create a Vercel Protection Bypass for Automation secret for the hosted smoke only, set it as +`VERCEL_PROTECTION_BYPASS` in the operator's gitignored `.env`, and run: ```bash npm run smoke:hosted -- --base-url https://YOUR-PRIVATE-DEMO.vercel.app ``` That command checks health/policy output, sign-in, the fixed synthetic CSV, and fail-closed anonymous -board access through the protection wall. Never add the bypass value to Vercel's application -environment. The captain still performs the authenticated walkthrough below; the smoke command does -not pretend to replace it. +board access through the required protection wall. Never add the bypass value to Vercel's +application environment or share it with the reviewer. The captain still performs the authenticated +walkthrough below; the smoke command does not pretend to replace it. ## Two-minute reviewer walkthrough diff --git a/apps/web/app/board/evidence-desk.tsx b/apps/web/app/board/evidence-desk.tsx index 75ab300..331ed77 100644 --- a/apps/web/app/board/evidence-desk.tsx +++ b/apps/web/app/board/evidence-desk.tsx @@ -411,11 +411,11 @@ export function EvidenceDetails({
{label}
+

Exact contributing attempts

{evidence.sessions.length ?

Exact contributing sessions

: null} -
); @@ -433,14 +433,21 @@ export function narrationProvenance(entry: BoardEntryView): string { return `${mode} · ${status} · catalog ${entry.narration.catalogVersion} · renderer ${entry.narration.renderVersion}`; } -function NarrationTrust({ entry }: { entry: BoardEntryView }) { +export function NarrationTrust({ entry }: { entry: BoardEntryView }) { const fallback = entry.narration.mode === 'deterministic-fallback'; + const degraded = entry.narration.status === 'degraded'; + const degradedReason = + entry.narration.degradedReason?.replaceAll('-', ' ') ?? 'generated wording unavailable'; return (