Skip to main content

User Deletion History Preservation

Context

Before June 13, 2026, deleting a user, student, or staff row could erase operational history through ON DELETE CASCADE foreign keys. That was acceptable for identity cleanup, but it was not acceptable for attendance, class, exam, counseling, announcement, or SMS history that the tenant still needed after the actor was gone.

The main rollout happened in 27e5603b, then follow-up commits adjusted the user-facing behavior:

  • 2a960e47: read models learned to render deleted actors as explicit placeholders instead of leaking nulls.
  • 71fcf736, e6b1cf7e, and c4d9d4fe: the team iterated on whether active class enrollment should block deletion or be converted into a dropout.

Related steady-state references live in the Student Service and Class Service pages.

What Broke Or Changed

The schema and application layer both had to move.

  • V66__preserve_user_history_on_delete.sql changed historical foreign keys from cascade semantics to ON DELETE SET NULL.
  • The migration added exam_results.student_deleted and classes.teacher_deleted so the product could distinguish "deleted actor" from "never assigned" in the cases where the old foreign key became NULL.
  • StudentCommandService.deleteStudent(...) stopped being a simple auth-user delete. It now enforces an inactive-student precondition, cleans up active class enrollments through ClassService, and still refuses deletion when future counseling work is open.

The migration's core contract is explicit:

-- lumie-backend/app/src/main/resources/db/migration/public/V66__preserve_user_history_on_delete.sql
alter table if exists sms_messages
add constraint sms_messages_sender_id_fkey
foreign key (sender_id) references staff(id) on delete set null;

Diagnosis And Decision Ladder

  1. The team first fixed the data-loss root cause in the schema, not in controller code. That is why V66__preserve_user_history_on_delete.sql touches multiple tables and adds deletion markers plus triggers.
  2. The first application-layer reaction was conservative: block deletion when current classes or upcoming counseling reservations still existed.
  3. That proved too strict for student lifecycle reality because class dropout is itself valid history. 71fcf736 introduced automatic active-enrollment drop on delete.
  4. e6b1cf7e briefly restored the block after the first implementation regressed class-state handling.
  5. c4d9d4fe reintroduced the dropout path, which is the behavior that survived in the current single-delete flow.

The current single-delete path shows that final decision:

// lumie-backend/modules/student/src/main/java/com/lumie/student/application/service/StudentCommandService.java
if (student.isActive()) {
throw new BusinessException(StudentErrorCode.CANNOT_DELETE_ACTIVE_STUDENT);
}
classService.dropActiveEnrollmentsForStudent(tenantSlug, id);
AuthService.DeleteUserResult deleteResult = authService.deleteUser(userId);

Resolution

The durable fix combined schema preservation and application cleanup:

  • historical rows in assignment_submissions, attendance_records, class_enrollments, counseling_*, exam_results, and sms_messages survive identity deletion because the FK targets are nullable and use ON DELETE SET NULL;
  • before-delete triggers in V66__preserve_user_history_on_delete.sql mark exam_results.student_deleted and classes.teacher_deleted so the UI can keep historical meaning after the owner row disappears;
  • single student deletion now drops active enrollments instead of erasing the class trail, while upcoming counseling reservations still block the delete path.

Prevention

  • When a delete path crosses module boundaries, fix the persistence contract first. Application checks alone cannot stop database-level cascades.
  • Preserve the meaning of NULL before changing a foreign key. student_deleted and teacher_deleted exist because NULL already meant other things in those tables.
  • Prefer converting active lifecycle state into historical terminal state, such as ENROLLED -> DROPPED, instead of blocking deletion forever when the business still needs the history.
  • Current drift to watch: deleteStudent(...) and batchDelete(...) do not share the same safeguards in the inspected StudentCommandService.java. Single delete drops active enrollments and checks future operations; batch delete still only rejects active students before calling auth deletion. Future changes should decide whether that difference is intentional and document or remove it.