Skip to content

feat(web): add route-addressable Evidence Desk - #5

Merged
alexdancer merged 6 commits into
mainfrom
fm/huddle-quick-demo-guide-workflow-a1
Jul 29, 2026
Merged

feat(web): add route-addressable Evidence Desk#5
alexdancer merged 6 commits into
mainfrom
fm/huddle-quick-demo-guide-workflow-a1

Conversation

@alexdancer

Copy link
Copy Markdown
Owner

Intent

Build Huddle's Variant B Evidence Desk for the bounded synthetic quick-demo: a route-addressable ranked rail and evidence workspace with truthful loading, denied, unavailable, not-built, empty, stale, partial-narration, and refresh states; deterministic priority bands separate from evidence confidence; progressive complete dominant and additional-cause evidence; recovery on pre-open supersession; keyboard/mobile/focus restoration behavior; and deterministic narration provenance. Implement the framework- and database-neutral EvidenceReader visible-open boundary using injected ports, a server-only signed five-minute grant and bounded fifteen-minute same-opening renewal with key rotation/constant-time verification, and an injected atomic nonce-ledger contract. Keep the environment one synthetic guide only; do not add migrations, concrete DB acknowledgement storage, import/refresh implementation, notifications, public APIs, real data, parent/coach portals, or model calls. Operations/integration owns concrete BoardReader and acknowledgment persistence composition, so this branch must fail safely without inventing a production persistence fallback until those ports land.

What Changed

  • Add a route-addressable Evidence Desk with a ranked rail, progressive dominant and additional-cause evidence, truthful board states, narration provenance, and focus restoration.
  • Introduce the framework-neutral visible-open workflow with signed five-minute grants, bounded renewal, key rotation, reveal leases, and injected atomic acknowledgment persistence ports.
  • Add focused application and web coverage for evidence rendering, routing state, rail navigation, token handling, and visible-open behavior, and align the feature documentation and contracts.

Risk Assessment

✅ Low: The bounded remediation durably closes the reveal-supersession race through an injected atomic lease contract and provides a focusable successful-empty fallback without adding forbidden concrete persistence or broader product scope.

Testing

No baseline command results were supplied; focused automated tests, a real /board fail-safe request, scope inspection, and a self-contained production-component HTML render all passed. A screenshot was not possible because no local or connected browser backend was available, so reviewer-visible responsive HTML was captured instead.

