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..1ba71fb --- /dev/null +++ b/apps/web/app/board/acknowledge-visible-open.ts @@ -0,0 +1,41 @@ +import 'server-only'; + +import type { EvidenceReader, GuideAccess } from '@huddle/application'; +import type { + VisibleAcknowledgmentAction, + VisibleOpenAuthorizationAction, +} 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, + }); + }; +} + +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/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..83039db --- /dev/null +++ b/apps/web/app/board/evidence-desk.tsx @@ -0,0 +1,620 @@ +import type { + AdditionalEvidenceView, + BoardEntryView, + EvidenceView, + RefreshView, +} from '@huddle/application'; +import type { EvidenceDeskState } from './lib/evidence-desk-state'; +import { + evidenceDeskStateFocusId, + RailLink, + RestoreRailPosition, + ReturnToRail, +} from './rail-navigation'; +import { + VisibleOpenAcknowledgment, + type VisibleAcknowledgmentAction, + type VisibleOpenAuthorizationAction, +} 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): string { + return value == null ? 'Not available (null)' : String(value); +} +function timing(value: number | null): string { + 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 · 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': + 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 ( +
+ + {priorityBand(entry.severity)} + + + Evidence confidence: {confidenceLabel(entry.finalConfidence)} + +
+ ); +} +function Freshness({ + state, +}: { + state: Extract< + EvidenceDeskState, + { kind: 'board' | 'board-updated' | 'report-unavailable' } + >['board']; +}) { + const freshness = + state.kind === 'stale' ? 'Last successful board — stale' : 'Committed board ready'; + 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 + {narration} + {refreshDescription(state.refresh, true)} +
+ ); +} + +function Rail({ + board, + selectedId, +}: { + board: Extract< + EvidenceDeskState, + { kind: 'board' | 'board-updated' | 'report-unavailable' } + >['board']; + selectedId?: string; +}) { + return ( + + ); +} + +type EvidenceDisclosure = Pick< + AdditionalEvidenceView, + | 'signalId' + | 'cause' + | 'scope' + | 'summary' + | 'computed' + | 'derived' + | 'prerequisiteCheck' + | 'conflicts' + | 'attempts' + | 'sessions' +>; + +function AttemptTable({ attempts }: { attempts: EvidenceDisclosure['attempts'] }) { + return ( + + + + + + + + + + + + + {attempts.map((attempt) => ( + + + + + + + + + ))} + +
Attempt identityWhenSkillItem metadataOutcomeTiming
+ Attempt ID {attempt.attemptId ?? 'null'} +
+ Activity {attempt.activityId} +
+ Ordinal {attempt.ordinal} +
{attempt.submittedAt} + {attempt.skill.code} +
+ {attempt.skill.name} +
+ {attempt.itemType} +
+ {attempt.timingProfile} +
+ {attempt.isCorrect ? 'Correct' : 'Incorrect'} +
+ Chosen label: {attempt.chosenLabel ?? 'null'} +
+ Misconception: {attempt.misconception ?? 'null'} +
+ Hints used: {attempt.hintsUsed} +
+ Wall-clock: {timing(attempt.elapsedMs)} +
+ Engaged: {timing(attempt.engagedMs)} +
+ Quality: {attempt.timingQuality} +
+ ); +} + +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: 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

+
+
+
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)}
+
+
+
Distractor concentration
+
{formatMetric(computed.distractorConcentration)}
+
+
+
Winsorized-out count
+
{computed.winsorizedOutCount}
+
+
+ +

Derived values

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

Prerequisite check

+

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

+ +

Conflict adjustments

