Skip to content

Force-deleting a subject who has a remote assignment fails with 500 and deletes nothing #1748

Open

Description

@joshunrau

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:

  1. Create a subject and an assignment for them (any status).
  2. DELETE /v1/subjects/<subject id>?force=true (or odc-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

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

    @joshunrau
    CollaboratorAuthor

    Still valid on main (d86740e). SubjectsService.deleteById has not changed since 2214e7f (#1605), and no open PR touches it.

    Problems with the suggested fix

    • accessibleQuery(ability, 'delete', 'Assignment') throws CASL's ForbiddenError, which surfaces as a 500, when the caller holds no Assignment delete 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 extra delete Subject grant, like the STANDARD user in apps/api/test/suites/03-record-file-deletion.suite.ts:94, who has no Assignment rule. The scoping test at apps/api/src/subjects/__tests__/subjects.service.test.ts:309 builds the same kind of ability, so the suggested line fails it. apps/api/AGENTS.md says to check ability.can first in this case.
    • To remove gateway copies from SubjectsService, SubjectsModule would have to import GatewayModule. GatewayModule and AssignmentsModule are registered only when GATEWAY_ENABLED is set (apps/api/src/main.ts:59-66). GatewayModule imports InstrumentRecordsModule and SessionsModule (apps/api/src/gateway/gateway.module.ts:25-27), and both of those import SubjectsModule. The import would be circular. It would also start the synchronizer and register the assignment routes on instances that have the gateway disabled.
    • GatewayService.deleteRemoteAssignment fails 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.ts sets no validateStatus, so axios rejects the 404 before the status check at gateway.service.ts:89 sees 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

    1. In the force branch, read the id and groupId of the subject's assignments without scoping. If ability.can('delete', forcedAppSubject('Assignment', assignment)) is false for any of them, throw ConflictException before writing anything. Otherwise add assignment.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 emulated SetNull on InstrumentRecord.assignment has nothing to update once Records created from a remote assignment are never linked to that assignment #1571 links the two. apps/api/AGENTS.md requires every service query to be scoped, so give the unscoped read a doc comment that says why, as findArchivedInstrumentIds has.
    2. Remove orphaned gateway copies in GatewaySynchronizer.sync(), not in SubjectsService. 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 at assignmentsService.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 one GATEWAY_REFRESH_INTERVAL (10 s in .env.template). The same sweep also removes the copies that create (assignments.service.ts:95-100) and createBulk (: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: add assignment to 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 no Assignment rule 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 new AssignmentsService lookup.

    • 03-record-file-deletion.suite.ts: the issue's case. Seed the assignment with prisma.assignment.create, the same way seed() creates records.

    • E2E: having no subject-delete UI does not rule out a Playwright test. testing/src/specs/record-file-deletion.spec.ts already drives a deletion over apiRequestContext. In testing/src/specs/gateway-assignment.spec.ts:

      1. Call seedAssignment(api).
      2. Force-delete assignment.subjectId as admin and expect 200.
      3. expect.poll apiRequestContext.get(assignment.url) until it returns 404, with a timeout of two gatewayRefreshIntervals.

      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.

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: MediumStraightforward change, about a day 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