Repository navigation
Description
A subject who has ever been sent a remote assignment cannot be deleted. DELETE /v1/subjects/:id?force=true is the request odc-cli subjects delete always sends. It deletes the subject's record files, records and sessions, then the subject, but it never deletes the subject's assignments. Assignment.subject is a required relation with no onDelete, so Prisma's emulated Restrict refuses to delete the subject. The transaction rolls back and the caller gets a bare 500. This applies to assignments in any status: outstanding, complete, expired or canceled. #1572 fixed the same refusal for record files, and assignments are the dependent that path still misses.
Where
apps/api/src/subjects/subjects.service.ts:146-155:
await this.prismaClient.$transaction([
this.prismaClient.instrumentRecordFile.deleteMany({ where: { id: { in: files.map((file) => file.id) } } }),
this.prismaClient.instrumentRecord.deleteMany({
where: { ...recordWhere, id: { in: recordIds } }
}),
this.prismaClient.session.deleteMany({
where: { AND: [accessibleQuery(ability, 'delete', 'Session')], subjectId: subject.id }
}),
this.prismaClient.subject.delete({ where })
]);apps/api/prisma/schema.prisma:81, on Assignment:
subject Subject @relation(fields: [subjectId], references: [id])Reproduce
Verified with the api integration harness as admin:
- Create a subject and an assignment for them (any status).
DELETE /v1/subjects/<subject id>?force=true(orodc-cli subjects delete <subject id>).
Actual: 500 Internal Server Error. The subject, its sessions, its records and its assignment all remain.
Expected: the subject is deleted along with its assignments, as the force flag promises for its other dependents. If assignments must be kept, the response should be a 409 that says the subject has assignments.
Tests
apps/api/src/subjects/__tests__/subjects.service.test.ts:it("should delete the subject's assignments in the same transaction, so a subject who was sent a remote assignment can still be force-deleted").apps/api/test/suites/03-record-file-deletion.suite.ts(integration):it('should force-delete a subject who has an assignment'). The web app has no subject-delete UI, so the integration suite stands in for a Playwright test here, as in Deleting a record that has uploaded files, or force-deleting its subject, is refused #1572.
Suggested fix
Add this.prismaClient.assignment.deleteMany({ where: { AND: [accessibleQuery(ability, 'delete', 'Assignment')], subjectId: subject.id } }) to the transaction, before the subject delete. Before the transaction, remove the gateway copy of each outstanding assignment, as AssignmentsService.updateById does when canceling. Otherwise the gateway keeps serving a link whose completion can never be imported.
Activity
joshunrau commentedon Oct 26, 2026
Still valid on
main(d86740e).SubjectsService.deleteByIdhas not changed since 2214e7f (#1605), and no open PR touches it.Problems with the suggested fix
accessibleQuery(ability, 'delete', 'Assignment')throws CASL'sForbiddenError, which surfaces as a 500, when the caller holds noAssignmentdelete rule at all. It runs while the transaction array is being built, so such a caller could not force-delete any subject, even one with no assignments. Below ADMIN, a caller reaches this route only through an extradelete Subjectgrant, like the STANDARD user inapps/api/test/suites/03-record-file-deletion.suite.ts:94, who has noAssignmentrule. The scoping test atapps/api/src/subjects/__tests__/subjects.service.test.ts:309builds the same kind of ability, so the suggested line fails it.apps/api/AGENTS.mdsays to checkability.canfirst in this case.SubjectsService,SubjectsModulewould have to importGatewayModule.GatewayModuleandAssignmentsModuleare registered only whenGATEWAY_ENABLEDis set (apps/api/src/main.ts:59-66).GatewayModuleimportsInstrumentRecordsModuleandSessionsModule(apps/api/src/gateway/gateway.module.ts:25-27), and both of those importSubjectsModule. The import would be circular. It would also start the synchronizer and register the assignment routes on instances that have the gateway disabled.GatewayService.deleteRemoteAssignmentfails when the gateway copy is already gone. The gateway answers 404 (apps/gateway/src/routers/api.router.ts:165-167).apps/api/src/gateway/gateway.http.tssets novalidateStatus, so axios rejects the 404 before the status check atgateway.service.ts:89sees it. Suppose a delete-gateway-first loop fails partway, or the transaction after it rolls back. Every retry then fails on the copies that were already removed.deleteBulk(apps/api/src/assignments/assignments.service.ts:158-172) and cancel (:203-204) already fail this way for any assignment whose gateway copy is gone.Recommended plan
idandgroupIdof the subject's assignments without scoping. Ifability.can('delete', forcedAppSubject('Assignment', assignment))is false for any of them, throwConflictExceptionbefore writing anything. Otherwise addassignment.deleteMany({ where: { id: { in: ids } } })to the transaction, after the record delete and before the subject delete. Because it comes after the record delete, the emulatedSetNullonInstrumentRecord.assignmenthas nothing to update once Records created from a remote assignment are never linked to that assignment #1571 links the two.apps/api/AGENTS.mdrequires every service query to be scoped, so give the unscoped read a doc comment that says why, asfindArchivedInstrumentIdshas.GatewaySynchronizer.sync(), not inSubjectsService. After the fetch, use one query to find which remote ids still have an api row. Delete the rest from the gateway before the per-assignment loop. Those copies can never be imported. The gateway stores only the public key, and the private key exists only on the api row (encryptionKeyPair,apps/api/prisma/schema.prisma:84). Today a completed orphan fails atassignmentsService.findById(gateway.synchronizer.ts:108), which comes before the gateway delete at:216. The failure is logged on every pass, and the encrypted submission stays on the gateway. The cost of this approach is that the link stays live until the next pass, up to oneGATEWAY_REFRESH_INTERVAL(10 s in.env.template). The same sweep also removes the copies thatcreate(assignments.service.ts:95-100) andcreateBulk(:128-131) leave behind when the gateway stored the assignment but the call still threw.If the link must stop working at the moment of deletion, step 2 has to solve the module wiring above and treat a gateway 404 as already deleted. The bulk gateway delete proposed in #1749 would fit there.
Tests
subjects.service.test.ts: addassignmentto the Prisma mock (:35-55) and to the order test (:296). Add a case where an out-of-scope assignment returns 409 and nothing is written. Add another where a caller with noAssignmentrule force-deletes a subject that has no assignments.gateway.synchronizer.test.ts: a remote assignment with no api row is deleted from the gateway and not imported, and one that has a row goes through the existing loop. Also add a unit test for the newAssignmentsServicelookup.03-record-file-deletion.suite.ts: the issue's case. Seed the assignment withprisma.assignment.create, the same wayseed()creates records.E2E: having no subject-delete UI does not rule out a Playwright test.
testing/src/specs/record-file-deletion.spec.tsalready drives a deletion overapiRequestContext. Intesting/src/specs/gateway-assignment.spec.ts:seedAssignment(api).assignment.subjectIdas admin and expect 200.expect.pollapiRequestContext.get(assignment.url)until it returns 404, with a timeout of twogatewayRefreshIntervals.That poll uses up most of the default 30 s test budget, so configure a longer timeout, as the malformed-submission describe in that file does.
Step 2 edits the same
sync()loop as #1637 and #1703, so land it together with them or after them.Triage note written with Claude Code. Code references point at
main@d86740e.