From cc7766cbc2ce9b608d0952b4c216f7985d39bd6b Mon Sep 17 00:00:00 2001 From: alexdancer Date: Wed, 29 Jul 2026 14:25:39 -0500 Subject: [PATCH 1/6] feat: add Evidence Desk shell and visible-open use case --- .../web/app/board/acknowledge-visible-open.ts | 23 + apps/web/app/board/entry-card.tsx | 12 +- apps/web/app/board/error.tsx | 20 + apps/web/app/board/evidence-desk.tsx | 434 ++++++++++++++++++ apps/web/app/board/lib/evidence-desk-state.ts | 70 +++ apps/web/app/board/loading.tsx | 7 + apps/web/app/board/page.tsx | 81 ++-- apps/web/app/board/rail-navigation.tsx | 62 +++ .../app/board/visible-open-acknowledgment.tsx | 54 +++ apps/web/lib/acknowledgment-tokens.ts | 137 ++++++ apps/web/test/acknowledgment-tokens.test.ts | 87 ++++ apps/web/test/board-without-narrator.test.ts | 2 +- apps/web/test/evidence-desk-state.test.ts | 136 ++++++ apps/web/test/server-only.ts | 2 + apps/web/vitest.config.ts | 4 + .../src/evidence-reader-implementation.ts | 202 ++++++++ packages/application/src/index.ts | 2 + .../evidence-reader-implementation.test.ts | 229 +++++++++ 18 files changed, 1509 insertions(+), 55 deletions(-) create mode 100644 apps/web/app/board/acknowledge-visible-open.ts create mode 100644 apps/web/app/board/error.tsx create mode 100644 apps/web/app/board/evidence-desk.tsx create mode 100644 apps/web/app/board/lib/evidence-desk-state.ts create mode 100644 apps/web/app/board/loading.tsx create mode 100644 apps/web/app/board/rail-navigation.tsx create mode 100644 apps/web/app/board/visible-open-acknowledgment.tsx create mode 100644 apps/web/lib/acknowledgment-tokens.ts create mode 100644 apps/web/test/acknowledgment-tokens.test.ts create mode 100644 apps/web/test/evidence-desk-state.test.ts create mode 100644 apps/web/test/server-only.ts create mode 100644 packages/application/src/evidence-reader-implementation.ts create mode 100644 packages/application/test/evidence-reader-implementation.test.ts diff --git a/apps/web/app/board/acknowledge-visible-open.ts b/apps/web/app/board/acknowledge-visible-open.ts new file mode 100644 index 0000000..9fecdbf --- /dev/null +++ b/apps/web/app/board/acknowledge-visible-open.ts @@ -0,0 +1,23 @@ +import 'server-only'; + +import type { EvidenceReader, GuideAccess } from '@huddle/application'; +import type { VisibleAcknowledgmentAction } from './visible-open-acknowledgment'; + +/** + * 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, + }); + }; +} diff --git a/apps/web/app/board/entry-card.tsx b/apps/web/app/board/entry-card.tsx index 8d7bd17..586c395 100644 --- a/apps/web/app/board/entry-card.tsx +++ b/apps/web/app/board/entry-card.tsx @@ -17,10 +17,20 @@ export function EntryCard({ row }: { row: BoardRow }) { export function formatAdditionalCauses(causes: BoardRow['additionalCauses']): string { if (causes.length === 0) return '—'; return causes - .map(({ cause, severity }) => `${cause} (${Math.round(severity * 100)}%)`) + .map(({ cause, severity }) => `${humanizeLegacyCause(cause)} — ${priorityBand(severity)}`) .join(', '); } +function humanizeLegacyCause(cause: BoardRow['cause']): string { + return cause.replaceAll('_', ' '); +} + +function priorityBand(severity: number): string { + if (severity >= 0.75) return 'urgent priority'; + if (severity >= 0.5) return 'elevated priority'; + return 'watch priority'; +} + function severityClass(severity: number): string { if (severity >= 0.75) return 'severe'; if (severity >= 0.5) return 'elevated'; diff --git a/apps/web/app/board/error.tsx b/apps/web/app/board/error.tsx new file mode 100644 index 0000000..14c7842 --- /dev/null +++ b/apps/web/app/board/error.tsx @@ -0,0 +1,20 @@ +'use client'; + +export default function BoardError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + void error; + return ( +
+

Board unavailable

+

Huddle could not retrieve a current or prior board. This is not an all-clear.

+ +
+ ); +} diff --git a/apps/web/app/board/evidence-desk.tsx b/apps/web/app/board/evidence-desk.tsx new file mode 100644 index 0000000..40d8f99 --- /dev/null +++ b/apps/web/app/board/evidence-desk.tsx @@ -0,0 +1,434 @@ +import type { AcknowledgmentView, BoardEntryView, EvidenceView } from '@huddle/application'; +import type { EvidenceDeskState } from './lib/evidence-desk-state'; +import { RailLink, RestoreRailPosition, ReturnToRail } from './rail-navigation'; +import { + VisibleOpenAcknowledgment, + type VisibleAcknowledgmentAction, +} from './visible-open-acknowledgment'; + +const causeLabels: Record = { + guessing: 'Guessing pattern', + prerequisite_gap: 'Prerequisite gap', + grinding: 'Sustained effort signal', + hint_farming: 'Hint reliance', + no_read_retry: 'Retry pattern', + decay: 'Recent decay', + disengagement: 'Engagement change', + fine: 'No action needed', +}; + +export function humanizeCause(cause: BoardEntryView['cause']): string { + return causeLabels[cause]; +} +export function priorityBand( + severity: number +): 'Urgent priority' | 'Elevated priority' | 'Watch priority' { + if (severity >= 0.75) return 'Urgent priority'; + 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 formatMetric(value: number | null, unit = ''): string { + return value == null ? 'Not available' : `${value}${unit}`; +} +function timing(value: number | null): string { + return value == null ? 'Not recorded' : `${Math.round(value / 1000)} sec`; +} +function StatusChip({ entry }: { entry: BoardEntryView }) { + return ( +
+ + {priorityBand(entry.severity)} + + + Evidence confidence: {confidenceLabel(entry.finalConfidence)} + +
+ ); +} +function Freshness({ + state, +}: { + state: Extract['board']; +}) { + const refresh = state.refresh.state; + const freshness = + state.kind === 'stale' ? 'Last successful board — stale' : 'Committed board ready'; + const refreshText = + refresh === 'idle' + ? 'No refresh in progress' + : refresh === 'queued' || refresh === 'running' + ? 'Refresh in progress; this board remains usable' + : refresh === 'failed' + ? 'Refresh failed; prior board remains usable' + : 'Refresh completed'; + return ( +
+ + {freshness} + As of {state.asOf} · America/Chicago · synthetic guide workspace + {refreshText} +
+ ); +} + +function Rail({ + board, + selectedId, +}: { + board: Extract['board']; + selectedId?: string; +}) { + return ( + + ); +} + +function AttemptTable({ evidence }: { evidence: EvidenceView }) { + return ( + + + + + + + + + + + + + {evidence.exact.attempts.map((attempt) => ( + + + + + + + + + ))} + +
WhenSkill / itemOutcomeWall-clockEngagedQuality
{attempt.submittedAt} + {attempt.skill.code} · {attempt.itemType} + + {attempt.isCorrect ? 'Correct' : 'Incorrect'} + {attempt.chosenLabel ? ` · ${attempt.chosenLabel}` : ''} + {timing(attempt.elapsedMs)}{timing(attempt.engagedMs)}{attempt.timingQuality}
+ ); +} +function EvidenceDetails({ + label, + evidence, +}: { + label: string; + evidence: Pick; +}) { + return ( +
+ {label} +
+
+
+
Contributing attempts
+
{evidence.comparison.computed.attemptCount}
+
+
+
Incorrect attempts
+
{evidence.comparison.computed.wrongCount}
+
+
+
Wrong-of-last-N
+
+ {evidence.comparison.derived.wrongOfLastN + ? `${evidence.comparison.derived.wrongOfLastN.wrong} of ${evidence.comparison.derived.wrongOfLastN.of}` + : 'Not available'} +
+
+
+
Speed ratio
+
{formatMetric(evidence.comparison.derived.speedRatio)}
+
+
+ +

Sessions

+
    + {evidence.exact.sessions.map((session) => ( +
  • + {session.startedAt}–{session.endedAt} · {timing(session.totalElapsedMs)} total ·{' '} + {session.vendorAttemptCount ?? 'No source count'} attempts · {session.timingQuality} +
  • + ))} +
+
+
+ ); +} +function Workspace({ + evidence, + grant, + renewal, + action, +}: { + evidence: EvidenceView; + grant: string; + renewal: string; + action?: VisibleAcknowledgmentAction; +}) { + const { entry } = evidence; + const comparison = evidence.comparison; + return ( +
+ +

Evidence report · rank {entry.rank}

+ +

{entry.student.firstName}

+

+ {entry.scope.kind === 'skill' + ? `${entry.scope.skill.code} · ${entry.scope.skill.name}` + : 'Cross-skill evidence'} +

+
+

Why this is ranked now

+

{entry.diagnosis}

+
+ Try this opener +
+ {entry.opener} +
+
+
+ {' '} + {entry.narration.mode === 'deterministic-fallback' + ? 'Deterministic fallback' + : 'Validated generated wording'}{' '} + ·{' '} + {entry.narration.status === 'degraded' + ? `degraded: ${entry.narration.degradedReason}` + : 'complete'}{' '} + · catalog {entry.narration.catalogVersion} +
+
+

Compare with this student’s own pattern

+
+
+
Personal correct baseline
+
{timing(comparison.computed.personalCorrectBaselineMs)}
+
+
+
Recent wrong duration
+
{timing(comparison.computed.medianWrongDurationMs)}
+
+
+
Consecutive wrong
+
{formatMetric(comparison.derived.consecutiveWrong)}
+
+
+
+
+ Prerequisite checks and conflict adjustments +
+

+ {comparison.prerequisiteCheck + ? `${comparison.prerequisiteCheck.skillCode} · ${comparison.prerequisiteCheck.skillName}: ${comparison.prerequisiteCheck.verdict}${comparison.prerequisiteCheck.isKnown ? ` (${comparison.prerequisiteCheck.masteryValue})` : ' (unknown)'}` + : 'No prerequisite check applies to this finding.'} +

+
    + {comparison.conflicts.length ? ( + comparison.conflicts.map((conflict) => ( +
  • + {conflict.family}: {humanizeCause(conflict.suggestedCause)} adjustment from{' '} + {conflict.ruleId} +
  • + )) + ) : ( +
  • No conflicting deterministic rule adjustments.
  • + )} +
+
+
+ +
+

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 + +
+

+ {additional.prerequisiteCheck + ? `Prerequisite: ${additional.prerequisiteCheck.skillCode} · ${additional.prerequisiteCheck.verdict}` + : 'No prerequisite check applies.'} +

+
    + {additional.conflicts.length ? ( + additional.conflicts.map((conflict) => ( +
  • + {conflict.family}: {humanizeCause(conflict.suggestedCause)} from{' '} + {conflict.ruleId} +
  • + )) + ) : ( +
  • No conflict adjustments.
  • + )} +
+ +
+
+ )) + ) : ( +

No additional causes were retained for this report.

+ )} +
+ {action ? ( + + ) : null} +
+ ); +} + +export function EvidenceDesk({ + state, + action, +}: { + state: EvidenceDeskState; + action?: VisibleAcknowledgmentAction; +}) { + if (state.kind === 'checking-session') + return ( +
+

Checking your guide workspace…

+
+ ); + if (state.kind === 'denied') + return ( +
+

Guide workspace unavailable

+

Sign in with an authorized synthetic guide account to continue.

+
+ ); + if (state.kind === 'unavailable') + return ( +
+

Board unavailable

+

No current or last-successful board could be retrieved. This is not an all-clear.

+
+ ); + if (state.kind === 'not-built') + return ( +
+

Today’s board is not built yet

+

A complete board has not been committed. Try again after refresh is available.

+
+ ); + const selected = state.kind === 'board' ? state.open?.evidence.entry.triageEntryId : undefined; + return ( +
+ {state.kind === 'board' && !state.open ? : null} +
+
+

Huddle · synthetic-only

+

Evidence Desk

+

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

+
+
+ + {state.kind === 'board-updated' ? ( +
+ Board updated; select the current report. + Your previous report was not opened or acknowledged. +
+ ) : null} + {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. +

+
+ ) : ( +
+ +
+ {state.kind === 'board' && state.open ? ( + + ) : ( +
+

Select a report

+

+ Open a ranked report to review its evidence, baseline comparison, and concrete + first move. +

+
+ )} +
+
+ )} +
+ ); +} diff --git a/apps/web/app/board/lib/evidence-desk-state.ts b/apps/web/app/board/lib/evidence-desk-state.ts new file mode 100644 index 0000000..5479b6a --- /dev/null +++ b/apps/web/app/board/lib/evidence-desk-state.ts @@ -0,0 +1,70 @@ +import type { + AuthorizedEvidenceOpen, + BoardReader, + BoardView, + EvidenceReader, + GuideAccess, +} from '@huddle/application'; + +export type EvidenceDeskState = + | { kind: 'checking-session' } + | { kind: 'denied' } + | { kind: 'unavailable' } + | { kind: 'not-built'; refresh: Extract['refresh'] } + | { + kind: 'board-updated'; + board: Extract; + } + | { + kind: 'board'; + board: Extract; + open: AuthorizedEvidenceOpen | null; + }; + +export interface EvidenceDeskDependencies { + resolveAccess(): Promise; + boardReader?: BoardReader; + evidenceReader?: EvidenceReader; +} + +/** + * Top-level adapter state is intentionally broader than BoardView. BoardView begins only after a + * trusted GuideAccess exists, so denied and unavailable must never borrow board facts from it. + */ +export async function readEvidenceDeskState( + dependencies: EvidenceDeskDependencies, + input: { boardDate: string; boardRunId?: string; triageEntryId?: string } +): Promise { + const access = await dependencies.resolveAccess(); + if (!access) return { kind: 'denied' }; + if (!dependencies.boardReader) return { kind: 'unavailable' }; + + const board = await dependencies.boardReader.readCurrent(access, { boardDate: input.boardDate }); + if (board.kind === 'not-built') return { kind: 'not-built', refresh: board.refresh }; + if (board.kind === 'successful-empty') return { kind: 'board', board, open: null }; + + if (!input.boardRunId && !input.triageEntryId) return { kind: 'board', board, open: null }; + if ( + !input.boardRunId || + !input.triageEntryId || + input.boardRunId !== board.boardRunId || + !board.entries.some((entry) => entry.triageEntryId === input.triageEntryId) || + !dependencies.evidenceReader + ) { + return { kind: 'board-updated', board }; + } + + // This is the actual navigation boundary. Never substitute a cached/prefetched readEntry result. + const opened = await dependencies.evidenceReader.openEntry(access, { + boardRunId: input.boardRunId, + triageEntryId: input.triageEntryId, + }); + if (opened.kind === 'authorized-evidence-open') return { kind: 'board', board, open: opened }; + + // A head promotion between rail render and opening exposes no stale detail. Re-read only the rail. + const current = await dependencies.boardReader.readCurrent(access, { + boardDate: input.boardDate, + }); + if (current.kind === 'not-built') return { kind: 'not-built', refresh: current.refresh }; + return { kind: 'board-updated', board: current }; +} diff --git a/apps/web/app/board/loading.tsx b/apps/web/app/board/loading.tsx new file mode 100644 index 0000000..d098a0f --- /dev/null +++ b/apps/web/app/board/loading.tsx @@ -0,0 +1,7 @@ +export default function LoadingBoard() { + return ( +
+

Checking your guide workspace…

+
+ ); +} diff --git a/apps/web/app/board/page.tsx b/apps/web/app/board/page.tsx index 324427b..8301a97 100644 --- a/apps/web/app/board/page.tsx +++ b/apps/web/app/board/page.tsx @@ -1,60 +1,35 @@ -import { getBoardEntriesForAuthenticatedRequest } from './lib/triage'; -import { EmptyState } from './empty-state'; -import { EntryCard } from './entry-card'; +import { resolveGuideAccess } from '../../lib/guide-access'; +import { EvidenceDesk } from './evidence-desk'; +import { chicagoBoardDate } from './lib/triage'; +import { readEvidenceDeskState } from './lib/evidence-desk-state'; -export const metadata = { - title: 'Morning Triage Board', -}; - -// The nightly board is data, not a build-time fixture. Resolve it for each request. +export const metadata = { title: 'Huddle Evidence Desk' }; export const dynamic = 'force-dynamic'; -export default async function BoardPage() { - const board = await getBoardEntriesForAuthenticatedRequest(); +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, +}: { + searchParams: { entry?: string; run?: string }; +}) { + const state = await readEvidenceDeskState( + { resolveAccess: resolveGuideAccess }, + { + boardDate: chicagoBoardDate(new Date()), + boardRunId: searchParams.run, + triageEntryId: searchParams.entry, + } + ); return ( -
- -

