Repository navigation
Description
If the gateway restarts (a deploy, a crash, or docker compose up pulling a new image) while a patient is filling in an instrument, the patient can no longer submit and is given no way to recover without losing their answers. The human-verification set is in memory by design, and apps/gateway/AGENTS.md says "a restart just makes the patient re-verify". But the page never offers re-verification. The Cap widget is rendered only as beforeBegin on the overview step, and Assignment's verified state stays true on the client. After the restart, every PATCH /api/assignments/:id is refused with 403 "Assignment has not passed human verification". The axios interceptor turns that into a generic "HTTP Request Failed" toast titled "403", and pressing Submit again fails the same way. Reloading is the only way to see the widget again, and it discards the form's answers. For an interactive instrument, the task the patient has just finished has to be done again from the start.
Where
apps/gateway/src/routers/api.router.ts:102-104:
if (!isAssignmentVerified(id)) {
throw new HttpException(403, 'Assignment has not passed human verification');
}apps/gateway/src/Root.tsx:40 and :74-91. verified is never reset, the PATCH failure is not handled, and the widget lives only on the overview step:
const [verified, setVerified] = useState(false);
// ...
await axios.patch(`/api/assignments/${id}`, updateData, {
headers: {
Authorization: `Bearer ${token}`
}
});
// ...
<InstrumentRenderer
disableSummaryActions
beforeBegin={<CapWidget onSolve={(token) => void handleSolve(token)} />}Reproduce
- Open an assignment link, verify, click Begin, and answer the form.
- Restart the gateway process (for example
docker compose restart gateway, or stop and startpnpm devfor the gateway). - Click Submit, then click it again.
Actual: each click shows "HTTP Request Failed" / "403", and nothing on the page explains it or offers the verification again. The only recovery is to reload, which clears every answer and returns to the overview.
Expected: the page tells the patient (translated) that it needs to confirm they are human again, shows the widget in place without leaving the content step, and resubmits the same answers once the challenge is solved.
Tests
apps/gateway/src/__tests__/Root.test.tsx:it('should ask the patient to verify again when a submission is refused for lack of verification, and resubmit the same answers once they do').apps/gateway/src/routers/__tests__/api.router.test.ts:it('should mark a refusal for missing verification distinctly, so the page can tell it from other 403s').- e2e,
testing/src/specs/gateway-assignment.spec.ts: after Begin, usegatewayPage.routeto answer the firstPATCHwith the gateway's verification-required 403. Assert that the re-verification prompt appears and that the answers are still on the form.
Suggested fix
Have the PATCH return a machine-readable reason for this case, for example { code: 'VERIFICATION_REQUIRED', statusCode: 403 }. In Assignment, catch that response: set verified to false, keep the pending submission, and render CapWidget in a dialog with a translated explanation. After handleSolve succeeds, replay the kept submission. Leave the global toast for other errors.
Activity
joshunrau commentedon Oct 26, 2026
Still reproduces on
main(d86740e).The replay has to happen inside the pending
handleSubmitcallReplaying the kept submission later from
handleSolvewould save the answers but leave the patient on the content step. The renderers move on only whenonSubmitresolves (ScalarInstrumentRenderer.tsx:82-88,SeriesInstrumentRenderer.tsx:99-118), and nothing on the page can send the result again.InteractiveContentforwards the instrument'sdonethrough one voided call (packages/react-core/src/components/InteractiveContent/InteractiveContent.tsx:76-84). A form withresetButtonis already empty by the time the 403 arrives (#1673). So onVERIFICATION_REQUIRED,handleSubmitshould open the dialog, await a promise thathandleSolveresolves once/api/auth/verifysucceeds, re-send the sameupdateData, and return only when that succeeds.Gotchas:
apps/gateway/src/routers/api.router.ts:102-104is raised before the row is read, so re-sending is safe. A timeout or a 5xx gives no such guarantee, and a re-sent SERIES PATCH appends a second ciphertext (api.router.ts:129-138), the corruption in Submitting an instrument again before the first save completes creates a duplicate record #1668 and Gateway appends series answers without checking which item they answer, so a second tab locks the patient out or makes the series unimportable #1750.!verifiedbranch (apps/gateway/src/Root.tsx:54-57) through the same wait. It returns normally today, so onceverifiedcan go back tofalse, the renderer would show the summary with nothing saved.apps/gateway/src/components/Cap.tsx:25-30registers thesolvelistener with[]deps, so the widget always calls theonSolvefrom its first render. Give that effect[handleSolve], or keep the pending resolver in a ref, not in state.FormContentvoids the promise and libui clearsisSubmittingat once (Submitting an instrument again before the first save completes creates a duplicate record #1668). A second Submit would start a second wait.For the toast, copying
apps/web'smeta.disableDefaultErrorNotification(apps/web/src/services/axios.ts:18,:163-165) would leaveRoot.tsxto raise the generic toast itself for every other failure. Let the caller name the codes it handles instead, so the gateway interceptor (apps/gateway/src/services/axios.ts:16-43) still reports everything else. #1750 and #1703 add refusalsRoot.tsxmust also tell apart, so build thecode(onHttpException, inerrorHandlerMiddleware's body, in that option) once, in whichever lands first.apps/gateway/AGENTS.md:54("ThrowHttpException(status, message)") changes in the same PR.Alternative: make verification survive a restart
/api/auth/verifycould return an HMAC of the assignment id keyed onconfig.apiKey, using thecryptothatsrc/utils/auth.tsalready imports (no new dependency). The page sends it with each PATCH, and the handler checks it instead of callingisAssignmentVerified./verifycan look up the row first and refuse an unknown or completed id. Today it stores any id that passes proof of work (cap.router.ts:58-62), and nothing evicts an id that is never completed, ascap.router.ts:15-19notes. That growth goes away with the set.apps/gateway/AGENTS.md:59-61and:76-79. It also dropsclearAssignmentVerificationon COMPLETE (api.router.ts:151-153), which today is what refuses a second submit after completion.Recommendation: take this route. It is less code than the dialog, and it removes the failure instead of handling it.
Ordering
Either way, #1750's
completedAtguard lands first or in the same PR. With the dialog, a second tab re-verifies after completion, and its SCALAR PATCH overwrites the completedencryptedDatabefore the sync. With signing, a signature issued before COMPLETE stays valid after it. For the dialog, land #1715 first: it translatesRoot.tsx:49and:55and the Cap labels the dialog reuses. #1535 is the same problem inapps/web.Tests, beyond those in the issue
Root.test.tsx:onSubmitstays pending until the dialog's widget is solved, thenpatchis called again with the same body. A 403 without the code is not replayed. The mockedInstrumentRendereralways rendersbeforeBegin, andsolveChallengetakes the firstcap-widget, so query inside the dialog.axios.test.tsanderror-handler.middleware.test.ts: a named code raises no toast while other errors still do, and the body carries the code.gatewayPage.route(..., { times: 1 }), solve the dialog's widget, and assert the summary heading appears without answering again. That is two proofs of work, and the first alone is allowed 120 s (testing/src/specs/gateway-assignment.spec.ts:88), so the test needs more than the describe's 180 s (:65).api.router.test.tsaccepts a valid signature and refuses a forged one,cap.router.test.tsrefuses an unknown or completed id, andRoot.test.tsxsends the signature/verifyreturned. The e2e sends a tampered signature throughapiRequestContext, like the malformed-submission test, and expects 403.Triage note written with Claude Code. Code references point at
main@d86740e.