Skip to content

Materialize The Answer Snapshot At Submission Instead Of Backfilling During Validation #445

Description

@b-at-neu

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

  • Answer resolution (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 backfill
  • submitApplication performs no writes during validation; the snapshot is materialized in a single step at submission
  • A deliberately cleared application-only answer still submits as empty and is never refilled from the profile
  • A required global question added after a draft was created still blocks submission with a message naming it
  • reopenApplication's behaviour is unchanged or explicitly re-specified

Notes

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions