Skip to content

Gateway sync marks a completed assignment COMPLETE without importing it when completedAt or symmetricKey is missing #1637

Open

Description

@joshunrau

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.create is not called, gatewayService.deleteRemoteAssignment is not called, and assignmentsService.updateStatusById(id, 'COMPLETE') is called.
  • Expected: updateStatusById is not called and the error is logged as a failure to synchronize that assignment. The local status stays OUTSTANDING, so the problem stays visible and is retried, matching the missing-encryptedData case.

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 sibling encryptedData: null test, 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

  1. added
    BugType: existing behavior is wrong
    Difficulty: LowIsolated change, about 2 hours or less
    Area: APIapps/api (NestJS backend, Prisma models, CASL permissions)
    on Oct 5, 2026
  2. joshunrau commented on Oct 26, 2026

    @joshunrau
    CollaboratorAuthor

    Still reproduces on main (d86740e). The test file is now apps/api/src/gateway/__tests__/gateway.synchronizer.test.ts (renamed in efa9070), with the two tests at :210 and :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 only Failed to synchronize remote assignment with ID: ..., with the error as cause (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 reads COMPLETE, 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 $RemoteAssignment a discriminated union on status. A zod union has no .omit, which $CreateRemoteAssignmentData and $CreateRemoteAssignmentsData use (packages/schemas/src/assignment/assignment.ts:141, :158). The gateway's satisfies 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 in fetchRemoteAssignments, 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 $RemoteAssignment today makes every pass sync nothing until someone fixes the row, for example a status outside the enum: the gateway stores it as a free String (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 cause message names completedAt (or symmetricKey).

    • e2e, in testing/src/specs/gateway-assignment.spec.ts next to 'gateway malformed submission'. The gateway's API can reach only the completedAt case: re-create the row through POST /api/assignments with status: 'COMPLETE' ($CreateRemoteAssignmentData keeps status and completedAt), then send a Cap-verified PATCH without status, which leaves completedAt null (api.router.ts:142). Writing the gateway's SQLite row directly is simpler and covers both fields. node:sqlite loads without a flag on Node v24.15.0, so this needs no new dependency.

      1. Seed two assignments with seedAssignment(api).
      2. Export GATEWAY_DATABASE_URL from src/support/env.ts (test:e2e already loads .env) and open it with new 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 with SQLITE_BUSY.
      3. In RemoteAssignmentModel, set the first row to status = 'COMPLETE', encryptedData = 'x', symmetricKey = 'y', completedAt = NULL.
      4. Then set the second row to 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.
      5. Wait 2 * gatewayRefreshInterval.
      6. Assert through api.findAssignments that the control is CANCELED and the first is still OUTSTANDING, with no REMOTE session for its subject. Cancel the first in a finally. 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 expiresAt instead of waiting out a short expiry.

    Triage note written with Claude Code. Code references point at main @ d86740e.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Area: APIapps/api (NestJS backend, Prisma models, CASL permissions)BugType: existing behavior is wrongDifficulty: LowIsolated change, about 2 hours or lessPriority: MediumReal value; schedule normally

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions