Repository navigation
Conversation
cold-13
force-pushed
the
fix/enforce-application-review-state-machine
branch
from
October 1, 2026 10:07
0063570 to
405c7db
Compare
`NgoApplication.status` is `PENDING -> APPROVED | REJECTED`, and both targets are terminal. The check that enforced that sat after the `return` inside the `try` in both review handlers, so it never ran — and it read an `application` variable that no longer existed. Approving a rejected application (or the reverse) therefore returned 200 and overwrote the recorded decision and its review note. Fold the check into the UPDATE (`WHERE id = ? AND status = 'PENDING'`) so the transition is a compare-and-swap: Postgres re-evaluates that predicate against the latest committed row, so of two concurrent reviews exactly one matches. `updateMany` cannot raise the not-found error `update` did, so a single follow-up read tells 404 (no such application) from 409 `already_reviewed`, and the 409 now reports the status already on file. Both routes go through one handler now, so the two decisions cannot drift apart in how they are applied.
cold-13
force-pushed
the
fix/enforce-application-review-state-machine
branch
from
October 1, 2026 10:34
405c7db to
c084882
Compare
Contributor
|
@cold-13 the issue you linked doesn't exist |
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #1302
What this protects
NgoApplication.statusis a three-state machine with exactly one legaltransition:
PENDING -> APPROVED | REJECTED. Both targets are terminal — arecorded decision is the record of what the review concluded, and it is what
an admin acts on and what an applicant is told by
GET /ngo-applications/status. Today nothing enforces that: thealready_reviewedcheck in both review handlers sits after thereturninside the
try, so it never runs, and it reads anapplicationvariablethat no longer exists in that scope.
The observable consequences on
maintoday:POST /ngo-applications/:id/approveon a rejected application returns200and flips it toAPPROVED.POST /ngo-applications/:id/rejecton an approved application returns200and flips it toREJECTED.whole 5-minute clock-skew window, and an admin dashboard retrying a request
it never saw the answer to is an ordinary accident) silently overwrites
the decision and the review note, leaving no trace that the outcome had
ever been anything else.
This is the invariant the reachable-looking check was meant to hold. The two
tests that covered it are not in the current suite.
The fix
src/routes/ngoApplications.ts:95— the "is it still pending?" test is foldedinto the
UPDATEitself:This is a compare-and-swap, not a read-then-write. Postgres re-evaluates the
predicate against the latest committed row when a concurrent writer holds the
row lock, so of two reviews that race, exactly one matches and the loser
updates zero rows. A
findUniquefollowed by anupdate— the shape #80introduced — cannot do that: both callers read
PENDINGand both write.updateManyalso cannot raise the "record not found" error thatupdatedoes, so the two reasons for matching nothing are told apart by one
follow-up read that selects only the status:
404 {"error":"not_found"}(unchanged)PENDING409 {"error":"already_reviewed","status":<current>}Both routes now go through a single
reviewHandler(decision), so approve andreject cannot drift apart in how they are applied — the two hand-copied
handlers are what let the guard rot in the first place. The invariant is
documented at the top of that function.
Failure modes, deliberately
saw it. The retry gets
409with the status on file, which distinguishes"your review landed" from "another reviewer got there first". (A successful
review also cannot be silently lost:
updateManyonly ever moves a row outof
PENDING, and nothing else in the codebase writes this column.)empty, the handler answers
404rather than200with an empty body, sothe response is never inconsistent with the row.
become
500 internal_server_error; only a genuinely missing row is a404.Security
requireAdminSignaturepreHandler is untouched — authorization and thestate machine stay separate gates, and a validly signed request that tries
to re-review is still refused.
applicationId,currentStatusand theattempted decision only. The applicant's
contactEmail/descriptionandthe reviewer's
reviewNoteare never logged or echoed on the failure path.validation are all unchanged.
Compatibility
Unchanged:
400for a malformed id or body,401/503from the authmiddleware,
404for an unknown id,200with the same application body onsuccess. The one change is additive — the
409body gainsstatus.Tests
test/routes/ngoApplications.test.ts, newapplication review state machineblock plus one added to the existing review block:
pending application with its note stored (new; the reject happy path had no
coverage at all).
already-rejected one, and to reject an approved one; each asserts the stored
status is unchanged, not just the status code.
rejected application with
Original reviewer note.stays rejected with thatexact note after an approve carrying
Overwriting note..409body is exactly{error, status}.200and one409, and the stored note is the winner's, so the loser'snote never lands. Plus the worst case, two admins submitting opposite
decisions at once: the application lands in exactly one terminal state.
400and theapplication is still
PENDING, i.e. a request that fails validation has notconsumed the one transition the application is allowed.
Notes for review
POST /ngo-applicationsstill has a read-then-write duplicate check(two concurrent submissions can create two
PENDINGapplications). That isa separate invariant, already tracked and being fixed in fix(applications): prevent duplicate pending applications #182, so it is left
alone here rather than duplicated.
409for a repeat (rather than an idempotent200) is the contract Already-reviewed applications can be approved or rejected again #49specified and Status #80 implemented, so this keeps it. If you would rather a
same-decision retry return
200, that is a one-line change now that thetransition is atomic — say the word.