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, andc4d9d4fe: 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.sqlchanged historical foreign keys from cascade semantics toON DELETE SET NULL.- The migration added
exam_results.student_deletedandclasses.teacher_deletedso the product could distinguish "deleted actor" from "never assigned" in the cases where the old foreign key becameNULL. StudentCommandService.deleteStudent(...)stopped being a simple auth-user delete. It now enforces an inactive-student precondition, cleans up active class enrollments throughClassService, 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
- 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.sqltouches multiple tables and adds deletion markers plus triggers. - The first application-layer reaction was conservative: block deletion when current classes or upcoming counseling reservations still existed.
- That proved too strict for student lifecycle reality because class dropout is itself valid history.
71fcf736introduced automatic active-enrollment drop on delete. e6b1cf7ebriefly restored the block after the first implementation regressed class-state handling.c4d9d4fereintroduced 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, andsms_messagessurvive identity deletion because the FK targets are nullable and useON DELETE SET NULL; - before-delete triggers in
V66__preserve_user_history_on_delete.sqlmarkexam_results.student_deletedandclasses.teacher_deletedso 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
NULLbefore changing a foreign key.student_deletedandteacher_deletedexist becauseNULLalready 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(...)andbatchDelete(...)do not share the same safeguards in the inspectedStudentCommandService.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.