Evidence: Self-contained responsive Evidence Desk render using the production component and CSS
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Huddle Evidence Desk — focused test evidence</title>
    <script type="module">(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const l of document.querySelectorAll('link[rel="modulepreload"]'))r(l);new MutationObserver(l=>{for(const i of l)if(i.type==="childList")for(const u of i.addedNodes)u.tagName==="LINK"&&u.rel==="modulepreload"&&r(u)}).observe(document,{childList:!0,subtree:!0});function n(l){const i={};return l.integrity&&(i.integrity=l.integrity),l.referrerPolicy&&(i.referrerPolicy=l.referrerPolicy),l.crossOrigin==="use-credentials"?i.credentials="include":l.crossOrigin==="anonymous"?i.credentials="omit":i.credentials="same-origin",i}function r(l){if(l.ep)return;l.ep=!0;const i=n(l);fetch(l.href,i)}})();function pc(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var Zo={exports:{}},T={};/**
 * @license React
 * react.production.min.js
 *
 * Copyright (c) Facebook, Inc. and its affiliates.
 *
 * This source code is licensed under the MIT license found in the
 * LICENSE file in the root directory of this source tree.
 */var qn=Symbol.for("react.element"),mc=Symbol.for("react.portal"),vc=Symbol.for("react.fragment"),hc=Symbol.for("react.strict_mode"),gc=Symbol.for("react.profiler"),yc=Symbol.for("react.provider"),wc=Symbol.for("react.context"),kc=Symbol.for("react.forward_ref"),Ec=Symbol.for("react.suspense"),Sc=Symbol.for("react.memo"),Rc=Symbol.for("react.lazy"),ju=Symbol.iterator;function Cc(e){return e===null||typeof e!="object"?null:(e=ju&&e[ju]||e["@@iterator"],typeof e=="function"?e:null)}var Jo={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},bo=Object.assign,ea={};function un(e,t,n){this.props=e,this.context=t,this.refs=ea,this.updater=n||Jo}un.prototype.isReactComponent={};un.prototype.setState=function(e,t){if(typeof e!="object"&&typeof e!="function"&&e!=null)throw Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,e,t,"setState")};un.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,"forceUpdate")};function ta(){}ta.prototype=un.prototype;function Bi(e,t,n){this.props=e,this.context=t,this.refs=ea,this.updater=n||Jo}var Wi=Bi.prototype=new ta;Wi.constructor=Bi;bo(Wi,un.prototype);Wi.isPureReactComponent=!0;var Au=Array.isArray,na=Object.prototype.hasOwnProperty,Hi={current:null},ra={key:!0,ref:!0,__self:!0,__source:!0};function la(e,t,n){var r,l={},i=null,u=null;if(t!=null)for(r in t.ref!==void 0&&(u=t.ref),t.key!==void 0&&(i=""+t.key),t)na.call(t,r)&&!ra.hasOwnProperty(r)&&(l[r]=t[r]);var o=arguments.length-2;if(o===1)l.children=n;else if(1<o){for(var a=Array(o),c=0;c<o;c++)a[c]=arguments[c+2];l.children=a}if(e&&e.defaultProps)for(r in o=e.defaultProps,o)l[r]===void 0&&(l[r]=o[r]);return{$$typeof:qn,type:e,key:i,ref:u,props:l,_owner:Hi.current}}function Nc(e,t){return{$$typeof:qn,type:e.type,key:t,ref:e.ref,props:e.props,_owner:e._owner}}function Qi(e){return typeof e=="object"&&e!==null&&e.$$typeof===qn}function _c(e){var t={"=":"=0",":":"=2"};return"$"+e.replace(/[=:]/g,function(n){return t[n]})}var Uu=/\/+/g;function El(e,t){return typeof e=="object"&&e!==null&&e.key!=null?_c(""+e.key):t.toString(36)}function wr(e,t,n,r,l){var i=typeof e;(i==="undefined"||i==="boolean")&&(e=null);var u=!1;if(e===null)u=!0;else switch(i){case"string":case"number":u=!0;break;case"object":switch(e.$$typeof){case qn:case mc:u=!0}}if(u)return u=e,l=l(u),e=r===""?"."+El(u,0):r,Au(l)?(n="",e!=null&&(n=e.replace(Uu,"$&/")+"/"),wr(l,t,n,"",function(c){return c})):l!=null&&(Qi(l)&&(l=Nc(l,n+(!l.key||u&&u.key===l.key?"":(""+l.key).replace(Uu,"$&/")+"/")+e)),t.push(l)),1;if(u=0,r=r===""?".":r+":",Au(e))for(var o=0;o<e.length;o++){i=e[o];var a=r+El(i,o);u+=wr(i,t,n,a,l)}else if(a=Cc(e),typeof a=="function")for(e=a.call(e),o=0;!(i=e.next()).done;)i=i.value,a=r+El(i,o++),u+=wr(i,t,n,a,l);else if(i==="object")throw t=String(e),Error("Objects are not valid as a React child (found: "+(t==="[object Object]"?"object with keys {"+Object.keys(e).join(", ")+"}":t)+"). If you meant to render a collection of children, use an array instead.");return u}function nr(e,t,n){if(e==null)return e;var r=[],l=0;return wr(e,r,"","",function(i){return t.call(n,i,l++)}),r}function xc(e){if(e._status===-1){var t=e._result;t=t(),t.then(function(n){(e._status===0||e._status===-1)&&(e._status=1,e._result=n)},function(n){(e._status===0||e._status===-1)&&(e._status=2,e._result=n)}),e._status===-1&&(e._status=0,e._result=t)}if(e._status===1)return e._result.default;throw e._result}var oe={current:null},kr={transition:null},Pc={ReactCurrentDispatcher:oe,ReactCurrentBatchConfig:kr,ReactCurrentOwner:Hi};function ia(){throw Error("act(...) is not supported in production builds of React.")}T.Children={map:nr,forEach:function(e,t,n){nr(e,function(){t.apply(this,arguments)},n)},count:function(e){var t=0;return nr(e,function(){t++}),t},toArray:function(e){return nr(e,function(t){return t})||[]},only:function(e){if(!Qi(e))throw Error("React.Children.only expected to receive a single React element child.");return e}};T.Component=un;T.Fragment=vc;T.Profiler=gc;T.PureComponent=Bi;T.StrictMode=hc;T.Suspense=Ec;T.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED=Pc;T.act=ia;T.cloneElement=function(e,t,n){if(e==null)throw Error("React.cloneElement(...): The argument must be a React element, but you passed "+e+".");var r=bo({},e.props),l=e.key,i=e.ref,u=e._owner;if(t!=null){if(t.ref!==void 0&&(i=t.ref,u=Hi.current),t.key!==void 0&&(l=""+t.key),e.type&&e.type.defaultProps)var o=e.type.defaultProps;for(a in t)na.call(t,a)&&!ra.hasOwnProperty(a)&&(r[a]=t[a]===void 0&&o!==void 0?o[a]:t[a])}var a=arguments.length-2;if(a===1)r.children=n;else if(1<a){o=Array(a);for(var c=0;c<a;c++)o[c]=arguments[c+2];r.children=o}return{$$typeof:qn,type:e.type,key:l,ref:i,props:r,_owner:u}};T.createContext=function(e){return e={$$typeof:wc,_currentValue:e,_currentValue2:e,_threadCount:0,Provider:null,Consumer:null,_defaultValue:null,_globalName:null},e.Provider={$$typeof:yc,_context:e},e.Consumer=e};T.createElement=la;T.createFactory=function(e){var t=la.bind(null,e);return t.type=e,t};T.createRef=function(){return{current:null}};T.forwardRef=function(e){return{$$typeof:kc,render:e}};T.isValidElement=Qi;T.lazy=function(e){return{$$typeof:Rc,_payload:{_status:-1,_result:e},_init:xc}};T.memo=function(e,t){return{$$typeof:Sc,type:e,compare:t===void 0?null:t}};T.startTransition=function(e){var t=kr.transition;kr.transition={};try{e()}finally{kr.transition=t}};T.unstable_act=ia;T.useCallback=function(e,t){return oe.current.useCallback(e,t)};T.useContext=function(e){return oe.current.useContext(e)};T.useDebugValue=function(){};T.useDeferredValue=function(e){return oe.current.useDeferredValue(e)};T.useEffect=function(e,t){return oe.current.useEffect(e,t)};T.useId=function(){return oe.current.useId()};T.useImperativeHandle=function(e,t,n){return oe.current.useImperativeHandle(e,t,n)};T.useInsertionEffect=function(e,t){return oe.current.useInsertionEffect(e,t)};T.useLayoutEffect=function(e,t){return oe.current.useLayoutEffect(e,t)};T.useMemo=function(e,t){return oe.current.useMemo(e,t)};T.useReducer=function(e,t,n){return oe.current.useReducer(e,t,n)};T.useRef=function(e){return oe.current.useRef(e)};T.useState=function(e){return oe.current.useState(e)};T.useSyncExternalStore=function(e,t,n){return oe.current.useSyncExternalStore(e,t,n)};T.useTransition=function(){return oe.current.useTransition()};T.version="18.3.1";Zo.exports=T;var B=Zo.exports;const zc=pc(B);var ua={exports:{}},we={},oa={exports:{}},aa={};/**
 * @license React
 * scheduler.production.min.js
 *
 * Copyright (c) Facebook, Inc. and its affiliates.
 *
 * This source code is licensed under the MIT license found in the
 * LICENSE file in the root directory of this source tree.
 */(functi

... [156964 bytes truncated] ...

8:00:00.000Z",timezone:"America/Chicago",completedAt:"2026-07-29T08:00:00.000Z",inputReceiptSetFingerprint:"synthetic-receipts-v1",entries:[Vi,{triageEntryId:"entry-jordan",findingFingerprint:"finding-jordan-decay",student:{id:"synthetic-student-jordan",firstName:"Jordan"},rank:2,cause:"decay",scope:{kind:"skill",skill:{code:"5.3K",name:"Add and subtract fractions"}},severity:.57,finalConfidence:.83,diagnosis:"Jordan’s recent work has declined relative to their own prior pattern.",opener:"What feels different about this skill today?",narration:{mode:"catalog-selection",status:"complete",degradedReason:null,catalogVersion:"catalog-3",renderVersion:"renderer-8"},acknowledgedAt:"2026-07-29T08:02:10.000Z",additionalCauseCount:0}],refresh:{state:"failed",requestId:"refresh-42",completedAt:"2026-07-29T08:04:00.000Z",failureCode:"compile-timeout",preservedBoardRunId:"synthetic-run-2026-07-29"},narration:{status:"degraded",degradedCount:1}},open:{kind:"authorized-evidence-open",openingId:"opening-avery-1",acknowledgmentGrant:{token:"opaque-five-minute-grant",expiresAt:"2026-07-29T08:09:00.000Z"},openingRenewalToken:"opaque-fifteen-minute-renewal",evidence:{kind:"evidence",boardRunId:"synthetic-run-2026-07-29",signalId:47,entry:Vi,summary:{dominantCause:"guessing",severity:.81,rawConfidence:.92,finalConfidence:.5796,confidenceBreakdown:{timingMultiplier:.9,winsorizationMultiplier:.92,conflictMultiplier:.7},ruleId:"guessing.fast-wrong",ruleVersion:"3"},comparison:{computed:{attemptCount:4,wrongCount:3,medianWrongDurationMs:1501,personalCorrectBaselineMs:3200,personalSessionMeanBaselineMs:6400,distractorConcentration:.625,winsorizedOutCount:1},derived:{wrongOfLastN:{wrong:3,of:4},speedRatio:.4690625,consecutiveWrong:2,daysSinceFirstAttempt:6},prerequisiteCheck:{skillCode:"4.3E",skillName:"Represent equivalent fractions",masteryValue:.73,isKnown:!0,verdict:"adequate"},conflicts:[{family:"mastery",suggestedCause:"prerequisite_gap",ruleId:"prereq-v2"}],additionalCauses:[{signalId:48,cause:"prerequisite_gap",severity:.62,finalConfidence:.6384}],additionalEvidence:[Jf]},exact:{attempts:[{attemptId:91,activityId:"synthetic-activity-guessing-91",ordinal:4,skill:{code:"4.4A",name:"Add and subtract whole numbers"},itemType:"multiple_choice",timingProfile:"word_problem",submittedAt:"2026-07-29T07:31:22.125Z",isCorrect:!1,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"}]}}}},ep=async()=>({kind:"authorized-visible-open",validForMs:24e4}),tp=async()=>({findingFingerprint:Vi.findingFingerprint,acknowledgedAt:"2026-07-29T08:05:12.000Z"});oc(document.getElementById("root")).render(zc.createElement(Zf,{state:bf,authorizationAction:ep,action:tp}));
</script>
    <style>: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,.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}}
