Problem
submitApplication mutates data as a side effect of validating it. syncGlobalAnswersFromProfile (prisma/actions/applications.ts, added by #355) backfills missing GlobalApplicationAnswer rows from the applicant's profile mid-submit, then returns the labels of required questions still empty so the caller can build an error message. A read-then-write inside the validation path is why the whole action needs a transaction.
The backfill's correctness rests on a subtle, easy-to-miss rule: it branches on row presence, not value emptiness.
- no snapshot row → the applicant has never engaged with this question, fall back to their profile answer
- row exists but empty → they deliberately cleared it for this application, keep it empty
Collapse those two and you silently resurrect an answer the applicant intentionally removed. This already produced one review finding during #355 (R1-M1), and the comment guarding it is one of the few in the codebase that legitimately needs two lines to state.
Idea
Reframe the snapshot from "backfill whatever rows are missing" to "freeze the answers at submission":
- Every read path resolves
snapshot ?? profile explicitly. The client already does exactly this at initialGlobalValues in components/features/application-stepper.tsx.
- The snapshot is materialized once, at the moment of submission, as the point-in-time record it is meant to be — not incrementally patched during validation.
Validation then reads a resolved value and writes nothing, and the "presence vs. emptiness" rule becomes a property of one explicit resolver rather than an invariant every future editor of submitApplication has to know.
Rejected alternative: eager propagation
Propagating global-question changes to all in-progress applications at the moment an admin creates/edits a question (prisma/actions/global-questions.ts, which today only calls revalidatePath) was considered and rejected:
- It doesn't remove the submit-time read. Admin adds a required question → rows are eagerly created empty on every draft → the applicant answers it in their profile afterwards → at submit the eagerly-created row is still empty. You would still have to consult the profile at submit, having added N writes per admin action for nothing.
- It destroys the presence signal. If every question always has a row, "never engaged" and "deliberately cleared" become indistinguishable, and recovering the difference needs a new schema field (a
customized flag or similar) to encode what row presence already encodes for free.
- Write amplification (one admin edit → one write per in-progress draft), plus a backfill migration for existing drafts and extra handling for restoring soft-deleted questions.
Acceptance criteria
Notes
Problem
submitApplicationmutates data as a side effect of validating it.syncGlobalAnswersFromProfile(prisma/actions/applications.ts, added by #355) backfills missingGlobalApplicationAnswerrows from the applicant's profile mid-submit, then returns the labels of required questions still empty so the caller can build an error message. A read-then-write inside the validation path is why the whole action needs a transaction.The backfill's correctness rests on a subtle, easy-to-miss rule: it branches on row presence, not value emptiness.
Collapse those two and you silently resurrect an answer the applicant intentionally removed. This already produced one review finding during #355 (
R1-M1), and the comment guarding it is one of the few in the codebase that legitimately needs two lines to state.Idea
Reframe the snapshot from "backfill whatever rows are missing" to "freeze the answers at submission":
snapshot ?? profileexplicitly. The client already does exactly this atinitialGlobalValuesincomponents/features/application-stepper.tsx.Validation then reads a resolved value and writes nothing, and the "presence vs. emptiness" rule becomes a property of one explicit resolver rather than an invariant every future editor of
submitApplicationhas to know.Rejected alternative: eager propagation
Propagating global-question changes to all in-progress applications at the moment an admin creates/edits a question (
prisma/actions/global-questions.ts, which today only callsrevalidatePath) was considered and rejected:customizedflag or similar) to encode what row presence already encodes for free.Acceptance criteria
snapshot ?? profile) lives in one shared helper used by both client and server, replacing the ad-hoc resolution in the stepper and the implicit resolution inside the backfillsubmitApplicationperforms no writes during validation; the snapshot is materialized in a single step at submissionreopenApplication's behaviour is unchanged or explicitly re-specifiedNotes
prisma/actions/applications.tsandcomponents/features/application-stepper.tsx, so it will collide with Gate Application Answer Writes and Submission on Application Status #345 (PR #345 Gate Application Answer Writes and Submission on Application Status #411) and Preserve Stored Answers When a Question Type or Options Change #354 (PR #354 Preserve Stored Answers When A Question Type Or Options Change #427). Schedule this after both land — it is a cleanup of the shape they leave behind, not a replacement for either.