Repository navigation
Description
When the gateway reports a completed remote assignment that has encryptedData but a null completedAt or symmetricKey, GatewaySynchronizer.handleAssignmentComplete logs an error and returns normally. sync() treats that as success and calls assignmentsService.updateStatusById(id, 'COMPLETE'). The local assignment is then shown to clinicians as complete, but no session or instrument record was created, and the remote assignment, which still holds the patient's encrypted responses, is never deleted or imported. Each later pass repeats the same log-and-mark-complete cycle. A missing encryptedData is handled differently: it throws, so the status stays untouched and the assignment is retried, which is what the comment in sync() describes. The current gateway writes all three fields together, so reaching this state needs a gateway defect, a version mismatch or edited gateway data. But when it is reached, the result is a submission that looks imported and is not.
Where
apps/api/src/gateway/gateway.synchronizer.ts:110-116:
if (!remoteAssignment.completedAt) {
this.loggingService.error(`Field 'completedAt' is null for assignment '${assignment.id}'`);
return;
} else if (!remoteAssignment.symmetricKey) {
this.loggingService.error(`Field 'symmetricKey' is null for assignment '${assignment.id}'`);
return;
}This is followed by apps/api/src/gateway/gateway.synchronizer.ts:89-94:
} else if (assignment.status === 'COMPLETE') {
await this.handleAssignmentComplete(assignment);
} ...
await this.assignmentsService.updateStatusById(assignment.id, assignment.status);Compare apps/api/src/gateway/gateway.synchronizer.ts:105-107, which throws for missing encryptedData.
Reproduce
Have gatewayService.fetchRemoteAssignments return a remote assignment with status: 'COMPLETE', valid encryptedData, and completedAt: null (or symmetricKey: null). Then run sync().
- Actual: an error is logged,
sessionsService.createis not called,gatewayService.deleteRemoteAssignmentis not called, andassignmentsService.updateStatusById(id, 'COMPLETE')is called. - Expected:
updateStatusByIdis not called and the error is logged as a failure to synchronize that assignment. The local status staysOUTSTANDING, so the problem stays visible and is retried, matching the missing-encryptedDatacase.
Tests
apps/api/src/gateway/__tests__/gateway.synchronizer.spec.ts, describe('incomplete submission'), tests 'should create no session for a submission without a completion date' and 'should create no session for a submission without a symmetric key'. Neither one asserts on updateStatusById, so they do not pin the defect. When fixing:
- add
expect(assignmentsService.updateStatusById).not.toHaveBeenCalled()to both; - change the logged-error assertion to the
{ error: 'Failed to synchronize remote assignment with ID: assignment-1' }shape, as in the siblingencryptedData: nulltest, if the fix throws instead of logging.
Suggested fix
Throw instead of logging and returning, as the encryptedData check already does, and move both checks above the findById call so all three missing-field cases are handled in one place.
Found while raising unit test coverage in #1632.
Activity
joshunrau commentedon Oct 26, 2026
Still reproduces on
main(d86740e). The test file is nowapps/api/src/gateway/__tests__/gateway.synchronizer.test.ts(renamed in efa9070), with the two tests at:210and:219. No open PR touches the synchronizer.Fix
Throwing and hoisting the checks is right. Name the missing field in each thrown message. After the change,
sync()logs onlyFailed to synchronize remote assignment with ID: ..., with the error ascause(gateway.synchronizer.ts:95-99), so the message is the only place that says which field was null.That log is also the only warning left. Once the row stays
OUTSTANDING, the subject's Assignments tab enables Cancel (apps/web/src/routes/_app/datahub/subjects/$subjectId/assignments.tsx:69). Cancelling it, or bulk-deleting it, deletes the gateway copy that holds the responses (assignments.service.ts:160-162,:203-205). Today the row readsCOMPLETE, so neither is offered and the responses stay on the gateway. That is acceptable for a state that takes a gateway defect to reach, but it is one more reason to make the message specific.Keep the guard in the synchronizer rather than making
$RemoteAssignmenta discriminated union onstatus. A zod union has no.omit, which$CreateRemoteAssignmentDataand$CreateRemoteAssignmentsDatause (packages/schemas/src/assignment/assignment.ts:141,:158). The gateway'ssatisfies RemoteAssignment[]over nullable Prisma columns (apps/gateway/src/routers/api.router.ts:29-35) would stop compiling. And one bad row would fail the whole-array parse infetchRemoteAssignments, which then returns[](gateway.service.ts:104-112), so nothing would sync.That last behaviour is already a bug of the same kind. Any row that fails
$RemoteAssignmenttoday makes every pass sync nothing until someone fixes the row, for example astatusoutside the enum: the gateway stores it as a freeString(apps/gateway/prisma/schema.prisma:25) and casts it (api.router.ts:33). Parsing per row and skipping only the bad rows fixes it. That should be its own issue.Tests
Unit: the issue's two assertions, plus one that the logged
causemessage namescompletedAt(orsymmetricKey).e2e, in
testing/src/specs/gateway-assignment.spec.tsnext to'gateway malformed submission'. The gateway's API can reach only thecompletedAtcase: re-create the row throughPOST /api/assignmentswithstatus: 'COMPLETE'($CreateRemoteAssignmentDatakeepsstatusandcompletedAt), then send a Cap-verified PATCH withoutstatus, which leavescompletedAtnull (api.router.ts:142). Writing the gateway's SQLite row directly is simpler and covers both fields.node:sqliteloads without a flag on Node v24.15.0, so this needs no new dependency.seedAssignment(api).GATEWAY_DATABASE_URLfromsrc/support/env.ts(test:e2ealready loads.env) and open it withnew DatabaseSync(new URL(url), { timeout: 5_000 }). The default busy timeout is 0, so a write that lands while the gateway holds a lock fails withSQLITE_BUSY.RemoteAssignmentModel, set the first row tostatus = 'COMPLETE', encryptedData = 'x', symmetricKey = 'y', completedAt = NULL.status = 'CANCELED', as a control. Any pass that cancels it has also read the first row, so the test cannot pass on a sync that never ran.2 * gatewayRefreshInterval.api.findAssignmentsthat the control isCANCELEDand the first is stillOUTSTANDING, with noREMOTEsession for its subject. Cancel the first in afinally. Teardown drops only the API's database, so a row left in the gateway's SQLite would log a sync failure on every pass of later local runs.This would be the first spec that writes to an app's database directly. With it in place, Remote assignments never expire: the gateway accepts submissions after expiresAt and nothing marks an assignment EXPIRED #1703's e2e could backdate the gateway row's
expiresAtinstead of waiting out a short expiry.Triage note written with Claude Code. Code references point at
main@d86740e.