</style>
  </head>
  <body>
    <div id="root"></div>
  </body>
</html>
- Evidence: Actual /board denied-state response demonstrating fail-safe behavior without operations-owned ports (local file: /var/folders/nt/rdk7cjs538l8zphln24q2k900000gn/T/no-mistakes-evidence/01KYQNFBSKTDJ9XTF0BPSBG7NM/board-denied.html)

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

🔧 **Review** - 8 issues found → auto-fixed (4) ✅
  • 🚨 apps/web/app/board/evidence-desk.tsx:38 - The required “progressive complete dominant and additional-cause evidence” is incomplete and sometimes altered: durations are rounded from stored milliseconds, while attempt/session IDs, ordinal, timing profile, hints, misconception, confidence breakdown, and several computed/derived values are omitted. The same lossy disclosure is reused for additional causes; render exact stored or bundle-precomputed values and expose all causal support.
  • 🚨 apps/web/app/board/visible-open-acknowledgment.tsx:27 - The visible-open boundary equates React effect execution with visibility. A background tab can acknowledge an unseen report, and because the grant expiry is discarded before rendering, a response delayed beyond five minutes renders and renews an opening that should be refreshed before visibility. Gate evidence exposure and submission on actual visibility, retain expiresAt, and repeat openEntry when already expired.
  • 🚨 packages/application/src/evidence-reader-implementation.ts:192 - Grant expiry is classified before entering the ledger transaction. A grant verified just before expiry can expire in flight yet still use consumeVisibleGrant, so the required “bounded fifteen-minute same-opening renewal” and “injected atomic nonce-ledger contract” do not hold atomically. Move consume-versus-renew selection to one ledger operation that owns the transactional time/nonce transition.
  • 🚨 apps/web/app/board/evidence-desk.tsx:377 - The required truthful refresh states are not all represented: not-built ignores its retained refresh value, and queued/running are collapsed elsewhere. A first-build failure, queued build, and running build therefore show the same instruction. Render the concrete idle/queued/running/succeeded/failed state, including first-build failures.
  • 🚨 apps/web/app/board/evidence-desk.tsx:60 - The required truthful partial-narration state and deterministic narration provenance are incomplete. Freshness ignores board.narration.status/degradedCount, while the report provenance omits renderVersion; a partially narrated board appears simply ready and renderer versions under one catalog are indistinguishable.
  • ⚠️ apps/web/app/board/visible-open-acknowledgment.tsx:31 - The action result’s immutable acknowledgedAt is discarded and every success displays “Seen just now.” Reopening an unchanged finding or renewing after supersession therefore misreports an older first-open timestamp; preserve and display the returned timestamp.
  • ⚠️ apps/web/app/board/lib/evidence-desk-state.ts:64 - Every openEntry not-found result is labeled board-updated after rereading, even if the same run and entry remain current. A missing evidence record consequently produces a false supersession message; compare the reread head and use a truthful unavailable state when it did not change.
  • ⚠️ apps/web/lib/acknowledgment-tokens.ts:30 - Signature decoding is permissive: Node’s base64url decoder ignores invalid characters, so noncanonical variants of a valid signature can verify as the same bytes. Reject non-base64url syntax and noncanonical encodings before the constant-time comparison.