+
    + {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, + authorizationAction, + action, +}: { + evidence: EvidenceView; + grant: string; + grantExpiresAt: string; + renewal: string; + authorizationAction?: VisibleOpenAuthorizationAction; + action?: VisibleAcknowledgmentAction; +}) { + const { entry } = evidence; + const comparison = evidence.comparison; + const report = ( +
+ +

Evidence report · rank {entry.rank}

+ +

{entry.student.firstName}

+

{scopeLabel(entry.scope)}

+
+

Why this is ranked now

+

{entry.diagnosis}

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

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

Additional causes with complete evidence

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

No additional causes were retained for this report.

+ )} +
+
+ ); + return ( + + {report} + + ); +} + +export function EvidenceDesk({ + state, + authorizationAction, + action, +}: { + state: EvidenceDeskState; + authorizationAction?: VisibleOpenAuthorizationAction; + 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.

+

{refreshDescription(state.refresh, false)}

+
+ ); + 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.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

+

+ 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..6024d46 --- /dev/null +++ b/apps/web/app/board/lib/evidence-desk-state.ts @@ -0,0 +1,75 @@ +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: 'report-unavailable'; + board: Extract; + } + | { + 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 (!input.boardRunId && !input.triageEntryId) return { kind: 'board', board, open: null }; + 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. + 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 }; + 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/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..3e74d91 --- /dev/null +++ b/apps/web/app/board/rail-navigation.tsx @@ -0,0 +1,81 @@ +'use client'; + +import { useEffect, useId } from 'react'; + +const returnKey = 'huddle:evidence-desk:return'; +const railId = 'evidence-rail'; +export const evidenceDeskStateFocusId = 'evidence-desk-current-state'; + +export function railFocusId(studentId: string): string { + return `rail-student-${encodeURIComponent(studentId)}`; +} + +export function findRailFocusTarget( + root: Pick, + studentId: string +): HTMLElement | null { + return ( + root.getElementById(railFocusId(studentId)) ?? + root.querySelector(`#${railId} .rail-link`) ?? + root.getElementById(railId) ?? + root.getElementById(evidenceDeskStateFocusId) + ); +} + +export function RailLink({ + entryId, + studentId, + boardRunId, + selected, + children, +}: { + entryId: string; + studentId: string; + boardRunId: string; + selected: boolean; + children: React.ReactNode; +}) { + return ( + + sessionStorage.setItem(returnKey, JSON.stringify({ studentId, scrollY: window.scrollY })) + } + > + {children} + + ); +} + +export function RestoreRailPosition() { + useEffect(() => { + const raw = sessionStorage.getItem(returnKey); + if (!raw) return; + try { + const { studentId, scrollY } = JSON.parse(raw) as { studentId: string; scrollY: number }; + requestAnimationFrame(() => { + window.scrollTo({ top: scrollY, behavior: 'auto' }); + findRailFocusTarget(document, studentId)?.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..a2904b8 --- /dev/null +++ b/apps/web/app/board/visible-open-acknowledgment.tsx @@ -0,0 +1,260 @@ +'use client'; + +import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'; +import type { AcknowledgmentView, VisibleOpenAuthorization } from '@huddle/application'; + +type VisibleOpenCredentials = { + acknowledgmentGrant: string; + openingRenewalToken: string; +}; + +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}`; +} + +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, + 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 [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: 'reveal-unavailable' } + | { kind: 'acknowledgment-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(() => { + submitted.current = 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 ( + wasExposed || + revealDeadline !== null || + !documentVisible || + !reportVisible || + authorizationStarted.current + ) + return; + authorizationStarted.current = true; + if (!authorizationAction) { + setStatus({ kind: 'reveal-unavailable' }); + return; + } + 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 (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: 'acknowledgment-unavailable' }); + return; + } + setStatus({ kind: 'seen', acknowledgment: result }); + }) + .catch(() => setStatus({ kind: 'acknowledgment-unavailable' })); + }, [ + acknowledgmentGrant, + action, + documentVisible, + openingRenewalToken, + reportVisible, + shouldExpose, + ]); + + return ( +
+ {shouldExpose ? children : null} + {status.kind === 'seen' ? ( +

+ {acknowledgmentLabel(status.acknowledgment)} +

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

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

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

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

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

+ Marking this visible report as seen… +

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

+ Refreshing report authorization before showing evidence… +

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

+ Confirming report authorization before showing evidence… +

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

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

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

+ Waiting for this report to become visible… +

+ ) : null} +
+ ); +} diff --git a/apps/web/lib/acknowledgment-tokens.ts b/apps/web/lib/acknowledgment-tokens.ts new file mode 100644 index 0000000..5853c34 --- /dev/null +++ b/apps/web/lib/acknowledgment-tokens.ts @@ -0,0 +1,136 @@ +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 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 = decodeCanonical(encodedSignature); + if (!received) return false; + 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' }; + 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' }; + let claims: unknown; + try { + claims = JSON.parse(decodedPayload.toString('utf8')); + } catch { + return { kind: 'invalid' }; + } + if (!isClaims(claims) || claims.purpose !== purpose) return { kind: 'invalid' }; + const validForMs = new Date(claims.expiresAt).getTime() - clock.now().getTime(); + return validForMs <= 0 ? { kind: 'expired', claims } : { kind: 'valid', claims, validForMs }; + }, + }; +} + +/** 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..53e1cfc --- /dev/null +++ b/apps/web/test/acknowledgment-tokens.test.ts @@ -0,0 +1,107 @@ +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(verifiedGrant.validForMs).toBe(acknowledgmentTokenPolicy.grantTtlMs); + 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('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( + { 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-rendering.test.ts b/apps/web/test/evidence-desk-rendering.test.ts new file mode 100644 index 0000000..d8f63bf --- /dev/null +++ b/apps/web/test/evidence-desk-rendering.test.ts @@ -0,0 +1,232 @@ +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, + remainingRevealWindow, + revealPaintSafetyMs, + 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 · 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', + ]); + }); + + 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 · requested at 08:03 · request refresh-2'); + }); + + it('provides a focusable successful-empty restoration target', () => { + const state: EvidenceDeskState = { + kind: 'board', + board: { + kind: 'successful-empty', + boardRunId: 'run-empty', + 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: 'idle' }, + narration: { status: 'complete', degradedCount: 0 }, + }, + open: null, + }; + const html = renderToStaticMarkup(createElement(EvidenceDesk, { state })); + + expect(html).toContain('id="evidence-desk-current-state"'); + expect(html).toContain('tabindex="-1"'); + }); + + 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', + authorizationAction: vi.fn(), + 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'); + }); + + 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 new file mode 100644 index 0000000..42baafe --- /dev/null +++ b/apps/web/test/evidence-desk-state.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { BoardReader, BoardView, EvidenceReader, GuideAccess } from '@huddle/application'; +import { + createAcknowledgeVisibleOpenAction, + createAuthorizeVisibleOpenAction, +} from '../app/board/acknowledge-visible-open'; +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), + authorizeVisibleOpen: vi.fn(), + 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('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), + authorizeVisibleOpen: vi.fn(), + 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(), + 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', + })), + }; + const action = createAcknowledgeVisibleOpenAction({ + 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' }); + expect(evidenceReader.acknowledgeVisibleOpen).toHaveBeenCalledWith(access, { + 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 () => { + const boardReader: BoardReader = { readCurrent: vi.fn(async () => board()) }; + const evidenceReader: EvidenceReader = { + readEntry: vi.fn(), + openEntry: vi.fn(), + authorizeVisibleOpen: 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(); + }); + + 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/apps/web/test/rail-navigation.test.ts b/apps/web/test/rail-navigation.test.ts new file mode 100644 index 0000000..013a5e0 --- /dev/null +++ b/apps/web/test/rail-navigation.test.ts @@ -0,0 +1,63 @@ +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it, vi } from 'vitest'; +import { + evidenceDeskStateFocusId, + findRailFocusTarget, + RailLink, + railFocusId, +} from '../app/board/rail-navigation'; + +describe('Evidence Desk rail restoration', () => { + it('uses stable student identity across immutable board runs', () => { + const firstRun = renderToStaticMarkup( + createElement(RailLink, { + entryId: 'entry-a', + studentId: 'student-1', + boardRunId: 'run-a', + selected: false, + children: 'Avery', + }) + ); + const nextRun = renderToStaticMarkup( + createElement(RailLink, { + entryId: 'entry-b', + studentId: 'student-1', + boardRunId: 'run-b', + selected: false, + children: 'Avery', + }) + ); + + expect(firstRun).toContain(`id="${railFocusId('student-1')}"`); + expect(nextRun).toContain(`id="${railFocusId('student-1')}"`); + expect(firstRun).toContain('entry=entry-a'); + expect(nextRun).toContain('entry=entry-b'); + }); + + it('falls back to the current rail when the student is no longer ranked', () => { + const firstCurrentRow = {} as HTMLElement; + const currentRail = {} as HTMLElement; + const root = { + getElementById: vi.fn((id: string) => (id === 'evidence-rail' ? currentRail : null)), + querySelector: vi.fn(() => firstCurrentRow), + } as unknown as Pick; + + expect(findRailFocusTarget(root, 'student-missing')).toBe(firstCurrentRow); + + root.querySelector = vi.fn(() => null) as typeof root.querySelector; + expect(findRailFocusTarget(root, 'student-missing')).toBe(currentRail); + }); + + it('falls back to the focusable current state when the rail is empty', () => { + const currentState = {} as HTMLElement; + const root = { + getElementById: vi.fn((id: string) => + id === evidenceDeskStateFocusId ? currentState : null + ), + querySelector: vi.fn(() => null), + } as unknown as Pick; + + expect(findRailFocusTarget(root, 'student-missing')).toBe(currentState); + }); +}); 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..38a7b94 100644 --- a/apps/web/vitest.config.ts +++ b/apps/web/vitest.config.ts @@ -1,6 +1,11 @@ +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)) }, + }, 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..ebac932 --- /dev/null +++ b/packages/application/src/evidence-reader-implementation.ts @@ -0,0 +1,227 @@ +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; + acquireVisibleRevealLease( + access: GuideAccess, + input: { + boardDate: string; + boardRunId: string; + triageEntryId: string; + findingFingerprint: string; + openingId: string; + grantExpiresAt: string; + } + ): 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; validForMs: number } + | { 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 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 { + consumeVisibleOpening(input: { + grant: AcknowledgmentTokenClaims; + renewal: AcknowledgmentTokenClaims; + mintReplacementGrant(): AcknowledgmentTokenClaims | null; + }): 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 + ); +} + +function mintReplacementGrant( + dependencies: EvidenceReaderDependencies, + grant: AcknowledgmentTokenClaims +): AcknowledgmentTokenClaims | null { + const replacement = dependencies.tokens.issue({ + 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' + ); + return verifiedReplacement.kind === 'valid' ? verifiedReplacement.claims : null; +} + +/** + * 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 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' }; + } + const leaseAcquired = await dependencies.evidence.acquireVisibleRevealLease(access, { + boardDate: grant.claims.boardDate, + boardRunId: grant.claims.boardRunId, + triageEntryId: grant.claims.triageEntryId, + findingFingerprint: grant.claims.findingFingerprint, + openingId: grant.claims.openingId, + grantExpiresAt: grant.claims.expiresAt, + }); + if (!leaseAcquired) { + 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'); + 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 = 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..871c0da 100644 --- a/packages/application/src/evidence-reader.ts +++ b/packages/application/src/evidence-reader.ts @@ -6,8 +6,12 @@ export interface AcknowledgmentView { findingFingerprint: string; acknowledgedAt: string; } +export interface VisibleOpenAuthorization { + kind: 'authorized-visible-open'; + validForMs: number; +} export interface AdditionalEvidenceView { - signalId: string; + signalId: EvidenceBundle['additionalCauses'][number]['signalId']; cause: EvidenceBundle['finding']['dominantCause']; scope: EvidenceBundle['scope']; summary: EvidenceBundle['finding']; @@ -21,12 +25,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']; @@ -53,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/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..9d9d279 --- /dev/null +++ b/packages/application/test/evidence-reader-implementation.test.ts @@ -0,0 +1,297 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { + AcknowledgmentLedgerPort, + AcknowledgmentTokenClaims, + AcknowledgmentTokenService, + EvidenceReadPort, + 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', + signalId: 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: { + dominantCause: 'guessing', + severity: 0.8, + rawConfidence: 0.9, + finalConfidence: 0.9, + 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'), + validForMs: 300_000, + } + : { kind: 'valid', claims: claims(purpose), validForMs: 300_000 }) + ), + }; +} +function readerFixture( + options: { + tokens?: AcknowledgmentTokenService; + transactionExpiresGrant?: boolean; + revealLeaseAcquired?: boolean; + } = {} +) { + const findAuthorizedEvidence = vi.fn(async () => ({ + evidence, + boardDate: '2026-07-29', + })); + const acquireVisibleRevealLease = vi.fn( + async () => options.revealLeaseAcquired ?? true + ); + 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, acquireVisibleRevealLease }, + acknowledgments: { consumeVisibleOpening }, + tokens: options.tokens ?? tokens(), + newOpeningId: () => 'opening-1', + }), + findAuthorizedEvidence, + acquireVisibleRevealLease, + consumeVisibleOpening, + }; +} + +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'), + validForMs: 900_000, + }, + }), + }); + await fixture.reader.readEntry(access, { boardRunId: 'run-1', triageEntryId: 'entry-1' }); + expect(fixture.consumeVisibleOpening).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.consumeVisibleOpening).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, validForMs: 900_000 } }), + }); + await expect( + fixture.reader.acknowledgeVisibleOpen(access, { + acknowledgmentGrant: 'grant', + openingRenewalToken: 'renewal', + }) + ).resolves.toEqual({ kind: 'not-found' }); + 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 }); + expect(fixture.acquireVisibleRevealLease).toHaveBeenLastCalledWith(access, { + boardDate: '2026-07-29', + boardRunId: 'run-1', + triageEntryId: 'entry-1', + findingFingerprint: 'finding-1', + openingId: 'opening-1', + grantExpiresAt: '2026-07-29T08:05:00.000Z', + }); + 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('fails reveal closed when the atomic evidence lease is not acquired', async () => { + const superseded = readerFixture({ revealLeaseAcquired: false }); + await expect( + superseded.reader.authorizeVisibleOpen(access, { + acknowledgmentGrant: 'grant', + openingRenewalToken: 'renewal', + }) + ).resolves.toEqual({ kind: 'not-found' }); + expect(superseded.acquireVisibleRevealLease).toHaveBeenCalledTimes(1); + }); + + it('lets one ledger transaction renew a grant that expires after token verification', async () => { + const fixture = readerFixture({ + transactionExpiresGrant: true, + }); + await expect( + fixture.reader.acknowledgeVisibleOpen(access, { + acknowledgmentGrant: 'grant', + openingRenewalToken: 'renewal', + }) + ).resolves.toMatchObject({ findingFingerprint: 'finding-1' }); + expect(fixture.consumeVisibleOpening).toHaveBeenCalledTimes(1); + const transaction = fixture.consumeVisibleOpening.mock.calls[0]?.[0]; + expect(transaction).toEqual( + expect.objectContaining({ + 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 () => { + 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.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..83d0b0a 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; @@ -142,7 +155,7 @@ export interface AuthorizedEvidenceOpen { } export interface AdditionalEvidenceView { - signalId: string; + signalId: EvidenceBundle['additionalCauses'][number]['signalId']; cause: RootCause; scope: EvidenceScope; summary: EvidenceBundle['finding']; @@ -157,12 +170,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']; @@ -269,7 +279,9 @@ Variant B — **Evidence Desk** is the quick-demo shell: - mobile: the same URL-addressable queue and detail states shown one at a time; - `/board?entry=&run=` is the selected-report state, so refresh, Back, and deep links are deterministic; -- returning from detail restores focus and scroll position to the selected row; +- returning from detail restores scroll position and resolves the same student in the current rail, + falling back to the current first row, rail, or focusable current-state message when that student + is no longer ranked; - the run selected by `BoardReader` remains evidence-readable even when the board is `stale`; unauthorized, cross-guide, missing, or superseded/unselected runs return the same `not-found` result and expose no student fact. @@ -293,9 +305,17 @@ 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 `authorizeVisibleOpen` with only the opaque +credentials. Through the evidence-read port, that boundary atomically acquires one idempotent reveal +lease keyed by the signed opening ID: a new lease verifies the still-current run, entry, finding +fingerprint, and grant expiry in the same operation, while a retry may reuse that exact unexpired +lease. A board promotion ordered before acquisition denies the reveal; one ordered after acquisition +does not revoke the already-authorized immutable report. 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. Operations/integration owns the concrete atomic composition; without that +port, the application cannot authorize reveal. 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 diff --git a/specs/001-huddle-triage-board/data-model.md b/specs/001-huddle-triage-board/data-model.md index 0984b9d..45101c4 100644 --- a/specs/001-huddle-triage-board/data-model.md +++ b/specs/001-huddle-triage-board/data-model.md @@ -651,11 +651,20 @@ Grant-free `EvidenceReader.readEntry` responses may be prefetched but never crea acknowledgment grant. Actual navigation performs a fresh authorized `openEntry` read before rendering and receives a signed five-minute grant bound to the authenticated user, application scope, board date, run, entry, finding fingerprint, expiry, nonce, and unique opening ID, plus a renewal capability -bound to the same opening. An expired opening result is refreshed before visibility rather than -rendered. After the detail is visibly open, the narrow server action submits the opaque grant and -renewal capability. The action re-verifies `GuideAccess`. If the grant expires after visibility or -in flight, the capability authorizes an atomic replacement grant for that same opening even if -refresh has superseded the run; it cannot authorize another report. +bound to the same opening. + +Before paint, the injected evidence-read capability atomically acquires an idempotent reveal lease +for that opening while verifying the selected run, entry, finding fingerprint, and grant expiry. +Supersession ordered before acquisition denies the reveal; acquisition ordered first preserves +access to the immutable report. The application fails closed without this capability. Its concrete +durable representation is intentionally left to Operations/integration rather than added to this +reference DDL; the executable contract lives in +[`contracts/application-interfaces.md`](./contracts/application-interfaces.md). + +After the detail is visibly open, the narrow server action submits the opaque grant and renewal +capability. The action re-verifies `GuideAccess`. If the grant expires after visibility or in flight, +the capability authorizes an atomic replacement grant for that same opening even if refresh has +superseded the run; it cannot authorize another report. Token validation, optional same-opening renewal, the idempotent `report_acknowledgement` upsert, and insertion into `report_acknowledgement_grant_use` are one transaction. The finding primary key keeps diff --git a/specs/001-huddle-triage-board/plan.md b/specs/001-huddle-triage-board/plan.md index abfa503..a66fa92 100644 --- a/specs/001-huddle-triage-board/plan.md +++ b/specs/001-huddle-triage-board/plan.md @@ -2,8 +2,10 @@ **Branch**: `001-huddle-triage-board` | **Date**: 2026-07-27 | **Spec**: [spec.md](./spec.md) -**Status**: In implementation — bounded shared executable foundation landed; Evidence Desk, -Operations pipelines, model runtime, deployment, and the full simulator/eval remain out of scope. +**Status**: In implementation — the bounded shared foundation plus the Evidence Desk UI and +framework-neutral visible-open boundary have landed. Operations composition, import/refresh +persistence, concrete board/acknowledgment storage, model runtime, deployment, and the full +simulator/eval remain out of scope. **Input**: Feature specification from `/specs/001-huddle-triage-board/spec.md` @@ -235,10 +237,13 @@ freshness. Merged PR #2 (`9dc16a5`) already establishes Supabase public Auth sign-in, server-side user verification, synthetic `guide_auth_scope`, server-only Postgres reads through `@huddle/db`, and revoked browser Data API privileges. The repository physically names the dedicated data-access -package `packages/db`; this delta uses that path rather than proposing a no-value rename. The current -table board, destructive date-keyed refresh, raw narration strings, and missing -import/evidence/acknowledgment workflows are implementation gaps, not alternate contracts. Tasks -below evolve those seams toward the approved Evidence Desk and immutable publication model. +package `packages/db`; this delta uses that path rather than proposing a no-value rename. The +Evidence Desk shell, route state, progressive evidence presentation, and framework-neutral +`EvidenceReader` visible-open boundary are now present. The board route intentionally has no +production `BoardReader`, evidence-read, reveal-lease, or acknowledgment-ledger composition yet and +therefore fails closed as unavailable rather than falling back to the legacy table path. Concrete +import, immutable refresh publication, and acknowledgment persistence remain Operations/integration +gaps tracked below. ### Future gates preserved diff --git a/specs/001-huddle-triage-board/quickstart.md b/specs/001-huddle-triage-board/quickstart.md index 8037e3b..8c414c2 100644 --- a/specs/001-huddle-triage-board/quickstart.md +++ b/specs/001-huddle-triage-board/quickstart.md @@ -1,13 +1,16 @@ # Quickstart: Huddle — Morning Triage Board -**Feature**: `001-huddle-triage-board` | **Status**: future implementation command contract - -This branch is documentation-only: these are implementation command contracts, not claims about this -worktree. Read-only inspection of main (merged PR #2) shows a partial Supabase -Auth/`packages/db` foundation, but the Evidence Desk, import, refresh preservation, automatic -acknowledgment, and degraded fallback remain future tasks. T002a wires every root script, T002b -parses this file and requires each documented script plus a successful noninteractive `--help`, and -T111/T136 run the completed validations. +**Feature**: `001-huddle-triage-board` | **Status**: mixed implementation and future command contract + +The worktree contains the Supabase Auth/`packages/db` foundation plus the bounded Evidence Desk UI +and framework-neutral visible-open boundary. The `/board` route deliberately remains unavailable +until Operations/integration supplies concrete `BoardReader`, evidence-read/reveal-lease, and atomic +acknowledgment-ledger composition; it does not invent a production persistence fallback. Import, +immutable refresh publication, concrete acknowledgment persistence, and the complete guide-flow +verification remain future tasks. Commands below describe the completed workflow contract unless +the root `package.json` already defines them. T002a wires every root script, T002b parses this file +and requires each documented script plus a successful noninteractive `--help`, and T111/T136 run +the completed validations. ## Prerequisites and setup @@ -167,14 +170,18 @@ Expected by case: stale head remains inspectable; superseded/cross-guide IDs reveal nothing. - **auto-ack**: prefetch is grant-free. Actual opening bypasses prefetched evidence and performs a fresh authorized read that returns a signed five-minute, one-use grant plus a capability bound to - that exact opening. An opening result that expires before visibility is refreshed. After visibility, + that exact opening. Once its placeholder is visible, an atomic reveal lease verifies the selected + run, entry, finding, and unexpired grant before evidence is painted; supersession before acquisition + denies the reveal, while supersession after acquisition does not revoke the authorized immutable + report. A result without the full post-round-trip safety window is refreshed. After visibility, grant expiry or in-flight expiry atomically claims the expired source nonce and creates at most one replacement for the same opening, including after refresh supersession, and can never authorize another report. Sequential and concurrent losing renewal attempts return the stored acknowledgment with zero additional writes. Opening after a prefetch older than the TTL still acknowledges. List/prefetch/tampered/failed/unauthorized paths write zero; each consumed initial, renewed, and reopen nonce appears once in the replay ledger; replays/reopens are idempotent; and changed evidence - requires a new acknowledgment. + requires a new acknowledgment. The executable boundary is owned by + [`contracts/application-interfaces.md`](./contracts/application-interfaces.md). - **degraded-narration**: every cause and model failure retains membership/cause/confidence/rank, renders a non-empty grounded cause-specific fallback, and surfaces exact degraded reason. - **browser-boundary**: production client output contains only public Auth configuration and no diff --git a/specs/001-huddle-triage-board/research.md b/specs/001-huddle-triage-board/research.md index 8a38372..4a7c303 100644 --- a/specs/001-huddle-triage-board/research.md +++ b/specs/001-huddle-triage-board/research.md @@ -411,22 +411,23 @@ physical `packages/db`/`@huddle/db` package remains the dedicated data-access bo would add churn without strengthening the exclusivity gate. Next.js Server Components perform reads. Narrow authenticated server actions perform manual refresh, import validation/commit, and report-open acknowledgment; grant-free `EvidenceReader.readEntry` may support prefetch, while a fresh authorized -`openEntry` result supplies the short-lived one-use grant and same-opening renewal capability -submitted after visibility. A protected internal job handler accepts scheduled dispatch; no public -product API or generic repository layer is added. +`openEntry` result supplies the short-lived one-use grant and same-opening renewal capability. The +visible placeholder calls the storage-neutral `authorizeVisibleOpen` boundary before paint, and the +same credentials are submitted for acknowledgment only after visibility. A protected internal job +handler accepts scheduled dispatch; no public product API or generic repository layer is added. Variant B — Evidence Desk is the quick-demo shell. On desktop it is a ranked rail plus progressive evidence workspace; on mobile the same URL-addressable queue/detail states are mutually exclusive. Prefetch reads are side-effect free and grant-free. When navigation actually opens a report, the adapter bypasses prefetched evidence and performs a fresh authorized `openEntry` read, which returns evidence plus a signed five-minute, one-use grant bound to the exact user, scope, run, entry, and -finding, plus an opaque capability bound to the unique authorized opening. An expired opening result -is refreshed before rendering. After that report is visibly open, an idempotent server action submits -the grant and renewal capability. Expiry after visibility or in flight atomically renews only for -that same opening, even if refresh superseded the run. Renewal uniquely claims the expired source -nonce and stores one source-to-replacement mapping; sequential or concurrent losing attempts return -the stored acknowledgment without additional writes. Replay prevention lives in the separate -per-grant nonce ledger so reopens never rewrite the immutable first-open row. +finding, plus an opaque capability bound to the unique authorized opening. Before paint, an injected +atomic reveal lease verifies that opening against the selected run and expiry; supersession before +acquisition denies the reveal, while acquisition first preserves access to the immutable report. +After visibility, the idempotent acknowledgment boundary may atomically renew only that same opening. +The executable ordering, round-trip safety window, renewal, and replay contract is owned by +[`contracts/application-interfaces.md`](./contracts/application-interfaces.md); Operations/integration +owns its concrete atomic persistence. Nightly and manual refresh create the same constrained request shape. DB Cron checks Chicago-local eligibility every fifteen minutes from an app-owned synthetic scope; a server-only Edge dispatcher diff --git a/specs/001-huddle-triage-board/spec.md b/specs/001-huddle-triage-board/spec.md index 694880f..4c10f55 100644 --- a/specs/001-huddle-triage-board/spec.md +++ b/specs/001-huddle-triage-board/spec.md @@ -278,8 +278,11 @@ denied. existing request state and cannot publish a partial or older snapshot. - **Report is prefetched but not viewed**: prefetch creates and carries no acknowledgment grant, and no acknowledgment is written. Opening the report after more than the grant TTL performs a fresh - authorized opening read, renders only that result, and acknowledges it; an opening result that - expires before visibility is refreshed rather than rendered. + authorized opening read. When its placeholder becomes visible, the server atomically verifies the + selected run, entry, finding fingerprint, and grant expiry before authorizing the reveal. A + supersession ordered before that authorization exposes no report; one ordered after it does not + revoke the already-authorized immutable report. A grant without the required post-round-trip + pre-paint window is refreshed rather than rendered. - **Acknowledgment grant expires after visibility or in flight**: the server renews atomically against the same authorized opening, even after refresh supersession, and cannot apply the renewal to another guide, report, finding, or opening. It uniquely maps the expired source nonce to one @@ -463,15 +466,20 @@ denied. report is visibly opened. Prefetch MUST create and carry no acknowledgment grant. Actual opening MUST bypass prefetched evidence and perform a fresh authorized read that returns evidence plus a short-lived one-use acknowledgment grant and renewal capability bound to the user, scope, run, - entry, finding, and unique opening. An opening result that expires before visibility MUST be - refreshed rather than rendered. The mutation MUST accept only that grant and capability after - visibility. If the grant expires after visibility or in flight, it MUST atomically renew against - the same opening, remain valid across refresh supersession, and MUST NOT authorize another report. - Each expired source nonce MUST map to at most one replacement; sequential or concurrent losing - renewal attempts MUST return the stored acknowledgment without additional writes. The first-open - timestamp is immutable; every consumed initial, renewed, and reopen nonce MUST be recorded - separately for replay prevention; retries/reopens are idempotent; and listing, prefetch, invalid - grants, failed lookup, and unauthorized access MUST NOT acknowledge. + entry, finding, and unique opening. Visibility MUST trigger an atomic reveal authorization that + validates the still-selected run, entry, finding fingerprint, and unexpired grant before evidence + is painted; an idempotent retry MAY reuse only the same unexpired opening lease. Supersession before + lease acquisition MUST deny the reveal, while supersession after acquisition MUST NOT revoke that + immutable authorized report. The client MUST subtract the authorization round trip and reserve a + pre-paint safety window; otherwise it MUST refresh the opening rather than render it. The + acknowledgment mutation MUST accept only that grant and capability after visibility. If the grant + expires after visibility or in flight, it MUST atomically renew against the same opening, remain + valid across refresh supersession, and MUST NOT authorize another report. Each expired source nonce + MUST map to at most one replacement; sequential or concurrent losing renewal attempts MUST return + the stored acknowledgment without additional writes. The first-open timestamp is immutable; every + consumed initial, renewed, and reopen nonce MUST be recorded separately for replay prevention; + retries/reopens are idempotent; and listing, prefetch, invalid grants, failed lookup, and + unauthorized access MUST NOT acknowledge. - **FR-056**: Nightly and manual refresh MUST use the same `BoardCompiler` contract and expose `idle`, `queued`, `running`, `succeeded`, and `failed` states. A new board becomes visible only after complete atomic publication; queued/running/failed attempts preserve the last successful @@ -730,8 +738,9 @@ The current captain direction additionally fixes the implementation path without 1. **Shell**: Variant B — Evidence Desk, with progressive exact evidence and route-addressable master/detail behavior. -2. **Acknowledgment**: automatically persist first open after an authorized report is visibly open; - no separate guide action and no prefetch/list side effect. +2. **Acknowledgment**: authorize reveal atomically at the visible placeholder, then automatically + persist first open after the report is shown; no separate guide action and no prefetch/list side + effect. 3. **Narration fallback**: mandatory deterministic cause-specific catalog fallback, visibly degraded, never blocking or reranking. 4. **Import**: guide-facing bounded synthetic CSV validation/commit in the product. diff --git a/specs/001-huddle-triage-board/tasks.md b/specs/001-huddle-triage-board/tasks.md index 254a11a..443b41f 100644 --- a/specs/001-huddle-triage-board/tasks.md +++ b/specs/001-huddle-triage-board/tasks.md @@ -6,8 +6,10 @@ description: "Task list for Huddle — Morning Triage Board" **Input**: all artifacts in `/specs/001-huddle-triage-board/` -**Tests**: mandatory. Checked items have landed; every unchecked task remains future application -work. The shared foundation does not claim that the full constitution gates or product flows exist. +**Tests**: mandatory. Checked items have landed end to end. An unchecked task may now have a bounded +UI or framework-neutral seam, but its required Operations composition or acceptance coverage remains +future work. The shared foundation does not claim that the full constitution gates or product flows +exist. **Revision 4 (focused quick-demo delta, 2026-07-28 decisions)**: Revision 3 remains authoritative for diagnosis/evaluation. This delta aligns the task graph to current `packages/db`/Supabase foundations @@ -348,18 +350,21 @@ bounds → ingest/rules. Full simulator tune/report data cannot generate calibra desktop master-detail, mobile queue/detail, URL deep link, three progressive evidence levels, exact attempts/sessions, and every additional cause's own prerequisite check/conflicts/computed/ derived evidence; no horizontal overflow and Back focus/scroll restore. -- [ ] T129 **After T121** forward migration plus grant-free `EvidenceReader.readEntry`, fresh authorized - `EvidenceReader.openEntry`, its signed five-minute one-use grant, and - opening-bound renewal capability, `EvidenceReader.acknowledgeVisibleOpen`, and separate - `report_acknowledgement_grant_use` replay ledger. Prefetch never calls `openEntry` or receives a - grant; the actual opening adapter bypasses prefetched evidence, refreshes an expired opening result - before render, and submits the grant plus renewal capability only after visibility. The - acknowledgment action re-verifies `GuideAccess`; expiry after visibility or in flight atomically +- [ ] T129 **After T121** compose the existing framework-neutral `EvidenceReader` boundary with a + forward migration and concrete guide-scoped storage: grant-free `readEntry`, fresh authorized + `openEntry`, signed five-minute one-use grant, opening-bound renewal, `authorizeVisibleOpen`, + `acknowledgeVisibleOpen`, and a separate `report_acknowledgement_grant_use` replay ledger. Prefetch + never calls `openEntry` or receives a grant. At the visible placeholder, one atomic idempotent + reveal lease verifies the selected run, entry, finding, and grant expiry before paint; supersession + before acquisition denies reveal, acquisition first preserves the immutable report, and insufficient + post-round-trip safety causes a fresh opening read. The acknowledgment action then submits only the + opaque credentials and re-verifies `GuideAccess`; expiry after visibility or in flight atomically claims the expired source nonce and records one replacement for that same opening, including after refresh supersession. The transaction idempotently inserts the exact `(application_scope, board_date, finding_fingerprint)` first open and records every consumed initial, renewed, or reopen nonce without rewriting first open; losing renewal claims return the stored acknowledgment without - additional writes. + additional writes. Operations/integration owns this concrete composition; no process-local + production fallback is permitted. - [ ] T130 [P] Acknowledgment acceptance tests prove one write for every visible authorized open, including the selected displayed-stale head, and zero for board list, link prefetch, missing/superseded-unselected/cross-guide/unauthorized reads. They cover refresh superseding a