Morning Triage Board

- {board.status !== 'authorized' ? ( -

Sign in with an authorized demo guide account to access this board.

- ) : board.rows.length === 0 ? ( - - ) : ( - - - - - - - - - - - - - - {board.rows.map((row) => ( - - ))} - -
RankStudentCauseSkillAlso considerDiagnosisOpener
- )} -
+ <> + + + ); } diff --git a/apps/web/app/board/rail-navigation.tsx b/apps/web/app/board/rail-navigation.tsx new file mode 100644 index 0000000..56cfe30 --- /dev/null +++ b/apps/web/app/board/rail-navigation.tsx @@ -0,0 +1,62 @@ +'use client'; + +import { useEffect, useId } from 'react'; + +const returnKey = 'huddle:evidence-desk:return'; + +export function RailLink({ + entryId, + boardRunId, + selected, + children, +}: { + entryId: string; + boardRunId: string; + selected: boolean; + children: React.ReactNode; +}) { + const focusId = `rail-entry-${entryId}`; + return ( + + sessionStorage.setItem(returnKey, JSON.stringify({ focusId, scrollY: window.scrollY })) + } + > + {children} + + ); +} + +export function RestoreRailPosition() { + useEffect(() => { + const raw = sessionStorage.getItem(returnKey); + if (!raw) return; + try { + const { focusId, scrollY } = JSON.parse(raw) as { focusId: string; scrollY: number }; + requestAnimationFrame(() => { + window.scrollTo({ top: scrollY, behavior: 'auto' }); + document.getElementById(focusId)?.focus({ preventScroll: true }); + }); + } finally { + sessionStorage.removeItem(returnKey); + } + }, []); + return null; +} + +export function ReturnToRail() { + const id = useId(); + return ( + + ← Return to ranked reports + + {' '} + and restore your position + + + ); +} diff --git a/apps/web/app/board/visible-open-acknowledgment.tsx b/apps/web/app/board/visible-open-acknowledgment.tsx new file mode 100644 index 0000000..6c23eb0 --- /dev/null +++ b/apps/web/app/board/visible-open-acknowledgment.tsx @@ -0,0 +1,54 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import type { AcknowledgmentView } from '@huddle/application'; + +export type VisibleAcknowledgmentAction = (input: { + acknowledgmentGrant: string; + openingRenewalToken: string; +}) => Promise; + +/** + * This runs only after the exact report has committed to the browser. It receives no run, entry, + * guide, scope, or fingerprint from the client; server-side signed credentials remain the authority. + */ +export function VisibleOpenAcknowledgment({ + acknowledgmentGrant, + openingRenewalToken, + action, +}: { + acknowledgmentGrant: string; + openingRenewalToken: string; + action: VisibleAcknowledgmentAction; +}) { + const submitted = useRef(false); + const [status, setStatus] = useState<'pending' | 'seen' | 'unavailable'>('pending'); + + useEffect(() => { + if (submitted.current) return; + submitted.current = true; + void action({ acknowledgmentGrant, openingRenewalToken }) + .then((result) => { + setStatus('kind' in result && result.kind === 'not-found' ? 'unavailable' : 'seen'); + }) + .catch(() => setStatus('unavailable')); + }, [acknowledgmentGrant, action, openingRenewalToken]); + + if (status === 'seen') + return ( +

+ ✓ Seen just now +

+ ); + if (status === 'unavailable') + return ( +

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

+ ); + return ( +

+ Marking this visible report as seen… +

+ ); +} diff --git a/apps/web/lib/acknowledgment-tokens.ts b/apps/web/lib/acknowledgment-tokens.ts new file mode 100644 index 0000000..710f733 --- /dev/null +++ b/apps/web/lib/acknowledgment-tokens.ts @@ -0,0 +1,137 @@ +import 'server-only'; + +import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto'; +import type { + AcknowledgmentTokenClaims, + AcknowledgmentTokenService, + VerifiedAcknowledgmentToken, +} from '@huddle/application'; + +const GRANT_TTL_MS = 5 * 60 * 1000; +const RENEWAL_TTL_MS = 15 * 60 * 1000; + +type Clock = { now(): Date }; +type SigningKeys = { current: string; previous?: string }; + +function encode(value: string): string { + return Buffer.from(value).toString('base64url'); +} +function decode(value: string): string | null { + try { + return Buffer.from(value, 'base64url').toString('utf8'); + } catch { + return null; + } +} +function signature(payload: string, key: string): Buffer { + return createHmac('sha256', key).update(payload).digest(); +} +function signaturesMatch(encodedPayload: string, encodedSignature: string, key: string): boolean { + const received = Buffer.from(encodedSignature, 'base64url'); + const expected = signature(encodedPayload, key); + return received.length === expected.length && timingSafeEqual(received, expected); +} +function isClaims(value: unknown): value is AcknowledgmentTokenClaims { + if (!value || typeof value !== 'object') return false; + const candidate = value as Record; + const required = [ + 'authUserId', + 'guideId', + 'studioId', + 'boardDate', + 'boardRunId', + 'triageEntryId', + 'findingFingerprint', + 'openingId', + 'nonce', + 'issuedAt', + 'expiresAt', + ]; + return ( + (candidate.purpose === 'visible-open-grant' || candidate.purpose === 'opening-renewal') && + required.every((key) => typeof candidate[key] === 'string' && candidate[key].length > 0) && + Number.isFinite(Date.parse(candidate.issuedAt as string)) && + Number.isFinite(Date.parse(candidate.expiresAt as string)) + ); +} + +/** + * Server-only HMAC credentials for visible report opens. The opaque strings are never logged. The + * previous signing key exists solely for an in-flight rotation window; new credentials always use + * the current key. + */ +export function createAcknowledgmentTokenService( + keys: SigningKeys, + clock: Clock = { now: () => new Date() }, + nonce: () => string = randomUUID +): AcknowledgmentTokenService { + if (!keys.current) throw new Error('A current acknowledgment signing key is required'); + + const mint = ( + purpose: AcknowledgmentTokenClaims['purpose'], + input: Omit, + ttlMs: number + ) => { + const now = clock.now(); + const claims: AcknowledgmentTokenClaims = { + ...input, + purpose, + nonce: nonce(), + issuedAt: now.toISOString(), + expiresAt: new Date(now.getTime() + ttlMs).toISOString(), + }; + const payload = encode(JSON.stringify(claims)); + return { + token: `${payload}.${signature(payload, keys.current).toString('base64url')}`, + expiresAt: claims.expiresAt, + }; + }; + + return { + issue(input) { + const acknowledgmentGrant = mint('visible-open-grant', input, GRANT_TTL_MS); + const openingRenewalToken = mint('opening-renewal', input, RENEWAL_TTL_MS); + return { + acknowledgmentGrant, + openingRenewalToken: openingRenewalToken.token, + }; + }, + verify(token, purpose): VerifiedAcknowledgmentToken { + const [payload, encodedSignature, ...rest] = token.split('.'); + if (!payload || !encodedSignature || rest.length > 0) return { kind: 'invalid' }; + // Always check both supported keys; this avoids identifying which rotation key signed a token. + const currentMatches = signaturesMatch(payload, encodedSignature, keys.current); + const previousMatches = keys.previous + ? signaturesMatch(payload, encodedSignature, keys.previous) + : false; + if (!currentMatches && !previousMatches) return { kind: 'invalid' }; + const decoded = decode(payload); + if (!decoded) return { kind: 'invalid' }; + let claims: unknown; + try { + claims = JSON.parse(decoded); + } catch { + return { kind: 'invalid' }; + } + if (!isClaims(claims) || claims.purpose !== purpose) return { kind: 'invalid' }; + return new Date(claims.expiresAt).getTime() <= clock.now().getTime() + ? { kind: 'expired', claims } + : { kind: 'valid', claims }; + }, + }; +} + +/** Returns null rather than weakening acknowledgement behavior when production secrets are absent. */ +export function createServerAcknowledgmentTokenService(): AcknowledgmentTokenService | null { + const current = process.env.HUDDLE_ACKNOWLEDGMENT_KEY_CURRENT; + if (!current) return null; + return createAcknowledgmentTokenService({ + current, + previous: process.env.HUDDLE_ACKNOWLEDGMENT_KEY_PREVIOUS, + }); +} + +export const acknowledgmentTokenPolicy = { + grantTtlMs: GRANT_TTL_MS, + renewalGraceMs: RENEWAL_TTL_MS, +} as const; diff --git a/apps/web/test/acknowledgment-tokens.test.ts b/apps/web/test/acknowledgment-tokens.test.ts new file mode 100644 index 0000000..52e9afe --- /dev/null +++ b/apps/web/test/acknowledgment-tokens.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest'; +import { + acknowledgmentTokenPolicy, + createAcknowledgmentTokenService, +} from '../lib/acknowledgment-tokens'; + +function clock(iso = '2026-07-29T08:00:00.000Z') { + let now = new Date(iso); + return { + now: () => now, + advance: (milliseconds: number) => { + now = new Date(now.getTime() + milliseconds); + }, + }; +} +const identity = { + authUserId: 'auth-1', + guideId: 'guide-1', + studioId: 'studio-1', + boardDate: '2026-07-29', + boardRunId: 'run-1', + triageEntryId: 'entry-1', + findingFingerprint: 'fingerprint-1', + openingId: 'opening-1', +}; + +describe('server-only visible-open credentials', () => { + it('issues distinct purpose-bound five-minute grant and fifteen-minute renewal credentials', () => { + const now = clock(); + const tokens = createAcknowledgmentTokenService({ current: 'current-key' }, now, () => 'nonce'); + const issued = tokens.issue(identity); + const verifiedGrant = tokens.verify(issued.acknowledgmentGrant.token, 'visible-open-grant'); + expect(verifiedGrant).toMatchObject({ + kind: 'valid', + claims: expect.objectContaining({ ...identity, purpose: 'visible-open-grant' }), + }); + if (verifiedGrant.kind !== 'valid') throw new Error('expected a valid test grant'); + expect(issued.acknowledgmentGrant.expiresAt).toBe(verifiedGrant.claims.expiresAt); + expect(tokens.verify(issued.openingRenewalToken, 'opening-renewal')).toMatchObject({ + kind: 'valid', + claims: expect.objectContaining({ ...identity, purpose: 'opening-renewal' }), + }); + expect(tokens.verify(issued.acknowledgmentGrant.token, 'opening-renewal')).toEqual({ + kind: 'invalid', + }); + expect(acknowledgmentTokenPolicy).toEqual({ grantTtlMs: 300000, renewalGraceMs: 900000 }); + }); + + it('distinguishes verified expiry from tampering without logging or accepting altered content', () => { + const now = clock(); + const tokens = createAcknowledgmentTokenService({ current: 'current-key' }, now, () => 'nonce'); + const issued = tokens.issue(identity); + now.advance(acknowledgmentTokenPolicy.grantTtlMs); + expect(tokens.verify(issued.acknowledgmentGrant.token, 'visible-open-grant')).toMatchObject({ + kind: 'expired', + claims: expect.objectContaining(identity), + }); + expect(tokens.verify(`${issued.acknowledgmentGrant.token}x`, 'visible-open-grant')).toEqual({ + kind: 'invalid', + }); + }); + + it('accepts a current or previous signing key during rotation but signs new grants with current', () => { + const now = clock(); + const old = createAcknowledgmentTokenService( + { current: 'old-key' }, + now, + () => 'old-nonce' + ).issue(identity); + const rotating = createAcknowledgmentTokenService( + { current: 'new-key', previous: 'old-key' }, + now, + () => 'new-nonce' + ); + expect(rotating.verify(old.acknowledgmentGrant.token, 'visible-open-grant')).toMatchObject({ + kind: 'valid', + claims: expect.objectContaining({ nonce: 'old-nonce' }), + }); + const fresh = rotating.issue(identity); + expect( + createAcknowledgmentTokenService({ current: 'old-key' }, now).verify( + fresh.acknowledgmentGrant.token, + 'visible-open-grant' + ) + ).toEqual({ kind: 'invalid' }); + }); +}); diff --git a/apps/web/test/board-without-narrator.test.ts b/apps/web/test/board-without-narrator.test.ts index 37cad53..79e5a90 100644 --- a/apps/web/test/board-without-narrator.test.ts +++ b/apps/web/test/board-without-narrator.test.ts @@ -31,7 +31,7 @@ describe('board row rendering', () => { { cause: 'grinding', severity: 0.6 }, { cause: 'decay', severity: 0.4 }, ]) - ).toBe('grinding (60%), decay (40%)'); + ).toBe('grinding — elevated priority, decay — watch priority'); expect(formatAdditionalCauses([])).toBe('—'); }); }); diff --git a/apps/web/test/evidence-desk-state.test.ts b/apps/web/test/evidence-desk-state.test.ts new file mode 100644 index 0000000..b28a13d --- /dev/null +++ b/apps/web/test/evidence-desk-state.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { BoardReader, BoardView, EvidenceReader, GuideAccess } from '@huddle/application'; +import { createAcknowledgeVisibleOpenAction } from '../app/board/acknowledge-visible-open'; +import { readEvidenceDeskState } from '../app/board/lib/evidence-desk-state'; + +const access: GuideAccess = { + authUserId: 'auth-1', + guideId: 'guide-1', + studioId: 'studio-1', + role: 'guide', + syntheticOnly: true, +}; +const board = (boardRunId = 'run-1'): Exclude => ({ + kind: 'ready', + boardRunId, + 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', + refresh: { state: 'idle' }, + narration: { status: 'complete', degradedCount: 0 }, + entries: [ + { + triageEntryId: `${boardRunId}-entry`, + findingFingerprint: `${boardRunId}-finding`, + student: { id: 'student-1', firstName: 'Avery' }, + rank: 1, + cause: 'guessing', + scope: { kind: 'cross-skill' }, + severity: 0.8, + finalConfidence: 0.9, + diagnosis: 'Evidence-backed diagnosis.', + opener: 'Show me your first step.', + narration: { + mode: 'deterministic-fallback', + status: 'degraded', + degradedReason: 'pending', + catalogVersion: 'v1', + renderVersion: 'v1', + }, + acknowledgedAt: null, + additionalCauseCount: 1, + }, + ], +}); + +describe('Evidence Desk shell-state adapter', () => { + it('does not disclose roster or freshness while session/scope resolution is denied', async () => { + const reader: BoardReader = { readCurrent: vi.fn() }; + const state = await readEvidenceDeskState( + { resolveAccess: async () => null, boardReader: reader }, + { boardDate: '2026-07-29' } + ); + expect(state).toEqual({ kind: 'denied' }); + expect(reader.readCurrent).not.toHaveBeenCalled(); + expect(JSON.stringify(state)).not.toContain('Avery'); + }); + + it('separates unavailable composition from a successful empty board', async () => { + await expect( + readEvidenceDeskState({ resolveAccess: async () => access }, { boardDate: '2026-07-29' }) + ).resolves.toEqual({ kind: 'unavailable' }); + const empty: Exclude = { + ...board(), + kind: 'successful-empty', + entries: [], + }; + const state = await readEvidenceDeskState( + { resolveAccess: async () => access, boardReader: { readCurrent: async () => empty } }, + { boardDate: '2026-07-29' } + ); + expect(state).toMatchObject({ + kind: 'board', + board: { kind: 'successful-empty', entries: [] }, + }); + }); + + it('refreshes the rail and returns only the non-disclosing recovery after a pre-open supersession', async () => { + const old = board('run-1'); + const current = board('run-2'); + const boardReader: BoardReader = { + readCurrent: vi.fn().mockResolvedValueOnce(old).mockResolvedValueOnce(current), + }; + const evidenceReader: EvidenceReader = { + readEntry: vi.fn(), + openEntry: vi.fn(async () => ({ kind: 'not-found' }) as const), + acknowledgeVisibleOpen: vi.fn(), + }; + const state = await readEvidenceDeskState( + { resolveAccess: async () => access, boardReader, evidenceReader }, + { boardDate: '2026-07-29', boardRunId: 'run-1', triageEntryId: 'run-1-entry' } + ); + expect(state).toMatchObject({ kind: 'board-updated', board: { boardRunId: 'run-2' } }); + expect(evidenceReader.readEntry).not.toHaveBeenCalled(); + expect(evidenceReader.openEntry).toHaveBeenCalledTimes(1); + }); + + it('submits only opaque credentials after visibility and resolves access again server-side', async () => { + const evidenceReader: EvidenceReader = { + readEntry: vi.fn(), + openEntry: vi.fn(), + acknowledgeVisibleOpen: vi.fn(async () => ({ + findingFingerprint: 'run-1-finding', + acknowledgedAt: '2026-07-29T08:01:00.000Z', + })), + }; + const action = createAcknowledgeVisibleOpenAction({ + resolveAccess: async () => access, + evidenceReader, + }); + await expect( + action({ acknowledgmentGrant: 'opaque-grant', openingRenewalToken: 'opaque-renewal' }) + ).resolves.toMatchObject({ findingFingerprint: 'run-1-finding' }); + expect(evidenceReader.acknowledgeVisibleOpen).toHaveBeenCalledWith(access, { + acknowledgmentGrant: 'opaque-grant', + openingRenewalToken: 'opaque-renewal', + }); + }); + + it('treats a URL that no longer points at the selected run as a recovery, without opening it', async () => { + const boardReader: BoardReader = { readCurrent: vi.fn(async () => board()) }; + const evidenceReader: EvidenceReader = { + readEntry: vi.fn(), + openEntry: vi.fn(), + acknowledgeVisibleOpen: vi.fn(), + }; + const state = await readEvidenceDeskState( + { resolveAccess: async () => access, boardReader, evidenceReader }, + { boardDate: '2026-07-29', boardRunId: 'old-run', triageEntryId: 'old-entry' } + ); + expect(state).toMatchObject({ kind: 'board-updated', board: { boardRunId: 'run-1' } }); + expect(evidenceReader.openEntry).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/test/server-only.ts b/apps/web/test/server-only.ts new file mode 100644 index 0000000..c9f62b6 --- /dev/null +++ b/apps/web/test/server-only.ts @@ -0,0 +1,2 @@ +// Vitest replacement for Next.js's compile-time server-only marker. +export {}; diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts index b7d367e..03e4fde 100644 --- a/apps/web/vitest.config.ts +++ b/apps/web/vitest.config.ts @@ -1,6 +1,10 @@ +import { fileURLToPath } from 'node:url'; import { defineConfig } from 'vitest/config'; export default defineConfig({ + resolve: { + alias: { 'server-only': fileURLToPath(new URL('./test/server-only.ts', import.meta.url)) }, + }, test: { environment: 'node', globals: false, diff --git a/packages/application/src/evidence-reader-implementation.ts b/packages/application/src/evidence-reader-implementation.ts new file mode 100644 index 0000000..51f575f --- /dev/null +++ b/packages/application/src/evidence-reader-implementation.ts @@ -0,0 +1,202 @@ +import type { GuideAccess } from './access.js'; +import type { + AcknowledgmentView, + AuthorizedEvidenceOpen, + EvidenceReader, + EvidenceView, +} from './evidence-reader.js'; + +export type EvidenceReadIntent = 'prefetch' | 'visible-open'; + +export interface EvidenceOpenRecord { + evidence: EvidenceView; + /** Civil date of the immutable board run, bound into acknowledgment credentials. */ + boardDate: string; +} + +/** + * This is deliberately a narrow use-case port, not a database-shaped repository. The adapter must + * enforce guide scope and that a run is still evidence-readable under BoardReader selection rules. + */ +export interface EvidenceReadPort { + findAuthorizedEvidence( + access: GuideAccess, + input: { boardRunId: string; triageEntryId: string }, + intent: EvidenceReadIntent + ): Promise; +} + +export type AcknowledgmentPurpose = 'visible-open-grant' | 'opening-renewal'; + +export interface AcknowledgmentTokenClaims { + purpose: AcknowledgmentPurpose; + authUserId: string; + guideId: string; + studioId: string; + boardDate: string; + boardRunId: string; + triageEntryId: string; + findingFingerprint: string; + openingId: string; + nonce: string; + issuedAt: string; + expiresAt: string; +} + +export type VerifiedAcknowledgmentToken = + | { kind: 'valid'; claims: AcknowledgmentTokenClaims } + | { kind: 'expired'; claims: AcknowledgmentTokenClaims } + | { kind: 'invalid' }; + +/** Token material is owned by a server-only adapter; application code only sees this capability. */ +export interface AcknowledgmentTokenService { + issue(input: Omit): { + acknowledgmentGrant: { token: string; expiresAt: string }; + openingRenewalToken: string; + }; + verify(token: string, purpose: AcknowledgmentPurpose): VerifiedAcknowledgmentToken; +} + +/** + * One transaction in the persistence adapter consumes a grant/reopen nonce and returns the + * immutable first acknowledgment. It must never be backed by process-local production state. + */ +export interface AcknowledgmentLedgerPort { + consumeVisibleGrant(input: { + grant: AcknowledgmentTokenClaims; + renewal: AcknowledgmentTokenClaims; + }): Promise; + renewAndConsumeVisibleGrant(input: { + expiredGrant: AcknowledgmentTokenClaims; + renewal: AcknowledgmentTokenClaims; + /** A freshly issued same-opening nonce, consumed in the same transaction as the source claim. */ + replacementGrant: AcknowledgmentTokenClaims; + }): Promise; +} + +export interface EvidenceReaderDependencies { + evidence: EvidenceReadPort; + acknowledgments: AcknowledgmentLedgerPort; + tokens: AcknowledgmentTokenService; + newOpeningId(): string; +} + +function accessIsUsable(access: GuideAccess): boolean { + return access.role === 'guide' && access.syntheticOnly; +} + +function sameOpening( + access: GuideAccess, + grant: AcknowledgmentTokenClaims, + renewal: AcknowledgmentTokenClaims +): boolean { + return ( + grant.purpose === 'visible-open-grant' && + renewal.purpose === 'opening-renewal' && + grant.authUserId === access.authUserId && + grant.guideId === access.guideId && + grant.studioId === access.studioId && + grant.authUserId === renewal.authUserId && + grant.guideId === renewal.guideId && + grant.studioId === renewal.studioId && + grant.boardDate === renewal.boardDate && + grant.boardRunId === renewal.boardRunId && + grant.triageEntryId === renewal.triageEntryId && + grant.findingFingerprint === renewal.findingFingerprint && + grant.openingId === renewal.openingId + ); +} + +async function renewExpiredVisibleGrant( + dependencies: EvidenceReaderDependencies, + expiredGrant: AcknowledgmentTokenClaims, + renewal: AcknowledgmentTokenClaims +): Promise { + const replacement = dependencies.tokens.issue({ + authUserId: expiredGrant.authUserId, + guideId: expiredGrant.guideId, + studioId: expiredGrant.studioId, + boardDate: expiredGrant.boardDate, + boardRunId: expiredGrant.boardRunId, + triageEntryId: expiredGrant.triageEntryId, + findingFingerprint: expiredGrant.findingFingerprint, + openingId: expiredGrant.openingId, + }); + const verifiedReplacement = dependencies.tokens.verify( + replacement.acknowledgmentGrant.token, + 'visible-open-grant' + ); + if (verifiedReplacement.kind !== 'valid') return null; + return dependencies.acknowledgments.renewAndConsumeVisibleGrant({ + expiredGrant, + renewal, + replacementGrant: verifiedReplacement.claims, + }); +} + +/** + * Framework- and persistence-neutral EvidenceReader implementation. `readEntry` cannot issue a + * grant; only `openEntry` creates a grant after a fresh authorized lookup. Acknowledgment never + * accepts route identifiers from the browser: they are entirely recovered from signed credentials. + */ +export function createEvidenceReader(dependencies: EvidenceReaderDependencies): EvidenceReader { + return { + async readEntry(access, input) { + if (!accessIsUsable(access)) return { kind: 'not-found' }; + const record = await dependencies.evidence.findAuthorizedEvidence(access, input, 'prefetch'); + return record?.evidence ?? { kind: 'not-found' }; + }, + + async openEntry(access, input) { + if (!accessIsUsable(access)) return { kind: 'not-found' }; + const record = await dependencies.evidence.findAuthorizedEvidence( + access, + input, + 'visible-open' + ); + if (!record) return { kind: 'not-found' }; + + const entry = record.evidence.entry; + const openingId = dependencies.newOpeningId(); + const credentials = dependencies.tokens.issue({ + authUserId: access.authUserId, + guideId: access.guideId, + studioId: access.studioId, + boardDate: record.boardDate, + boardRunId: record.evidence.boardRunId, + triageEntryId: entry.triageEntryId, + findingFingerprint: entry.findingFingerprint, + openingId, + }); + const result: AuthorizedEvidenceOpen = { + kind: 'authorized-evidence-open', + evidence: record.evidence, + openingId, + ...credentials, + }; + return result; + }, + + async acknowledgeVisibleOpen(access, input) { + if (!accessIsUsable(access)) return { kind: 'not-found' }; + const grant = dependencies.tokens.verify(input.acknowledgmentGrant, 'visible-open-grant'); + const renewal = dependencies.tokens.verify(input.openingRenewalToken, 'opening-renewal'); + if ( + grant.kind === 'invalid' || + renewal.kind !== 'valid' || + !sameOpening(access, grant.claims, renewal.claims) + ) { + return { kind: 'not-found' }; + } + + const acknowledgment = + grant.kind === 'valid' + ? await dependencies.acknowledgments.consumeVisibleGrant({ + grant: grant.claims, + renewal: renewal.claims, + }) + : await renewExpiredVisibleGrant(dependencies, grant.claims, renewal.claims); + return acknowledgment ?? { kind: 'not-found' }; + }, + }; +} diff --git a/packages/application/src/index.ts b/packages/application/src/index.ts index 2b39338..479d9cc 100644 --- a/packages/application/src/index.ts +++ b/packages/application/src/index.ts @@ -1,6 +1,8 @@ export type * from './access.js'; export type * from './board-reader.js'; export type * from './evidence-reader.js'; +export type * from './evidence-reader-implementation.js'; +export { createEvidenceReader } from './evidence-reader-implementation.js'; export type * from './board-compiler.js'; export type * from './importer.js'; export { SYNTHETIC_IMPORT_CONFLICT_POLICY } from './importer.js'; diff --git a/packages/application/test/evidence-reader-implementation.test.ts b/packages/application/test/evidence-reader-implementation.test.ts new file mode 100644 index 0000000..168e99d --- /dev/null +++ b/packages/application/test/evidence-reader-implementation.test.ts @@ -0,0 +1,229 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { + AcknowledgmentTokenClaims, + AcknowledgmentTokenService, + EvidenceView, + GuideAccess, + VerifiedAcknowledgmentToken, +} from '../src/index.js'; +import { createEvidenceReader } from '../src/index.js'; + +const access: GuideAccess = { + authUserId: 'auth-guide', + guideId: 'guide-1', + studioId: 'studio-1', + role: 'guide', + syntheticOnly: true, +}; +const claims = ( + purpose: AcknowledgmentTokenClaims['purpose'], + nonce = 'nonce-1' +): AcknowledgmentTokenClaims => ({ + purpose, + authUserId: access.authUserId, + guideId: access.guideId, + studioId: access.studioId, + boardDate: '2026-07-29', + boardRunId: 'run-1', + triageEntryId: 'entry-1', + findingFingerprint: 'finding-1', + openingId: 'opening-1', + nonce, + issuedAt: '2026-07-29T08:00:00.000Z', + expiresAt: '2026-07-29T08:05:00.000Z', +}); +const evidence: EvidenceView = { + kind: 'evidence', + boardRunId: 'run-1', + entry: { + triageEntryId: 'entry-1', + findingFingerprint: 'finding-1', + student: { id: 'student-1', firstName: 'Avery' }, + rank: 1, + cause: 'guessing', + scope: { kind: 'cross-skill' }, + severity: 0.8, + finalConfidence: 0.9, + diagnosis: 'A deterministic diagnosis.', + opener: 'Show me your first step.', + narration: { + mode: 'deterministic-fallback', + status: 'degraded', + degradedReason: 'pending', + catalogVersion: 'v1', + renderVersion: 'v1', + }, + acknowledgedAt: null, + additionalCauseCount: 0, + }, + summary: { + ruleId: 'guessing-v1', + ruleVersion: 'v1', + confidenceBreakdown: { timingMultiplier: 1, winsorizationMultiplier: 1, conflictMultiplier: 1 }, + }, + comparison: { + computed: { + attemptCount: 1, + wrongCount: 1, + medianWrongDurationMs: 1000, + personalCorrectBaselineMs: 2000, + personalSessionMeanBaselineMs: null, + distractorConcentration: null, + winsorizedOutCount: 0, + }, + derived: { + wrongOfLastN: { wrong: 1, of: 1 }, + speedRatio: null, + consecutiveWrong: 1, + daysSinceFirstAttempt: 1, + }, + prerequisiteCheck: null, + conflicts: [], + additionalCauses: [], + additionalEvidence: [], + }, + exact: { attempts: [], sessions: [] }, +}; + +function tokens( + overrides: Partial> = {} +): AcknowledgmentTokenService { + return { + issue: vi.fn(() => ({ + acknowledgmentGrant: { + token: 'replacement-grant', + expiresAt: claims('visible-open-grant').expiresAt, + }, + openingRenewalToken: 'renewal', + })), + verify: vi.fn( + (token: string, purpose: AcknowledgmentTokenClaims['purpose']): VerifiedAcknowledgmentToken => + overrides[token] ?? + (token === 'replacement-grant' + ? { kind: 'valid', claims: claims(purpose, 'replacement-nonce') } + : { kind: 'valid', claims: claims(purpose) }) + ), + }; +} +function readerFixture(options: { tokens?: AcknowledgmentTokenService } = {}) { + const findAuthorizedEvidence = vi.fn(async () => ({ evidence, boardDate: '2026-07-29' })); + const consumeVisibleGrant = vi.fn(async () => ({ + findingFingerprint: 'finding-1', + acknowledgedAt: '2026-07-29T08:01:00.000Z', + })); + const renewAndConsumeVisibleGrant = vi.fn(async () => ({ + findingFingerprint: 'finding-1', + acknowledgedAt: '2026-07-29T08:01:00.000Z', + })); + return { + reader: createEvidenceReader({ + evidence: { findAuthorizedEvidence }, + acknowledgments: { consumeVisibleGrant, renewAndConsumeVisibleGrant }, + tokens: options.tokens ?? tokens(), + newOpeningId: () => 'opening-1', + }), + findAuthorizedEvidence, + consumeVisibleGrant, + renewAndConsumeVisibleGrant, + }; +} + +describe('EvidenceReader visible-open boundary', () => { + it('keeps prefetch grant-free and issues fresh credentials only for an authorized visible open', async () => { + const fixture = readerFixture(); + await expect( + fixture.reader.readEntry(access, { boardRunId: 'run-1', triageEntryId: 'entry-1' }) + ).resolves.toEqual(evidence); + expect(fixture.findAuthorizedEvidence).toHaveBeenLastCalledWith( + access, + { boardRunId: 'run-1', triageEntryId: 'entry-1' }, + 'prefetch' + ); + await expect( + fixture.reader.openEntry(access, { boardRunId: 'run-1', triageEntryId: 'entry-1' }) + ).resolves.toMatchObject({ + kind: 'authorized-evidence-open', + openingId: 'opening-1', + acknowledgmentGrant: { token: 'replacement-grant' }, + }); + expect(fixture.findAuthorizedEvidence).toHaveBeenLastCalledWith( + access, + { boardRunId: 'run-1', triageEntryId: 'entry-1' }, + 'visible-open' + ); + }); + + it('does not acknowledge a prefetch and rejects a grant paired with another opening', async () => { + const fixture = readerFixture({ + tokens: tokens({ + renewal: { kind: 'valid', claims: claims('opening-renewal', 'different-nonce') }, + }), + }); + await fixture.reader.readEntry(access, { boardRunId: 'run-1', triageEntryId: 'entry-1' }); + expect(fixture.consumeVisibleGrant).not.toHaveBeenCalled(); + await expect( + fixture.reader.acknowledgeVisibleOpen(access, { + acknowledgmentGrant: 'grant', + openingRenewalToken: 'renewal', + }) + ).resolves.toEqual({ + findingFingerprint: 'finding-1', + acknowledgedAt: '2026-07-29T08:01:00.000Z', + }); + // A nonce may differ by purpose; an opening identity may not. + expect(fixture.consumeVisibleGrant).toHaveBeenCalledTimes(1); + }); + + it('fails closed when the renewal capability names another report', async () => { + const mismatched = { ...claims('opening-renewal'), triageEntryId: 'entry-2' }; + const fixture = readerFixture({ + tokens: tokens({ renewal: { kind: 'valid', claims: mismatched } }), + }); + await expect( + fixture.reader.acknowledgeVisibleOpen(access, { + acknowledgmentGrant: 'grant', + openingRenewalToken: 'renewal', + }) + ).resolves.toEqual({ kind: 'not-found' }); + expect(fixture.consumeVisibleGrant).not.toHaveBeenCalled(); + }); + + it('renews only an expired verified grant against the same valid opening', async () => { + const fixture = readerFixture({ + tokens: tokens({ grant: { kind: 'expired', claims: claims('visible-open-grant') } }), + }); + await expect( + fixture.reader.acknowledgeVisibleOpen(access, { + acknowledgmentGrant: 'grant', + openingRenewalToken: 'renewal', + }) + ).resolves.toMatchObject({ findingFingerprint: 'finding-1' }); + expect(fixture.consumeVisibleGrant).not.toHaveBeenCalled(); + expect(fixture.renewAndConsumeVisibleGrant).toHaveBeenCalledWith( + expect.objectContaining({ + expiredGrant: expect.objectContaining({ openingId: 'opening-1', nonce: 'nonce-1' }), + replacementGrant: expect.objectContaining({ + openingId: 'opening-1', + nonce: 'replacement-nonce', + }), + }) + ); + }); + + it('returns one uniform not-found result for invalid credentials and non-synthetic access', async () => { + const fixture = readerFixture({ tokens: tokens({ grant: { kind: 'invalid' } }) }); + await expect( + fixture.reader.acknowledgeVisibleOpen(access, { + acknowledgmentGrant: 'grant', + openingRenewalToken: 'renewal', + }) + ).resolves.toEqual({ kind: 'not-found' }); + await expect( + fixture.reader.openEntry({ ...access, syntheticOnly: false } as unknown as GuideAccess, { + boardRunId: 'run-1', + triageEntryId: 'entry-1', + }) + ).resolves.toEqual({ kind: 'not-found' }); + expect(fixture.consumeVisibleGrant).not.toHaveBeenCalled(); + }); +}); From 0e4575cf7a36af25b385356315007a5ab0a34b5a Mon Sep 17 00:00:00 2001 From: alexdancer Date: Wed, 29 Jul 2026 14:55:20 -0500 Subject: [PATCH 2/6] no-mistakes(review): Fix Evidence Desk truthfulness and visible-open atomicity --- apps/web/app/board/evidence-desk.tsx | 405 +++++++++++++----- apps/web/app/board/lib/evidence-desk-state.ts | 14 +- .../app/board/visible-open-acknowledgment.tsx | 145 +++++-- apps/web/lib/acknowledgment-tokens.ts | 20 +- apps/web/test/acknowledgment-tokens.test.ts | 19 + apps/web/test/evidence-desk-rendering.test.ts | 198 +++++++++ apps/web/test/evidence-desk-state.test.ts | 19 + apps/web/vitest.config.ts | 1 + .../src/evidence-reader-implementation.ts | 57 +-- packages/application/src/evidence-reader.ts | 9 +- .../evidence-reader-implementation.test.ts | 59 +-- .../contracts/application-interfaces.md | 9 +- 12 files changed, 729 insertions(+), 226 deletions(-) create mode 100644 apps/web/test/evidence-desk-rendering.test.ts diff --git a/apps/web/app/board/evidence-desk.tsx b/apps/web/app/board/evidence-desk.tsx index 40d8f99..1dfdba3 100644 --- a/apps/web/app/board/evidence-desk.tsx +++ b/apps/web/app/board/evidence-desk.tsx @@ -1,4 +1,9 @@ -import type { AcknowledgmentView, BoardEntryView, EvidenceView } from '@huddle/application'; +import type { + AdditionalEvidenceView, + BoardEntryView, + EvidenceView, + RefreshView, +} from '@huddle/application'; import type { EvidenceDeskState } from './lib/evidence-desk-state'; import { RailLink, RestoreRailPosition, ReturnToRail } from './rail-navigation'; import { @@ -32,11 +37,32 @@ export function confidenceLabel(confidence: number): 'High' | 'Medium' | 'Low' { if (confidence >= 0.55) return 'Medium'; return 'Low'; } -function formatMetric(value: number | null, unit = ''): string { - return value == null ? 'Not available' : `${value}${unit}`; +function formatMetric(value: number | null): string { + return value == null ? 'Not available (null)' : String(value); } function timing(value: number | null): string { - return value == null ? 'Not recorded' : `${Math.round(value / 1000)} sec`; + return value == null ? 'Not recorded (null)' : `${value} ms`; +} +function scopeLabel(scope: BoardEntryView['scope']): string { + return scope.kind === 'skill' + ? `${scope.skill.code} · ${scope.skill.name}` + : 'Cross-skill evidence'; +} +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'; + case 'queued': + return `Refresh queued at ${refresh.requestedAt} · request ${refresh.requestId}${hasCommittedBoard ? '; the committed board remains usable' : '; no completed board is available yet'}`; + case 'running': + return `Refresh running since ${refresh.requestedAt} · request ${refresh.requestId}${hasCommittedBoard ? '; the committed board remains usable' : '; no completed board is available yet'}`; + case 'succeeded': + return `Refresh succeeded at ${refresh.completedAt} · request ${refresh.requestId} · board ${refresh.boardRunId}`; + 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'}`; + } } function StatusChip({ entry }: { entry: BoardEntryView }) { return ( @@ -55,25 +81,24 @@ function StatusChip({ entry }: { entry: BoardEntryView }) { function Freshness({ state, }: { - state: Extract['board']; + state: Extract< + EvidenceDeskState, + { kind: 'board' | 'board-updated' | 'report-unavailable' } + >['board']; }) { - const refresh = state.refresh.state; const freshness = state.kind === 'stale' ? 'Last successful board — stale' : 'Committed board ready'; - const refreshText = - refresh === 'idle' - ? 'No refresh in progress' - : refresh === 'queued' || refresh === 'running' - ? 'Refresh in progress; this board remains usable' - : refresh === 'failed' - ? 'Refresh failed; prior board remains usable' - : 'Refresh completed'; + const narration = + state.narration.status === 'degraded' + ? `Narration partially available · ${state.narration.degradedCount} degraded report${state.narration.degradedCount === 1 ? '' : 's'}` + : 'Narration complete'; return (
{freshness} As of {state.asOf} · America/Chicago · synthetic guide workspace - {refreshText} + {narration} + {refreshDescription(state.refresh, true)}
); } @@ -82,7 +107,10 @@ function Rail({ board, selectedId, }: { - board: Extract['board']; + board: Extract< + EvidenceDeskState, + { kind: 'board' | 'board-updated' | 'report-unavailable' } + >['board']; selectedId?: string; }) { return ( @@ -128,110 +156,283 @@ function Rail({ ); } -function AttemptTable({ evidence }: { evidence: EvidenceView }) { +type EvidenceDisclosure = Pick< + AdditionalEvidenceView, + | 'signalId' + | 'cause' + | 'scope' + | 'summary' + | 'computed' + | 'derived' + | 'prerequisiteCheck' + | 'conflicts' + | 'attempts' + | 'sessions' +>; + +function AttemptTable({ attempts }: { attempts: EvidenceDisclosure['attempts'] }) { return ( + - + + - - - + - {evidence.exact.attempts.map((attempt) => ( + {attempts.map((attempt) => ( + + + - - - ))}
Attempt identity WhenSkill / itemSkillItem metadata OutcomeWall-clockEngagedQualityTiming
+ Attempt ID {attempt.attemptId ?? 'null'} +
+ Activity {attempt.activityId} +
+ Ordinal {attempt.ordinal} +
{attempt.submittedAt} - {attempt.skill.code} · {attempt.itemType} + {attempt.skill.code} +
+ {attempt.skill.name} +
+ {attempt.itemType} +
+ {attempt.timingProfile}
{attempt.isCorrect ? 'Correct' : 'Incorrect'} - {attempt.chosenLabel ? ` · ${attempt.chosenLabel}` : ''} +
+ 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}
{timing(attempt.elapsedMs)}{timing(attempt.engagedMs)}{attempt.timingQuality}
); } -function EvidenceDetails({ + +function SessionTable({ sessions }: { sessions: EvidenceDisclosure['sessions'] }) { + return ( + + + + + + + + + + + + + {sessions.map((session) => ( + + + + + + + + + ))} + +
Session IDStartedEndedTotal elapsedSource attempt countTiming quality
{session.sessionId}{session.startedAt}{session.endedAt}{timing(session.totalElapsedMs)}{session.vendorAttemptCount ?? 'null'}{session.timingQuality}
+ ); +} + +export function EvidenceDetails({ label, evidence, }: { label: string; - evidence: Pick; + evidence: EvidenceDisclosure; }) { + const { summary, computed, derived } = evidence; return (
{label}
+

Finding identity and confidence

+
+
+
Signal ID
+
{evidence.signalId}
+
+
+
Cause
+
{humanizeCause(evidence.cause)}
+
+
+
Scope
+
{scopeLabel(evidence.scope)}
+
+
+
Rule
+
+ {summary.ruleId} · {summary.ruleVersion} +
+
+
+
Severity
+
{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

-
Contributing attempts
-
{evidence.comparison.computed.attemptCount}
+
Attempt count
+
{computed.attemptCount}
+
+
+
Wrong count
+
{computed.wrongCount}
+
+
+
Median wrong duration
+
{timing(computed.medianWrongDurationMs)}
+
+
+
Personal correct baseline
+
{timing(computed.personalCorrectBaselineMs)}
+
+
+
Personal session-mean baseline
+
{timing(computed.personalSessionMeanBaselineMs)}
-
Incorrect attempts
-
{evidence.comparison.computed.wrongCount}
+
Distractor concentration
+
{formatMetric(computed.distractorConcentration)}
+
+
Winsorized-out count
+
{computed.winsorizedOutCount}
+
+
+ +

Derived values

+
Wrong-of-last-N
- {evidence.comparison.derived.wrongOfLastN - ? `${evidence.comparison.derived.wrongOfLastN.wrong} of ${evidence.comparison.derived.wrongOfLastN.of}` - : 'Not available'} + {derived.wrongOfLastN + ? `${derived.wrongOfLastN.wrong} of ${derived.wrongOfLastN.of}` + : 'Not available (null)'}
Speed ratio
-
{formatMetric(evidence.comparison.derived.speedRatio)}
+
{formatMetric(derived.speedRatio)}
+
+
+
Consecutive wrong
+
{formatMetric(derived.consecutiveWrong)}
+
+
+
Days since first attempt
+
{formatMetric(derived.daysSinceFirstAttempt)}
- -

Sessions

+ +

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.exact.sessions.map((session) => ( -
  • - {session.startedAt}–{session.endedAt} · {timing(session.totalElapsedMs)} total ·{' '} - {session.vendorAttemptCount ?? 'No source count'} attempts · {session.timingQuality} -
  • - ))} + {evidence.conflicts.length ? ( + evidence.conflicts.map((conflict) => ( +
  • + {conflict.family} · {humanizeCause(conflict.suggestedCause)} · {conflict.ruleId} +
  • + )) + ) : ( +
  • No conflict adjustments.
  • + )}
+ +

Exact contributing attempts

+ +

Exact contributing sessions

+
); } + +export function narrationProvenance(entry: BoardEntryView): string { + const mode = + entry.narration.mode === 'deterministic-fallback' + ? 'Deterministic fallback' + : 'Validated generated wording'; + const status = + entry.narration.status === 'degraded' + ? `degraded: ${entry.narration.degradedReason}` + : 'complete'; + return `${mode} · ${status} · catalog ${entry.narration.catalogVersion} · renderer ${entry.narration.renderVersion}`; +} + function Workspace({ evidence, grant, + grantExpiresAt, renewal, action, }: { evidence: EvidenceView; grant: string; + grantExpiresAt: string; renewal: string; action?: VisibleAcknowledgmentAction; }) { const { entry } = evidence; const comparison = evidence.comparison; - return ( + const report = (

Evidence report · rank {entry.rank}

{entry.student.firstName}

-

- {entry.scope.kind === 'skill' - ? `${entry.scope.skill.code} · ${entry.scope.skill.name}` - : 'Cross-skill evidence'} -

+

{scopeLabel(entry.scope)}

Why this is ranked now

{entry.diagnosis}

@@ -242,15 +443,7 @@ function Workspace({
- {' '} - {entry.narration.mode === 'deterministic-fallback' - ? 'Deterministic fallback' - : 'Validated generated wording'}{' '} - ·{' '} - {entry.narration.status === 'degraded' - ? `degraded: ${entry.narration.degradedReason}` - : 'complete'}{' '} - · catalog {entry.narration.catalogVersion} + {narrationProvenance(entry)}

Compare with this student’s own pattern

@@ -269,29 +462,21 @@ function Workspace({
-
- Prerequisite checks and conflict adjustments -
-

- {comparison.prerequisiteCheck - ? `${comparison.prerequisiteCheck.skillCode} · ${comparison.prerequisiteCheck.skillName}: ${comparison.prerequisiteCheck.verdict}${comparison.prerequisiteCheck.isKnown ? ` (${comparison.prerequisiteCheck.masteryValue})` : ' (unknown)'}` - : 'No prerequisite check applies to this finding.'} -

-
    - {comparison.conflicts.length ? ( - comparison.conflicts.map((conflict) => ( -
  • - {conflict.family}: {humanizeCause(conflict.suggestedCause)} adjustment from{' '} - {conflict.ruleId} -
  • - )) - ) : ( -
  • No conflicting deterministic rule adjustments.
  • - )} -
-
-
- +

Additional causes with complete evidence

{comparison.additionalEvidence.length ? ( @@ -302,32 +487,9 @@ function Workspace({ {confidenceLabel(additional.summary.finalConfidence)} evidence confidence
-

- {additional.prerequisiteCheck - ? `Prerequisite: ${additional.prerequisiteCheck.skillCode} · ${additional.prerequisiteCheck.verdict}` - : 'No prerequisite check applies.'} -

-
    - {additional.conflicts.length ? ( - additional.conflicts.map((conflict) => ( -
  • - {conflict.family}: {humanizeCause(conflict.suggestedCause)} from{' '} - {conflict.ruleId} -
  • - )) - ) : ( -
  • No conflict adjustments.
  • - )} -
@@ -336,15 +498,18 @@ function Workspace({

No additional causes were retained for this report.

)}
- {action ? ( - - ) : null}
); + return ( + + {report} + + ); } export function EvidenceDesk({ @@ -378,7 +543,8 @@ export function EvidenceDesk({ return (

Today’s board is not built yet

-

A complete board has not been committed. Try again after refresh is available.

+

A complete board has not been committed.

+

{refreshDescription(state.refresh, false)}

); const selected = state.kind === 'board' ? state.open?.evidence.entry.triageEntryId : undefined; @@ -399,6 +565,12 @@ export function EvidenceDesk({ Your previous report was not opened or acknowledged. ) : null} + {state.kind === 'report-unavailable' ? ( +
+ This report’s evidence is unavailable. + The board has not changed; select another report or try again later. +
+ ) : null} {state.board.kind === 'successful-empty' ? (

No students need attention in this completed run

@@ -414,6 +586,7 @@ export function EvidenceDesk({ diff --git a/apps/web/app/board/lib/evidence-desk-state.ts b/apps/web/app/board/lib/evidence-desk-state.ts index 5479b6a..3921de5 100644 --- a/apps/web/app/board/lib/evidence-desk-state.ts +++ b/apps/web/app/board/lib/evidence-desk-state.ts @@ -11,6 +11,10 @@ export type EvidenceDeskState = | { kind: 'denied' } | { kind: 'unavailable' } | { kind: 'not-built'; refresh: Extract['refresh'] } + | { + kind: 'report-unavailable'; + board: Extract; + } | { kind: 'board-updated'; board: Extract; @@ -48,11 +52,11 @@ export async function readEvidenceDeskState( !input.boardRunId || !input.triageEntryId || input.boardRunId !== board.boardRunId || - !board.entries.some((entry) => entry.triageEntryId === input.triageEntryId) || - !dependencies.evidenceReader + !board.entries.some((entry) => entry.triageEntryId === input.triageEntryId) ) { return { kind: 'board-updated', board }; } + if (!dependencies.evidenceReader) return { kind: 'report-unavailable', board }; // This is the actual navigation boundary. Never substitute a cached/prefetched readEntry result. const opened = await dependencies.evidenceReader.openEntry(access, { @@ -66,5 +70,11 @@ export async function readEvidenceDeskState( boardDate: input.boardDate, }); if (current.kind === 'not-built') return { kind: 'not-built', refresh: current.refresh }; + if ( + current.boardRunId === board.boardRunId && + current.entries.some((entry) => entry.triageEntryId === input.triageEntryId) + ) { + return { kind: 'report-unavailable', board: current }; + } return { kind: 'board-updated', board: current }; } diff --git a/apps/web/app/board/visible-open-acknowledgment.tsx b/apps/web/app/board/visible-open-acknowledgment.tsx index 6c23eb0..1a5388c 100644 --- a/apps/web/app/board/visible-open-acknowledgment.tsx +++ b/apps/web/app/board/visible-open-acknowledgment.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useRef, useState, type ReactNode } from 'react'; import type { AcknowledgmentView } from '@huddle/application'; export type VisibleAcknowledgmentAction = (input: { @@ -8,47 +8,138 @@ export type VisibleAcknowledgmentAction = (input: { openingRenewalToken: string; }) => Promise; -/** - * This runs only after the exact report has committed to the browser. It receives no run, entry, - * guide, scope, or fingerprint from the client; server-side signed credentials remain the authority. - */ +export function acknowledgmentLabel(acknowledgment: AcknowledgmentView): string { + return `✓ Seen ${acknowledgment.acknowledgedAt}`; +} + +function elementIsVisible(element: HTMLElement): boolean { + const bounds = element.getBoundingClientRect(); + const style = window.getComputedStyle(element); + return ( + style.display !== 'none' && + style.visibility !== 'hidden' && + bounds.bottom > 0 && + bounds.right > 0 && + bounds.top < window.innerHeight && + bounds.left < window.innerWidth + ); +} + export function VisibleOpenAcknowledgment({ acknowledgmentGrant, + acknowledgmentGrantExpiresAt, openingRenewalToken, action, + children, }: { acknowledgmentGrant: string; + acknowledgmentGrantExpiresAt: string; openingRenewalToken: string; - action: VisibleAcknowledgmentAction; + action?: VisibleAcknowledgmentAction; + children: ReactNode; }) { const submitted = useRef(false); - const [status, setStatus] = useState<'pending' | 'seen' | 'unavailable'>('pending'); + const container = useRef(null); + const [documentVisible, setDocumentVisible] = useState(false); + const [reportVisible, setReportVisible] = useState(false); + const [exposed, setExposed] = useState(false); + const [status, setStatus] = useState< + | { kind: 'waiting' } + | { kind: 'refreshing' } + | { kind: 'pending' } + | { kind: 'seen'; acknowledgment: AcknowledgmentView } + | { kind: 'unavailable' } + >({ kind: 'waiting' }); + + useEffect(() => { + const updateDocumentVisibility = () => + setDocumentVisible(document.visibilityState === 'visible'); + updateDocumentVisibility(); + document.addEventListener('visibilitychange', updateDocumentVisibility); + return () => document.removeEventListener('visibilitychange', updateDocumentVisibility); + }, []); + + useEffect(() => { + const target = container.current; + if (!target) return; + if (typeof IntersectionObserver === 'undefined') { + const measure = () => setReportVisible(elementIsVisible(target)); + measure(); + window.addEventListener('resize', measure); + window.addEventListener('scroll', measure, true); + return () => { + window.removeEventListener('resize', measure); + window.removeEventListener('scroll', measure, true); + }; + } + const observer = new IntersectionObserver( + ([entry]) => setReportVisible(entry?.isIntersecting === true), + { threshold: 0.01 } + ); + observer.observe(target); + return () => observer.disconnect(); + }, []); useEffect(() => { - if (submitted.current) return; + submitted.current = false; + setExposed(false); + setStatus({ kind: 'waiting' }); + }, [acknowledgmentGrant, acknowledgmentGrantExpiresAt, openingRenewalToken]); + + useEffect(() => { + if (exposed || !documentVisible || !reportVisible) return; + if (Date.parse(acknowledgmentGrantExpiresAt) <= Date.now()) { + setStatus({ kind: 'refreshing' }); + window.location.reload(); + return; + } + setExposed(true); + setStatus({ kind: action ? 'pending' : 'unavailable' }); + }, [acknowledgmentGrantExpiresAt, action, documentVisible, exposed, reportVisible]); + + useEffect(() => { + if (!action || !exposed || !documentVisible || !reportVisible || submitted.current) return; + if (!container.current || !elementIsVisible(container.current)) return; submitted.current = true; void action({ acknowledgmentGrant, openingRenewalToken }) .then((result) => { - setStatus('kind' in result && result.kind === 'not-found' ? 'unavailable' : 'seen'); + if ('kind' in result) { + setStatus({ kind: 'unavailable' }); + return; + } + setStatus({ kind: 'seen', acknowledgment: result }); }) - .catch(() => setStatus('unavailable')); - }, [acknowledgmentGrant, action, openingRenewalToken]); - - if (status === 'seen') - return ( -

- ✓ Seen just now -

- ); - if (status === 'unavailable') - return ( -

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

- ); + .catch(() => setStatus({ kind: 'unavailable' })); + }, [acknowledgmentGrant, action, documentVisible, exposed, openingRenewalToken, reportVisible]); + return ( -

- Marking this visible report as seen… -

+
+ {exposed ? children : null} + {status.kind === 'seen' ? ( +

+ {acknowledgmentLabel(status.acknowledgment)} +

+ ) : null} + {status.kind === '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 === 'waiting' ? ( +

+ Waiting for this report to become visible… +

+ ) : null} +
); } diff --git a/apps/web/lib/acknowledgment-tokens.ts b/apps/web/lib/acknowledgment-tokens.ts index 710f733..2b58059 100644 --- a/apps/web/lib/acknowledgment-tokens.ts +++ b/apps/web/lib/acknowledgment-tokens.ts @@ -16,18 +16,17 @@ type SigningKeys = { current: string; previous?: string }; function encode(value: string): string { return Buffer.from(value).toString('base64url'); } -function decode(value: string): string | null { - try { - return Buffer.from(value, 'base64url').toString('utf8'); - } catch { - return null; - } +function decodeCanonical(value: string): Buffer | null { + if (!/^[A-Za-z0-9_-]+$/.test(value)) return null; + const decoded = Buffer.from(value, 'base64url'); + return decoded.toString('base64url') === value ? decoded : null; } function signature(payload: string, key: string): Buffer { return createHmac('sha256', key).update(payload).digest(); } function signaturesMatch(encodedPayload: string, encodedSignature: string, key: string): boolean { - const received = Buffer.from(encodedSignature, 'base64url'); + const received = decodeCanonical(encodedSignature); + if (!received) return false; const expected = signature(encodedPayload, key); return received.length === expected.length && timingSafeEqual(received, expected); } @@ -99,17 +98,18 @@ export function createAcknowledgmentTokenService( verify(token, purpose): VerifiedAcknowledgmentToken { const [payload, encodedSignature, ...rest] = token.split('.'); if (!payload || !encodedSignature || rest.length > 0) return { kind: 'invalid' }; + const decodedPayload = decodeCanonical(payload); + const decodedSignature = decodeCanonical(encodedSignature); + if (!decodedPayload || !decodedSignature) return { kind: 'invalid' }; // Always check both supported keys; this avoids identifying which rotation key signed a token. const currentMatches = signaturesMatch(payload, encodedSignature, keys.current); const previousMatches = keys.previous ? signaturesMatch(payload, encodedSignature, keys.previous) : false; if (!currentMatches && !previousMatches) return { kind: 'invalid' }; - const decoded = decode(payload); - if (!decoded) return { kind: 'invalid' }; let claims: unknown; try { - claims = JSON.parse(decoded); + claims = JSON.parse(decodedPayload.toString('utf8')); } catch { return { kind: 'invalid' }; } diff --git a/apps/web/test/acknowledgment-tokens.test.ts b/apps/web/test/acknowledgment-tokens.test.ts index 52e9afe..f7a4dad 100644 --- a/apps/web/test/acknowledgment-tokens.test.ts +++ b/apps/web/test/acknowledgment-tokens.test.ts @@ -60,6 +60,25 @@ describe('server-only visible-open credentials', () => { }); }); + it('rejects malformed and noncanonical base64url signatures', () => { + const tokens = createAcknowledgmentTokenService( + { current: 'current-key' }, + clock(), + () => 'nonce' + ); + const issued = tokens.issue(identity); + const [payload, signature] = issued.acknowledgmentGrant.token.split('.'); + expect(tokens.verify(`${payload}.${signature}=`, 'visible-open-grant')).toEqual({ + kind: 'invalid', + }); + expect(tokens.verify(`${payload}.${signature}!`, 'visible-open-grant')).toEqual({ + kind: 'invalid', + }); + expect(tokens.verify(`${payload}.${signature}\n`, 'visible-open-grant')).toEqual({ + kind: 'invalid', + }); + }); + it('accepts a current or previous signing key during rotation but signs new grants with current', () => { const now = clock(); const old = createAcknowledgmentTokenService( diff --git a/apps/web/test/evidence-desk-rendering.test.ts b/apps/web/test/evidence-desk-rendering.test.ts new file mode 100644 index 0000000..14d585d --- /dev/null +++ b/apps/web/test/evidence-desk-rendering.test.ts @@ -0,0 +1,198 @@ +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it, vi } from 'vitest'; +import type { AdditionalEvidenceView, BoardEntryView, RefreshView } from '@huddle/application'; +import { + EvidenceDesk, + EvidenceDetails, + narrationProvenance, + refreshDescription, +} from '../app/board/evidence-desk'; +import type { EvidenceDeskState } from '../app/board/lib/evidence-desk-state'; +import { + acknowledgmentLabel, + VisibleOpenAcknowledgment, +} from '../app/board/visible-open-acknowledgment'; + +const evidence: AdditionalEvidenceView = { + signalId: 47, + cause: 'guessing', + scope: { kind: 'skill', skill: { code: '4.4A', name: 'Add and subtract' } }, + summary: { + dominantCause: 'guessing', + severity: 0.81, + rawConfidence: 0.92, + finalConfidence: 0.5796, + confidenceBreakdown: { + timingMultiplier: 0.9, + winsorizationMultiplier: 0.92, + conflictMultiplier: 0.7, + }, + ruleId: 'guessing.fast-wrong', + ruleVersion: '3', + }, + computed: { + attemptCount: 4, + wrongCount: 3, + medianWrongDurationMs: 1501, + personalCorrectBaselineMs: 3200, + personalSessionMeanBaselineMs: 6400, + distractorConcentration: 0.625, + winsorizedOutCount: 1, + }, + derived: { + wrongOfLastN: { wrong: 3, of: 4 }, + speedRatio: 0.4690625, + consecutiveWrong: 2, + daysSinceFirstAttempt: 6, + }, + prerequisiteCheck: { + skillCode: '4.3E', + skillName: 'Fractions', + masteryValue: 0.73, + isKnown: true, + verdict: 'adequate', + }, + conflicts: [{ family: 'mastery', suggestedCause: 'prerequisite_gap', ruleId: 'prereq-v2' }], + attempts: [ + { + attemptId: 91, + activityId: 'activity-hash-1', + ordinal: 4, + skill: { code: '4.4A', name: 'Add and subtract' }, + itemType: 'multiple_choice', + timingProfile: 'word_problem', + submittedAt: '2026-07-29T07:31:22.125Z', + isCorrect: false, + elapsedMs: 1501, + engagedMs: 1499, + timingQuality: 'engaged', + chosenLabel: 'B', + misconception: 'reversed operation', + hintsUsed: 2, + }, + ], + sessions: [ + { + sessionId: 12, + startedAt: '2026-07-29T07:30:00.000Z', + endedAt: '2026-07-29T07:40:00.000Z', + totalElapsedMs: 600001, + vendorAttemptCount: 7, + timingQuality: 'session_only', + }, + ], +}; + +describe('Evidence Desk truthful presentation', () => { + it('renders complete exact evidence without duration rounding', () => { + const html = renderToStaticMarkup( + createElement(EvidenceDetails, { label: 'Complete evidence', evidence }) + ); + for (const value of [ + 'Signal ID', + '47', + '0.92', + '0.5796', + '0.625', + '0.4690625', + 'Attempt ID 91', + 'activity-hash-1', + 'Ordinal 4', + 'word_problem', + 'reversed operation', + 'Hints used: 2', + '1501 ms', + '1499 ms', + 'Session ID', + '600001 ms', + ]) { + expect(html).toContain(value); + } + expect(html).not.toContain('2 sec'); + }); + + it('distinguishes every refresh state, including first-build failure', () => { + const states: RefreshView[] = [ + { state: 'idle' }, + { state: 'queued', requestId: 'q-1', requestedAt: 'queued-at' }, + { state: 'running', requestId: 'r-1', requestedAt: 'running-at' }, + { state: 'succeeded', requestId: 's-1', completedAt: 'succeeded-at', boardRunId: 'run-2' }, + { + state: 'failed', + requestId: 'f-1', + completedAt: 'failed-at', + failureCode: 'compile-failed', + preservedBoardRunId: null, + }, + ]; + 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 since 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', + ]); + }); + + it('includes catalog and renderer versions in narration provenance', () => { + const entry = { + narration: { + mode: 'deterministic-fallback', + status: 'degraded', + degradedReason: 'pending', + catalogVersion: 'catalog-3', + renderVersion: 'renderer-8', + }, + } as BoardEntryView; + expect(narrationProvenance(entry)).toBe( + 'Deterministic fallback · degraded: pending · catalog catalog-3 · renderer renderer-8' + ); + }); + + it('shows partial board narration and its degraded count', () => { + 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: [], + refresh: { state: 'running', requestId: 'refresh-2', requestedAt: '08:03' }, + narration: { status: 'degraded', degradedCount: 2 }, + }, + open: null, + }; + const html = renderToStaticMarkup(createElement(EvidenceDesk, { state })); + expect(html).toContain('Narration partially available · 2 degraded reports'); + expect(html).toContain('Refresh running since 08:03 · request refresh-2'); + }); + + it('preserves the server acknowledgment timestamp', () => { + expect( + acknowledgmentLabel({ + findingFingerprint: 'finding-1', + acknowledgedAt: '2026-07-29T08:01:00.000Z', + }) + ).toBe('✓ Seen 2026-07-29T08:01:00.000Z'); + }); + + it('does not server-render evidence before document and report visibility are established', () => { + const html = renderToStaticMarkup( + createElement(VisibleOpenAcknowledgment, { + acknowledgmentGrant: 'grant', + acknowledgmentGrantExpiresAt: '2099-07-29T08:05:00.000Z', + openingRenewalToken: 'renewal', + action: vi.fn(), + children: createElement('strong', null, 'private evidence'), + }) + ); + expect(html).toContain('Waiting for this report to become visible'); + expect(html).not.toContain('private evidence'); + }); +}); diff --git a/apps/web/test/evidence-desk-state.test.ts b/apps/web/test/evidence-desk-state.test.ts index b28a13d..c1f21c4 100644 --- a/apps/web/test/evidence-desk-state.test.ts +++ b/apps/web/test/evidence-desk-state.test.ts @@ -97,6 +97,25 @@ describe('Evidence Desk shell-state adapter', () => { expect(evidenceReader.openEntry).toHaveBeenCalledTimes(1); }); + it('reports unavailable evidence without claiming the unchanged board was superseded', async () => { + const current = board('run-1'); + const boardReader: BoardReader = { readCurrent: vi.fn(async () => current) }; + const evidenceReader: EvidenceReader = { + readEntry: vi.fn(), + openEntry: vi.fn(async () => ({ kind: 'not-found' }) as const), + acknowledgeVisibleOpen: vi.fn(), + }; + const state = await readEvidenceDeskState( + { resolveAccess: async () => access, boardReader, evidenceReader }, + { boardDate: '2026-07-29', boardRunId: 'run-1', triageEntryId: 'run-1-entry' } + ); + expect(state).toMatchObject({ + kind: 'report-unavailable', + board: { boardRunId: 'run-1' }, + }); + expect(boardReader.readCurrent).toHaveBeenCalledTimes(2); + }); + it('submits only opaque credentials after visibility and resolves access again server-side', async () => { const evidenceReader: EvidenceReader = { readEntry: vi.fn(), diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts index 03e4fde..38a7b94 100644 --- a/apps/web/vitest.config.ts +++ b/apps/web/vitest.config.ts @@ -2,6 +2,7 @@ import { fileURLToPath } from 'node:url'; import { defineConfig } from 'vitest/config'; export default defineConfig({ + esbuild: { jsx: 'automatic' }, resolve: { alias: { 'server-only': fileURLToPath(new URL('./test/server-only.ts', import.meta.url)) }, }, diff --git a/packages/application/src/evidence-reader-implementation.ts b/packages/application/src/evidence-reader-implementation.ts index 51f575f..772838e 100644 --- a/packages/application/src/evidence-reader-implementation.ts +++ b/packages/application/src/evidence-reader-implementation.ts @@ -58,19 +58,16 @@ export interface AcknowledgmentTokenService { } /** - * One transaction in the persistence adapter consumes a grant/reopen nonce and returns the - * immutable first acknowledgment. It must never be backed by process-local production state. + * One persistence transaction uses its own clock to consume an unexpired grant or, after atomically + * claiming an expired source nonce, invoke `mintReplacementGrant` and consume that replacement. + * Renewal expiry, source mapping, replay insertion, and the immutable first acknowledgment belong + * to that same transaction. It must never be backed by process-local production state. */ export interface AcknowledgmentLedgerPort { - consumeVisibleGrant(input: { + consumeVisibleOpening(input: { grant: AcknowledgmentTokenClaims; renewal: AcknowledgmentTokenClaims; - }): Promise; - renewAndConsumeVisibleGrant(input: { - expiredGrant: AcknowledgmentTokenClaims; - renewal: AcknowledgmentTokenClaims; - /** A freshly issued same-opening nonce, consumed in the same transaction as the source claim. */ - replacementGrant: AcknowledgmentTokenClaims; + mintReplacementGrant(): AcknowledgmentTokenClaims | null; }): Promise; } @@ -107,31 +104,25 @@ function sameOpening( ); } -async function renewExpiredVisibleGrant( +function mintReplacementGrant( dependencies: EvidenceReaderDependencies, - expiredGrant: AcknowledgmentTokenClaims, - renewal: AcknowledgmentTokenClaims -): Promise { + grant: AcknowledgmentTokenClaims +): AcknowledgmentTokenClaims | null { const replacement = dependencies.tokens.issue({ - authUserId: expiredGrant.authUserId, - guideId: expiredGrant.guideId, - studioId: expiredGrant.studioId, - boardDate: expiredGrant.boardDate, - boardRunId: expiredGrant.boardRunId, - triageEntryId: expiredGrant.triageEntryId, - findingFingerprint: expiredGrant.findingFingerprint, - openingId: expiredGrant.openingId, + authUserId: grant.authUserId, + guideId: grant.guideId, + studioId: grant.studioId, + boardDate: grant.boardDate, + boardRunId: grant.boardRunId, + triageEntryId: grant.triageEntryId, + findingFingerprint: grant.findingFingerprint, + openingId: grant.openingId, }); const verifiedReplacement = dependencies.tokens.verify( replacement.acknowledgmentGrant.token, 'visible-open-grant' ); - if (verifiedReplacement.kind !== 'valid') return null; - return dependencies.acknowledgments.renewAndConsumeVisibleGrant({ - expiredGrant, - renewal, - replacementGrant: verifiedReplacement.claims, - }); + return verifiedReplacement.kind === 'valid' ? verifiedReplacement.claims : null; } /** @@ -189,13 +180,11 @@ export function createEvidenceReader(dependencies: EvidenceReaderDependencies): return { kind: 'not-found' }; } - const acknowledgment = - grant.kind === 'valid' - ? await dependencies.acknowledgments.consumeVisibleGrant({ - grant: grant.claims, - renewal: renewal.claims, - }) - : await renewExpiredVisibleGrant(dependencies, grant.claims, renewal.claims); + const acknowledgment = await dependencies.acknowledgments.consumeVisibleOpening({ + grant: grant.claims, + renewal: renewal.claims, + mintReplacementGrant: () => mintReplacementGrant(dependencies, grant.claims), + }); return acknowledgment ?? { kind: 'not-found' }; }, }; diff --git a/packages/application/src/evidence-reader.ts b/packages/application/src/evidence-reader.ts index 01cd249..4c1fdbf 100644 --- a/packages/application/src/evidence-reader.ts +++ b/packages/application/src/evidence-reader.ts @@ -7,7 +7,7 @@ export interface AcknowledgmentView { acknowledgedAt: string; } export interface AdditionalEvidenceView { - signalId: string; + signalId: EvidenceBundle['additionalCauses'][number]['signalId']; cause: EvidenceBundle['finding']['dominantCause']; scope: EvidenceBundle['scope']; summary: EvidenceBundle['finding']; @@ -21,12 +21,9 @@ export interface AdditionalEvidenceView { export interface EvidenceView { kind: 'evidence'; boardRunId: string; + signalId: EvidenceBundle['additionalCauses'][number]['signalId']; entry: BoardEntryView; - summary: { - ruleId: string; - ruleVersion: string; - confidenceBreakdown: EvidenceBundle['finding']['confidenceBreakdown']; - }; + summary: EvidenceBundle['finding']; comparison: { computed: EvidenceBundle['computed']; derived: EvidenceBundle['derived']; diff --git a/packages/application/test/evidence-reader-implementation.test.ts b/packages/application/test/evidence-reader-implementation.test.ts index 168e99d..efc688c 100644 --- a/packages/application/test/evidence-reader-implementation.test.ts +++ b/packages/application/test/evidence-reader-implementation.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; import type { + AcknowledgmentLedgerPort, AcknowledgmentTokenClaims, AcknowledgmentTokenService, EvidenceView, @@ -35,6 +36,7 @@ const claims = ( const evidence: EvidenceView = { kind: 'evidence', boardRunId: 'run-1', + signalId: 1, entry: { triageEntryId: 'entry-1', findingFingerprint: 'finding-1', @@ -57,6 +59,10 @@ const evidence: EvidenceView = { additionalCauseCount: 0, }, summary: { + dominantCause: 'guessing', + severity: 0.8, + rawConfidence: 0.9, + finalConfidence: 0.9, ruleId: 'guessing-v1', ruleVersion: 'v1', confidenceBreakdown: { timingMultiplier: 1, winsorizationMultiplier: 1, conflictMultiplier: 1 }, @@ -105,26 +111,28 @@ function tokens( ), }; } -function readerFixture(options: { tokens?: AcknowledgmentTokenService } = {}) { +function readerFixture( + options: { tokens?: AcknowledgmentTokenService; transactionExpiresGrant?: boolean } = {} +) { const findAuthorizedEvidence = vi.fn(async () => ({ evidence, boardDate: '2026-07-29' })); - const consumeVisibleGrant = vi.fn(async () => ({ - findingFingerprint: 'finding-1', - acknowledgedAt: '2026-07-29T08:01:00.000Z', - })); - const renewAndConsumeVisibleGrant = vi.fn(async () => ({ - findingFingerprint: 'finding-1', - acknowledgedAt: '2026-07-29T08:01:00.000Z', - })); + const consumeVisibleOpening = vi.fn( + async (input: Parameters[0]) => { + if (options.transactionExpiresGrant && !input.mintReplacementGrant()) return null; + return { + findingFingerprint: 'finding-1', + acknowledgedAt: '2026-07-29T08:01:00.000Z', + }; + } + ); return { reader: createEvidenceReader({ evidence: { findAuthorizedEvidence }, - acknowledgments: { consumeVisibleGrant, renewAndConsumeVisibleGrant }, + acknowledgments: { consumeVisibleOpening }, tokens: options.tokens ?? tokens(), newOpeningId: () => 'opening-1', }), findAuthorizedEvidence, - consumeVisibleGrant, - renewAndConsumeVisibleGrant, + consumeVisibleOpening, }; } @@ -160,7 +168,7 @@ describe('EvidenceReader visible-open boundary', () => { }), }); await fixture.reader.readEntry(access, { boardRunId: 'run-1', triageEntryId: 'entry-1' }); - expect(fixture.consumeVisibleGrant).not.toHaveBeenCalled(); + expect(fixture.consumeVisibleOpening).not.toHaveBeenCalled(); await expect( fixture.reader.acknowledgeVisibleOpen(access, { acknowledgmentGrant: 'grant', @@ -171,7 +179,7 @@ describe('EvidenceReader visible-open boundary', () => { acknowledgedAt: '2026-07-29T08:01:00.000Z', }); // A nonce may differ by purpose; an opening identity may not. - expect(fixture.consumeVisibleGrant).toHaveBeenCalledTimes(1); + expect(fixture.consumeVisibleOpening).toHaveBeenCalledTimes(1); }); it('fails closed when the renewal capability names another report', async () => { @@ -185,12 +193,12 @@ describe('EvidenceReader visible-open boundary', () => { openingRenewalToken: 'renewal', }) ).resolves.toEqual({ kind: 'not-found' }); - expect(fixture.consumeVisibleGrant).not.toHaveBeenCalled(); + expect(fixture.consumeVisibleOpening).not.toHaveBeenCalled(); }); - it('renews only an expired verified grant against the same valid opening', async () => { + it('lets one ledger transaction renew a grant that expires after token verification', async () => { const fixture = readerFixture({ - tokens: tokens({ grant: { kind: 'expired', claims: claims('visible-open-grant') } }), + transactionExpiresGrant: true, }); await expect( fixture.reader.acknowledgeVisibleOpen(access, { @@ -198,16 +206,17 @@ describe('EvidenceReader visible-open boundary', () => { openingRenewalToken: 'renewal', }) ).resolves.toMatchObject({ findingFingerprint: 'finding-1' }); - expect(fixture.consumeVisibleGrant).not.toHaveBeenCalled(); - expect(fixture.renewAndConsumeVisibleGrant).toHaveBeenCalledWith( + expect(fixture.consumeVisibleOpening).toHaveBeenCalledTimes(1); + const transaction = fixture.consumeVisibleOpening.mock.calls[0]?.[0]; + expect(transaction).toEqual( expect.objectContaining({ - expiredGrant: expect.objectContaining({ openingId: 'opening-1', nonce: 'nonce-1' }), - replacementGrant: expect.objectContaining({ - openingId: 'opening-1', - nonce: 'replacement-nonce', - }), + grant: expect.objectContaining({ openingId: 'opening-1', nonce: 'nonce-1' }), + renewal: expect.objectContaining({ openingId: 'opening-1', nonce: 'nonce-1' }), }) ); + expect(transaction?.mintReplacementGrant()).toEqual( + expect.objectContaining({ openingId: 'opening-1', nonce: 'replacement-nonce' }) + ); }); it('returns one uniform not-found result for invalid credentials and non-synthetic access', async () => { @@ -224,6 +233,6 @@ describe('EvidenceReader visible-open boundary', () => { triageEntryId: 'entry-1', }) ).resolves.toEqual({ kind: 'not-found' }); - expect(fixture.consumeVisibleGrant).not.toHaveBeenCalled(); + expect(fixture.consumeVisibleOpening).not.toHaveBeenCalled(); }); }); diff --git a/specs/001-huddle-triage-board/contracts/application-interfaces.md b/specs/001-huddle-triage-board/contracts/application-interfaces.md index 942b0ba..490c179 100644 --- a/specs/001-huddle-triage-board/contracts/application-interfaces.md +++ b/specs/001-huddle-triage-board/contracts/application-interfaces.md @@ -142,7 +142,7 @@ export interface AuthorizedEvidenceOpen { } export interface AdditionalEvidenceView { - signalId: string; + signalId: EvidenceBundle['additionalCauses'][number]['signalId']; cause: RootCause; scope: EvidenceScope; summary: EvidenceBundle['finding']; @@ -157,12 +157,9 @@ export interface AdditionalEvidenceView { export interface EvidenceView { kind: 'evidence'; boardRunId: string; + signalId: EvidenceBundle['additionalCauses'][number]['signalId']; entry: BoardEntryView; - summary: { - ruleId: string; - ruleVersion: string; - confidenceBreakdown: EvidenceBundle['finding']['confidenceBreakdown']; - }; + summary: EvidenceBundle['finding']; comparison: { computed: EvidenceBundle['computed']; derived: EvidenceBundle['derived']; From c283b7620f7360402eaad16ca1f17dbf471bd712 Mon Sep 17 00:00:00 2001 From: alexdancer Date: Wed, 29 Jul 2026 15:15:13 -0500 Subject: [PATCH 3/6] no-mistakes(review): Fix supersession routing and pre-paint reveal authorization --- .../web/app/board/acknowledge-visible-open.ts | 20 ++- apps/web/app/board/evidence-desk.tsx | 9 +- apps/web/app/board/lib/evidence-desk-state.ts | 13 +- .../app/board/visible-open-acknowledgment.tsx | 155 +++++++++++++++--- apps/web/lib/acknowledgment-tokens.ts | 5 +- apps/web/test/acknowledgment-tokens.test.ts | 1 + apps/web/test/evidence-desk-rendering.test.ts | 14 +- apps/web/test/evidence-desk-state.test.ts | 64 +++++++- .../src/evidence-reader-implementation.ts | 16 +- packages/application/src/evidence-reader.ts | 8 + .../evidence-reader-implementation.test.ts | 36 +++- .../contracts/application-interfaces.md | 21 ++- 12 files changed, 317 insertions(+), 45 deletions(-) diff --git a/apps/web/app/board/acknowledge-visible-open.ts b/apps/web/app/board/acknowledge-visible-open.ts index 9fecdbf..1ba71fb 100644 --- a/apps/web/app/board/acknowledge-visible-open.ts +++ b/apps/web/app/board/acknowledge-visible-open.ts @@ -1,7 +1,10 @@ import 'server-only'; import type { EvidenceReader, GuideAccess } from '@huddle/application'; -import type { VisibleAcknowledgmentAction } from './visible-open-acknowledgment'; +import type { + VisibleAcknowledgmentAction, + VisibleOpenAuthorizationAction, +} from './visible-open-acknowledgment'; /** * The composition root binds the request's server-resolved reader. Browser input contains only the @@ -21,3 +24,18 @@ export function createAcknowledgeVisibleOpenAction(dependencies: { }); }; } + +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, + }); + }; +} diff --git a/apps/web/app/board/evidence-desk.tsx b/apps/web/app/board/evidence-desk.tsx index 1dfdba3..ec4ed0a 100644 --- a/apps/web/app/board/evidence-desk.tsx +++ b/apps/web/app/board/evidence-desk.tsx @@ -9,6 +9,7 @@ import { RailLink, RestoreRailPosition, ReturnToRail } from './rail-navigation'; import { VisibleOpenAcknowledgment, type VisibleAcknowledgmentAction, + type VisibleOpenAuthorizationAction, } from './visible-open-acknowledgment'; const causeLabels: Record = { @@ -57,7 +58,7 @@ export function refreshDescription(refresh: RefreshView, hasCommittedBoard: bool case 'queued': return `Refresh queued at ${refresh.requestedAt} · request ${refresh.requestId}${hasCommittedBoard ? '; the committed board remains usable' : '; no completed board is available yet'}`; case 'running': - return `Refresh running since ${refresh.requestedAt} · request ${refresh.requestId}${hasCommittedBoard ? '; the committed board remains usable' : '; no completed board is available yet'}`; + return `Refresh running · requested at ${refresh.requestedAt} · request ${refresh.requestId}${hasCommittedBoard ? '; the committed board remains usable' : '; no completed board is available yet'}`; case 'succeeded': return `Refresh succeeded at ${refresh.completedAt} · request ${refresh.requestId} · board ${refresh.boardRunId}`; case 'failed': @@ -416,12 +417,14 @@ function Workspace({ grant, grantExpiresAt, renewal, + authorizationAction, action, }: { evidence: EvidenceView; grant: string; grantExpiresAt: string; renewal: string; + authorizationAction?: VisibleOpenAuthorizationAction; action?: VisibleAcknowledgmentAction; }) { const { entry } = evidence; @@ -505,6 +508,7 @@ function Workspace({ acknowledgmentGrant={grant} acknowledgmentGrantExpiresAt={grantExpiresAt} openingRenewalToken={renewal} + authorizationAction={authorizationAction} action={action} > {report} @@ -514,9 +518,11 @@ function Workspace({ export function EvidenceDesk({ state, + authorizationAction, action, }: { state: EvidenceDeskState; + authorizationAction?: VisibleOpenAuthorizationAction; action?: VisibleAcknowledgmentAction; }) { if (state.kind === 'checking-session') @@ -588,6 +594,7 @@ export function EvidenceDesk({ grant={state.open.acknowledgmentGrant.token} grantExpiresAt={state.open.acknowledgmentGrant.expiresAt} renewal={state.open.openingRenewalToken} + authorizationAction={authorizationAction} action={action} /> ) : ( diff --git a/apps/web/app/board/lib/evidence-desk-state.ts b/apps/web/app/board/lib/evidence-desk-state.ts index 3921de5..6024d46 100644 --- a/apps/web/app/board/lib/evidence-desk-state.ts +++ b/apps/web/app/board/lib/evidence-desk-state.ts @@ -45,17 +45,12 @@ export async function readEvidenceDeskState( const board = await dependencies.boardReader.readCurrent(access, { boardDate: input.boardDate }); if (board.kind === 'not-built') return { kind: 'not-built', refresh: board.refresh }; - if (board.kind === 'successful-empty') return { kind: 'board', board, open: null }; if (!input.boardRunId && !input.triageEntryId) return { kind: 'board', board, open: null }; - if ( - !input.boardRunId || - !input.triageEntryId || - input.boardRunId !== board.boardRunId || - !board.entries.some((entry) => entry.triageEntryId === input.triageEntryId) - ) { - return { kind: 'board-updated', board }; - } + if (!input.boardRunId || !input.triageEntryId) return { kind: 'report-unavailable', board }; + if (input.boardRunId !== board.boardRunId) return { kind: 'board-updated', board }; + if (!board.entries.some((entry) => entry.triageEntryId === input.triageEntryId)) + return { kind: 'report-unavailable', board }; if (!dependencies.evidenceReader) return { kind: 'report-unavailable', board }; // This is the actual navigation boundary. Never substitute a cached/prefetched readEntry result. diff --git a/apps/web/app/board/visible-open-acknowledgment.tsx b/apps/web/app/board/visible-open-acknowledgment.tsx index 1a5388c..a2904b8 100644 --- a/apps/web/app/board/visible-open-acknowledgment.tsx +++ b/apps/web/app/board/visible-open-acknowledgment.tsx @@ -1,12 +1,25 @@ 'use client'; -import { useEffect, useRef, useState, type ReactNode } from 'react'; -import type { AcknowledgmentView } from '@huddle/application'; +import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'; +import type { AcknowledgmentView, VisibleOpenAuthorization } from '@huddle/application'; -export type VisibleAcknowledgmentAction = (input: { +type VisibleOpenCredentials = { acknowledgmentGrant: string; openingRenewalToken: string; -}) => Promise; +}; + +export type VisibleAcknowledgmentAction = ( + input: VisibleOpenCredentials +) => Promise; +export type VisibleOpenAuthorizationAction = ( + input: VisibleOpenCredentials +) => Promise; + +export const revealPaintSafetyMs = 5_000; + +export function remainingRevealWindow(validForMs: number, roundTripMs: number): number { + return Math.max(0, validForMs - Math.max(0, roundTripMs)); +} export function acknowledgmentLabel(acknowledgment: AcknowledgmentView): string { return `✓ Seen ${acknowledgment.acknowledgedAt}`; @@ -29,26 +42,35 @@ export function VisibleOpenAcknowledgment({ acknowledgmentGrant, acknowledgmentGrantExpiresAt, openingRenewalToken, + authorizationAction, action, children, }: { acknowledgmentGrant: string; acknowledgmentGrantExpiresAt: string; openingRenewalToken: string; + authorizationAction?: VisibleOpenAuthorizationAction; action?: VisibleAcknowledgmentAction; children: ReactNode; }) { const submitted = useRef(false); + const authorizationStarted = useRef(false); + const refreshing = useRef(false); + const exposed = useRef(false); const container = useRef(null); const [documentVisible, setDocumentVisible] = useState(false); const [reportVisible, setReportVisible] = useState(false); - const [exposed, setExposed] = useState(false); + const [revealDeadline, setRevealDeadline] = useState(null); + const [wasExposed, setWasExposed] = useState(false); const [status, setStatus] = useState< | { kind: 'waiting' } + | { kind: 'authorizing' } + | { kind: 'authorized' } | { kind: 'refreshing' } | { kind: 'pending' } | { kind: 'seen'; acknowledgment: AcknowledgmentView } - | { kind: 'unavailable' } + | { kind: 'reveal-unavailable' } + | { kind: 'acknowledgment-unavailable' } >({ kind: 'waiting' }); useEffect(() => { @@ -82,45 +104,128 @@ export function VisibleOpenAcknowledgment({ useEffect(() => { submitted.current = false; - setExposed(false); + authorizationStarted.current = false; + refreshing.current = false; + exposed.current = false; + setRevealDeadline(null); + setWasExposed(false); setStatus({ kind: 'waiting' }); }, [acknowledgmentGrant, acknowledgmentGrantExpiresAt, openingRenewalToken]); + const refreshOpening = useCallback(() => { + if (refreshing.current) return; + refreshing.current = true; + setStatus({ kind: 'refreshing' }); + window.location.reload(); + }, []); + useEffect(() => { - if (exposed || !documentVisible || !reportVisible) return; - if (Date.parse(acknowledgmentGrantExpiresAt) <= Date.now()) { - setStatus({ kind: 'refreshing' }); - window.location.reload(); + if ( + wasExposed || + revealDeadline !== null || + !documentVisible || + !reportVisible || + authorizationStarted.current + ) + return; + authorizationStarted.current = true; + if (!authorizationAction) { + setStatus({ kind: 'reveal-unavailable' }); return; } - setExposed(true); - setStatus({ kind: action ? 'pending' : 'unavailable' }); - }, [acknowledgmentGrantExpiresAt, action, documentVisible, exposed, reportVisible]); + setStatus({ kind: 'authorizing' }); + const startedAt = performance.now(); + void authorizationAction({ acknowledgmentGrant, openingRenewalToken }) + .then((result) => { + if ('kind' in result && result.kind === 'not-found') { + refreshOpening(); + return; + } + const receivedAt = performance.now(); + const remainingMs = remainingRevealWindow(result.validForMs, receivedAt - startedAt); + if (remainingMs <= revealPaintSafetyMs) { + refreshOpening(); + return; + } + setRevealDeadline(receivedAt + remainingMs); + setStatus({ kind: 'authorized' }); + }) + .catch(() => setStatus({ kind: 'reveal-unavailable' })); + }, [ + acknowledgmentGrant, + action, + authorizationAction, + documentVisible, + openingRenewalToken, + refreshOpening, + reportVisible, + revealDeadline, + wasExposed, + ]); + + const withinRevealWindow = + revealDeadline !== null && + typeof performance !== 'undefined' && + performance.now() + revealPaintSafetyMs < revealDeadline; + const shouldExpose = + wasExposed || (documentVisible && reportVisible && withinRevealWindow && !refreshing.current); useEffect(() => { - if (!action || !exposed || !documentVisible || !reportVisible || submitted.current) return; + if (wasExposed || !shouldExpose) return; + exposed.current = true; + setWasExposed(true); + setStatus(action ? { kind: 'pending' } : { kind: 'acknowledgment-unavailable' }); + }, [action, shouldExpose, wasExposed]); + + useEffect(() => { + if (wasExposed || revealDeadline === null) return; + const delayMs = revealDeadline - performance.now() - revealPaintSafetyMs; + if (delayMs <= 0) { + refreshOpening(); + return; + } + const timeout = window.setTimeout(() => { + if (!exposed.current) refreshOpening(); + }, delayMs); + return () => window.clearTimeout(timeout); + }, [refreshOpening, revealDeadline, wasExposed]); + + useEffect(() => { + if (!action || !shouldExpose || !documentVisible || !reportVisible || submitted.current) return; if (!container.current || !elementIsVisible(container.current)) return; submitted.current = true; void action({ acknowledgmentGrant, openingRenewalToken }) .then((result) => { if ('kind' in result) { - setStatus({ kind: 'unavailable' }); + setStatus({ kind: 'acknowledgment-unavailable' }); return; } setStatus({ kind: 'seen', acknowledgment: result }); }) - .catch(() => setStatus({ kind: 'unavailable' })); - }, [acknowledgmentGrant, action, documentVisible, exposed, openingRenewalToken, reportVisible]); + .catch(() => setStatus({ kind: 'acknowledgment-unavailable' })); + }, [ + acknowledgmentGrant, + action, + documentVisible, + openingRenewalToken, + reportVisible, + shouldExpose, + ]); return (
- {exposed ? children : null} + {shouldExpose ? children : null} {status.kind === 'seen' ? (

{acknowledgmentLabel(status.acknowledgment)}

) : null} - {status.kind === 'unavailable' ? ( + {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.

@@ -135,6 +240,16 @@ export function VisibleOpenAcknowledgment({ 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… diff --git a/apps/web/lib/acknowledgment-tokens.ts b/apps/web/lib/acknowledgment-tokens.ts index 2b58059..5853c34 100644 --- a/apps/web/lib/acknowledgment-tokens.ts +++ b/apps/web/lib/acknowledgment-tokens.ts @@ -114,9 +114,8 @@ export function createAcknowledgmentTokenService( return { kind: 'invalid' }; } if (!isClaims(claims) || claims.purpose !== purpose) return { kind: 'invalid' }; - return new Date(claims.expiresAt).getTime() <= clock.now().getTime() - ? { kind: 'expired', claims } - : { kind: 'valid', claims }; + const validForMs = new Date(claims.expiresAt).getTime() - clock.now().getTime(); + return validForMs <= 0 ? { kind: 'expired', claims } : { kind: 'valid', claims, validForMs }; }, }; } diff --git a/apps/web/test/acknowledgment-tokens.test.ts b/apps/web/test/acknowledgment-tokens.test.ts index f7a4dad..53e1cfc 100644 --- a/apps/web/test/acknowledgment-tokens.test.ts +++ b/apps/web/test/acknowledgment-tokens.test.ts @@ -36,6 +36,7 @@ describe('server-only visible-open credentials', () => { }); if (verifiedGrant.kind !== 'valid') throw new Error('expected a valid test grant'); expect(issued.acknowledgmentGrant.expiresAt).toBe(verifiedGrant.claims.expiresAt); + expect(verifiedGrant.validForMs).toBe(acknowledgmentTokenPolicy.grantTtlMs); expect(tokens.verify(issued.openingRenewalToken, 'opening-renewal')).toMatchObject({ kind: 'valid', claims: expect.objectContaining({ ...identity, purpose: 'opening-renewal' }), diff --git a/apps/web/test/evidence-desk-rendering.test.ts b/apps/web/test/evidence-desk-rendering.test.ts index 14d585d..772673f 100644 --- a/apps/web/test/evidence-desk-rendering.test.ts +++ b/apps/web/test/evidence-desk-rendering.test.ts @@ -11,6 +11,8 @@ import { import type { EvidenceDeskState } from '../app/board/lib/evidence-desk-state'; import { acknowledgmentLabel, + remainingRevealWindow, + revealPaintSafetyMs, VisibleOpenAcknowledgment, } from '../app/board/visible-open-acknowledgment'; @@ -129,7 +131,7 @@ 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 since running-at · request r-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', ]); @@ -170,7 +172,7 @@ describe('Evidence Desk truthful presentation', () => { }; const html = renderToStaticMarkup(createElement(EvidenceDesk, { state })); expect(html).toContain('Narration partially available · 2 degraded reports'); - expect(html).toContain('Refresh running since 08:03 · request refresh-2'); + expect(html).toContain('Refresh running · requested at 08:03 · request refresh-2'); }); it('preserves the server acknowledgment timestamp', () => { @@ -188,6 +190,7 @@ describe('Evidence Desk truthful presentation', () => { acknowledgmentGrant: 'grant', acknowledgmentGrantExpiresAt: '2099-07-29T08:05:00.000Z', openingRenewalToken: 'renewal', + authorizationAction: vi.fn(), action: vi.fn(), children: createElement('strong', null, 'private evidence'), }) @@ -195,4 +198,11 @@ describe('Evidence Desk truthful presentation', () => { expect(html).toContain('Waiting for this report to become visible'); expect(html).not.toContain('private evidence'); }); + + it('subtracts round-trip time and reserves a pre-paint reveal window', () => { + expect(remainingRevealWindow(8_000, 2_500)).toBe(5_500); + expect(remainingRevealWindow(2_000, 3_000)).toBe(0); + expect(remainingRevealWindow(8_000, 2_500)).toBeGreaterThan(revealPaintSafetyMs); + expect(remainingRevealWindow(7_000, 2_500)).toBeLessThanOrEqual(revealPaintSafetyMs); + }); }); diff --git a/apps/web/test/evidence-desk-state.test.ts b/apps/web/test/evidence-desk-state.test.ts index c1f21c4..42baafe 100644 --- a/apps/web/test/evidence-desk-state.test.ts +++ b/apps/web/test/evidence-desk-state.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it, vi } from 'vitest'; import type { BoardReader, BoardView, EvidenceReader, GuideAccess } from '@huddle/application'; -import { createAcknowledgeVisibleOpenAction } from '../app/board/acknowledge-visible-open'; +import { + createAcknowledgeVisibleOpenAction, + createAuthorizeVisibleOpenAction, +} from '../app/board/acknowledge-visible-open'; import { readEvidenceDeskState } from '../app/board/lib/evidence-desk-state'; const access: GuideAccess = { @@ -86,6 +89,7 @@ describe('Evidence Desk shell-state adapter', () => { const evidenceReader: EvidenceReader = { readEntry: vi.fn(), openEntry: vi.fn(async () => ({ kind: 'not-found' }) as const), + authorizeVisibleOpen: vi.fn(), acknowledgeVisibleOpen: vi.fn(), }; const state = await readEvidenceDeskState( @@ -103,6 +107,7 @@ describe('Evidence Desk shell-state adapter', () => { const evidenceReader: EvidenceReader = { readEntry: vi.fn(), openEntry: vi.fn(async () => ({ kind: 'not-found' }) as const), + authorizeVisibleOpen: vi.fn(), acknowledgeVisibleOpen: vi.fn(), }; const state = await readEvidenceDeskState( @@ -120,6 +125,10 @@ describe('Evidence Desk shell-state adapter', () => { const evidenceReader: EvidenceReader = { readEntry: vi.fn(), openEntry: vi.fn(), + authorizeVisibleOpen: vi.fn(async () => ({ + kind: 'authorized-visible-open' as const, + validForMs: 240_000, + })), acknowledgeVisibleOpen: vi.fn(async () => ({ findingFingerprint: 'run-1-finding', acknowledgedAt: '2026-07-29T08:01:00.000Z', @@ -129,6 +138,16 @@ describe('Evidence Desk shell-state adapter', () => { resolveAccess: async () => access, evidenceReader, }); + const authorizationAction = createAuthorizeVisibleOpenAction({ + resolveAccess: async () => access, + evidenceReader, + }); + await expect( + authorizationAction({ + acknowledgmentGrant: 'opaque-grant', + openingRenewalToken: 'opaque-renewal', + }) + ).resolves.toEqual({ kind: 'authorized-visible-open', validForMs: 240_000 }); await expect( action({ acknowledgmentGrant: 'opaque-grant', openingRenewalToken: 'opaque-renewal' }) ).resolves.toMatchObject({ findingFingerprint: 'run-1-finding' }); @@ -136,6 +155,10 @@ describe('Evidence Desk shell-state adapter', () => { acknowledgmentGrant: 'opaque-grant', openingRenewalToken: 'opaque-renewal', }); + expect(evidenceReader.authorizeVisibleOpen).toHaveBeenCalledWith(access, { + acknowledgmentGrant: 'opaque-grant', + openingRenewalToken: 'opaque-renewal', + }); }); it('treats a URL that no longer points at the selected run as a recovery, without opening it', async () => { @@ -143,6 +166,7 @@ describe('Evidence Desk shell-state adapter', () => { const evidenceReader: EvidenceReader = { readEntry: vi.fn(), openEntry: vi.fn(), + authorizeVisibleOpen: vi.fn(), acknowledgeVisibleOpen: vi.fn(), }; const state = await readEvidenceDeskState( @@ -152,4 +176,42 @@ describe('Evidence Desk shell-state adapter', () => { expect(state).toMatchObject({ kind: 'board-updated', board: { boardRunId: 'run-1' } }); expect(evidenceReader.openEntry).not.toHaveBeenCalled(); }); + + it('reports supersession when a selected run is replaced by an empty head', async () => { + const empty = { + ...board('run-2'), + kind: 'successful-empty', + entries: [], + } satisfies Exclude; + const state = await readEvidenceDeskState( + { resolveAccess: async () => access, boardReader: { readCurrent: async () => empty } }, + { boardDate: '2026-07-29', boardRunId: 'run-1', triageEntryId: 'run-1-entry' } + ); + expect(state).toMatchObject({ + kind: 'board-updated', + board: { kind: 'successful-empty', boardRunId: 'run-2' }, + }); + }); + + it('treats incomplete selection parameters as unavailable without claiming supersession', async () => { + const boardReader: BoardReader = { readCurrent: vi.fn(async () => board()) }; + await expect( + readEvidenceDeskState( + { resolveAccess: async () => access, boardReader }, + { boardDate: '2026-07-29', boardRunId: 'run-1' } + ) + ).resolves.toMatchObject({ kind: 'report-unavailable', board: { boardRunId: 'run-1' } }); + await expect( + readEvidenceDeskState( + { resolveAccess: async () => access, boardReader }, + { boardDate: '2026-07-29', triageEntryId: 'run-1-entry' } + ) + ).resolves.toMatchObject({ kind: 'report-unavailable', board: { boardRunId: 'run-1' } }); + await expect( + readEvidenceDeskState( + { resolveAccess: async () => access, boardReader }, + { boardDate: '2026-07-29', boardRunId: 'run-1', triageEntryId: 'missing-entry' } + ) + ).resolves.toMatchObject({ kind: 'report-unavailable', board: { boardRunId: 'run-1' } }); + }); }); diff --git a/packages/application/src/evidence-reader-implementation.ts b/packages/application/src/evidence-reader-implementation.ts index 772838e..f33917b 100644 --- a/packages/application/src/evidence-reader-implementation.ts +++ b/packages/application/src/evidence-reader-implementation.ts @@ -44,7 +44,7 @@ export interface AcknowledgmentTokenClaims { } export type VerifiedAcknowledgmentToken = - | { kind: 'valid'; claims: AcknowledgmentTokenClaims } + | { kind: 'valid'; claims: AcknowledgmentTokenClaims; validForMs: number } | { kind: 'expired'; claims: AcknowledgmentTokenClaims } | { kind: 'invalid' }; @@ -168,6 +168,20 @@ export function createEvidenceReader(dependencies: EvidenceReaderDependencies): return result; }, + async authorizeVisibleOpen(access, input) { + if (!accessIsUsable(access)) return { kind: 'not-found' }; + const grant = dependencies.tokens.verify(input.acknowledgmentGrant, 'visible-open-grant'); + const renewal = dependencies.tokens.verify(input.openingRenewalToken, 'opening-renewal'); + if ( + grant.kind !== 'valid' || + renewal.kind !== 'valid' || + !sameOpening(access, grant.claims, renewal.claims) + ) { + return { kind: 'not-found' }; + } + return { kind: 'authorized-visible-open', validForMs: grant.validForMs }; + }, + async acknowledgeVisibleOpen(access, input) { if (!accessIsUsable(access)) return { kind: 'not-found' }; const grant = dependencies.tokens.verify(input.acknowledgmentGrant, 'visible-open-grant'); diff --git a/packages/application/src/evidence-reader.ts b/packages/application/src/evidence-reader.ts index 4c1fdbf..871c0da 100644 --- a/packages/application/src/evidence-reader.ts +++ b/packages/application/src/evidence-reader.ts @@ -6,6 +6,10 @@ export interface AcknowledgmentView { findingFingerprint: string; acknowledgedAt: string; } +export interface VisibleOpenAuthorization { + kind: 'authorized-visible-open'; + validForMs: number; +} export interface AdditionalEvidenceView { signalId: EvidenceBundle['additionalCauses'][number]['signalId']; cause: EvidenceBundle['finding']['dominantCause']; @@ -50,6 +54,10 @@ export interface EvidenceReader { access: GuideAccess, input: { boardRunId: string; triageEntryId: string } ): Promise; + authorizeVisibleOpen( + access: GuideAccess, + input: { acknowledgmentGrant: string; openingRenewalToken: string } + ): Promise; acknowledgeVisibleOpen( access: GuideAccess, input: { acknowledgmentGrant: string; openingRenewalToken: string } diff --git a/packages/application/test/evidence-reader-implementation.test.ts b/packages/application/test/evidence-reader-implementation.test.ts index efc688c..bd3594b 100644 --- a/packages/application/test/evidence-reader-implementation.test.ts +++ b/packages/application/test/evidence-reader-implementation.test.ts @@ -106,8 +106,12 @@ function tokens( (token: string, purpose: AcknowledgmentTokenClaims['purpose']): VerifiedAcknowledgmentToken => overrides[token] ?? (token === 'replacement-grant' - ? { kind: 'valid', claims: claims(purpose, 'replacement-nonce') } - : { kind: 'valid', claims: claims(purpose) }) + ? { + kind: 'valid', + claims: claims(purpose, 'replacement-nonce'), + validForMs: 300_000, + } + : { kind: 'valid', claims: claims(purpose), validForMs: 300_000 }) ), }; } @@ -164,7 +168,11 @@ describe('EvidenceReader visible-open boundary', () => { it('does not acknowledge a prefetch and rejects a grant paired with another opening', async () => { const fixture = readerFixture({ tokens: tokens({ - renewal: { kind: 'valid', claims: claims('opening-renewal', 'different-nonce') }, + renewal: { + kind: 'valid', + claims: claims('opening-renewal', 'different-nonce'), + validForMs: 900_000, + }, }), }); await fixture.reader.readEntry(access, { boardRunId: 'run-1', triageEntryId: 'entry-1' }); @@ -185,7 +193,7 @@ describe('EvidenceReader visible-open boundary', () => { it('fails closed when the renewal capability names another report', async () => { const mismatched = { ...claims('opening-renewal'), triageEntryId: 'entry-2' }; const fixture = readerFixture({ - tokens: tokens({ renewal: { kind: 'valid', claims: mismatched } }), + tokens: tokens({ renewal: { kind: 'valid', claims: mismatched, validForMs: 900_000 } }), }); await expect( fixture.reader.acknowledgeVisibleOpen(access, { @@ -196,6 +204,26 @@ describe('EvidenceReader visible-open boundary', () => { expect(fixture.consumeVisibleOpening).not.toHaveBeenCalled(); }); + it('authorizes reveal only while the same opening grant remains server-valid', async () => { + const fixture = readerFixture(); + await expect( + fixture.reader.authorizeVisibleOpen(access, { + acknowledgmentGrant: 'grant', + openingRenewalToken: 'renewal', + }) + ).resolves.toEqual({ kind: 'authorized-visible-open', validForMs: 300_000 }); + const expired = readerFixture({ + tokens: tokens({ grant: { kind: 'expired', claims: claims('visible-open-grant') } }), + }); + await expect( + expired.reader.authorizeVisibleOpen(access, { + acknowledgmentGrant: 'grant', + openingRenewalToken: 'renewal', + }) + ).resolves.toEqual({ kind: 'not-found' }); + expect(expired.consumeVisibleOpening).not.toHaveBeenCalled(); + }); + it('lets one ledger transaction renew a grant that expires after token verification', async () => { const fixture = readerFixture({ transactionExpiresGrant: true, diff --git a/specs/001-huddle-triage-board/contracts/application-interfaces.md b/specs/001-huddle-triage-board/contracts/application-interfaces.md index 490c179..8efba4f 100644 --- a/specs/001-huddle-triage-board/contracts/application-interfaces.md +++ b/specs/001-huddle-triage-board/contracts/application-interfaces.md @@ -63,6 +63,14 @@ export interface EvidenceReader { input: { boardRunId: string; triageEntryId: string } ): Promise; + authorizeVisibleOpen( + access: GuideAccess, + input: { + acknowledgmentGrant: string; + openingRenewalToken: string; + } + ): Promise; + acknowledgeVisibleOpen( access: GuideAccess, input: { @@ -130,6 +138,11 @@ export interface AcknowledgmentView { acknowledgedAt: string; } +export interface VisibleOpenAuthorization { + kind: 'authorized-visible-open'; + validForMs: number; +} + export interface AuthorizedEvidenceOpen { kind: 'authorized-evidence-open'; evidence: EvidenceView; @@ -290,9 +303,11 @@ prefetched result and call `EvidenceReader.openEntry` for a fresh authorization returns fresh evidence with a signed, five-minute, one-use acknowledgment grant bound to the authenticated user, application scope, board date, run, entry, finding fingerprint, and unique opening ID. The same result carries an opaque renewal capability bound to that exact authorized -opening. It cannot authorize another user, scope, report, finding, or opening. The report may render -only from this opening result; if its grant expires before visibility, the adapter repeats -`openEntry` rather than rendering with an expired grant. +opening. It cannot authorize another user, scope, report, finding, or opening. When the report +placeholder becomes visible, the client calls the side-effect-free `authorizeVisibleOpen` boundary +with only the opaque credentials. The server returns the grant's remaining validity, and the client +subtracts the full round-trip duration and reserves a pre-paint safety window. If that window cannot +be preserved, the adapter repeats `openEntry` rather than rendering with an expired grant. After that exact opening result is successfully visible, a tiny client adapter submits only the opaque grant plus its opening renewal capability to a narrow server action, which re-verifies the From 589c898b9533e5437c0acfbc8a18f2cc72582cbe Mon Sep 17 00:00:00 2001 From: alexdancer Date: Wed, 29 Jul 2026 15:40:25 -0500 Subject: [PATCH 4/6] no-mistakes(review): Close reveal supersession and restore stable rail focus --- apps/web/app/board/evidence-desk.tsx | 3 +- apps/web/app/board/rail-navigation.tsx | 27 +++++++++-- apps/web/test/rail-navigation.test.ts | 46 ++++++++++++++++++ .../src/evidence-reader-implementation.ts | 25 +++++++++- .../evidence-reader-implementation.test.ts | 47 ++++++++++++++++++- .../contracts/application-interfaces.md | 11 +++-- 6 files changed, 146 insertions(+), 13 deletions(-) create mode 100644 apps/web/test/rail-navigation.test.ts diff --git a/apps/web/app/board/evidence-desk.tsx b/apps/web/app/board/evidence-desk.tsx index ec4ed0a..c6cedd8 100644 --- a/apps/web/app/board/evidence-desk.tsx +++ b/apps/web/app/board/evidence-desk.tsx @@ -115,7 +115,7 @@ function Rail({ selectedId?: string; }) { return ( -