🔧 Fix: Fix Evidence Desk truthfulness and visible-open atomicity
4 issues (2 errors, 2 warnings) still open:

  • 🚨 apps/web/app/board/lib/evidence-desk-state.ts:48 - The required “recovery on pre-open supersession” is bypassed when the new head is successful-empty. If the URL selects run A/entry E and refresh promotes empty run B, this early return shows the ordinary empty state before comparing the selected IDs, omitting the non-disclosing board-updated recovery. Classify selection mismatch before returning the empty board.
  • 🚨 apps/web/app/board/visible-open-acknowledgment.tsx:91 - The signed five-minute visible-open invariant still has a TOCTOU path: expiry is checked while only the placeholder is visible, then setExposed(true) renders asynchronously. The grant can expire—or already be server-expired because the client clock is slow—before the evidence is painted, after which it is handled as post-visibility renewal. Make the reveal authorization server-authoritative or recheck before paint with a safe expiry window.
  • ⚠️ apps/web/app/board/evidence-desk.tsx:60 - The truthful refresh presentation invents a start time: RefreshView provides requestedAt, but the running state says “running since” that timestamp. A request queued at 08:00 and claimed at 08:05 is therefore reported as running since 08:00. Label it as requested time or add an authoritative start timestamp to the owning contract.
  • ⚠️ apps/web/app/board/lib/evidence-desk-state.ts:51 - A URL containing only run or only entry is labeled board-updated even when the board head is unchanged. Treat incomplete selection parameters as an invalid/unavailable route state rather than claiming supersession.

🔧 Fix: Fix supersession routing and pre-paint reveal authorization
3 issues (2 errors, 1 warning) still open:

  • 🚨 packages/application/src/evidence-reader-implementation.ts:171 - The required “recovery on pre-open supersession” remains bypassable after server render. If openEntry authorizes run A, run B becomes current before the hidden/off-screen report becomes visible, authorizeVisibleOpen verifies only the signed credentials and exposes A without rechecking that it remains evidence-readable. Revalidate the signed run/entry through EvidenceReadPort at this actual visibility boundary and compare the finding fingerprint before authorizing reveal.
  • 🚨 apps/web/app/board/rail-navigation.tsx:26 - The required “keyboard/mobile/focus restoration behavior” fails across refresh supersession because the saved focus target uses the run-scoped triageEntryId. After run B replaces run A, returning searches the new rail for A’s entry ID; even if the same student remains ranked, the immutable run has a different entry ID, so focus is not restored. Persist the stable student identity and resolve its current row, with a current-rail fallback when it disappears.
  • ⚠️ apps/web/app/board/rail-navigation.tsx:39 - Malformed or stale JSON in the mutable sessionStorage entry throws from the mount effect because parsing has finally but no catch, potentially breaking the rail instead of merely abandoning restoration. Catch parse errors, validate focusId and scrollY, and retain unconditional key cleanup.

🔧 Fix: Close reveal supersession and restore stable rail focus
2 errors still open:

  • 🚨 packages/application/src/evidence-reader-implementation.ts:194 - The durable “recovery on pre-open supersession” fix still has a narrower TOCTOU path: run A can pass this visible-reveal lookup, then run B can be promoted before the response reaches the client and A is painted. A was therefore superseded before visibility but is still revealed. The evidence-selection owner needs an atomic reveal lease or equivalent semantic handoff; another point-in-time lookup cannot close this interval.
  • 🚨 apps/web/app/board/rail-navigation.tsx:16 - The required focus fallback still returns no target when refresh promotes a successful-empty board. Returning from run A mounts RestoreRailPosition, but the empty branch renders neither a row nor #evidence-rail, so findRailFocusTarget returns null and focus is not restored. Provide a focusable current-state fallback such as the successful-empty heading or workspace container.

🔧 Fix: Add atomic reveal lease and empty-state focus fallback
✅ Re-checked - no issues remain.

✅ **Test** - passed

✅ No issues found.

  • npx vitest run apps/web/test/evidence-desk-rendering.test.ts apps/web/test/evidence-desk-state.test.ts apps/web/test/rail-navigation.test.ts apps/web/test/acknowledgment-tokens.test.ts packages/application/test/evidence-reader-implementation.test.ts apps/web/test/board-without-narrator.test.ts
  • npm run dev -w apps/web -- -p 4180 followed by curl --fail-with-body http://127.0.0.1:4180/board to capture the real route’s non-disclosing denied state
  • vite build --config vite.config.mjs and node make-portable.mjs in the evidence directory to bundle the production EvidenceDesk component and exact CSS into self-contained responsive HTML
  • Verified the portable artifact contains ranked priority/confidence, stale and failed-refresh status, partial narration, route addressing, dominant/additional evidence, and visible-open surfaces; verified its inline JavaScript parses
  • git diff --name-only d1924922413d8fbf5bb1b2c4432922ce0ecbfee4..cfdf75a0bdb758c6523fa31399b2389b443b06cd scope audit confirmed no migrations, concrete DB persistence, ingest/refresh implementation, notifications, portals, public APIs, or narrator/model changes
  • Attempted Playwright, Chrome DevTools, and the connected browser for screenshots; no browser backend is installed or connected in this environment
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

@alexdancer
alexdancer merged commit 9808154 into main Jul 29, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant