Conversation
Validating a pending modification handed the stored member ids straight to
replaceCollection, which destroys the junction rows and re-inserts them with
no check that the targets still exist. Postgres rejected the insert with
23503, so the request failed with a 500 - and every retry failed the same
way, leaving the document permanently unvalidatable and the contributor's
edit stuck. This fired twice in production on document 14958
(j_document_caver_author_t_caver_fk, caver 23871 deleted); document 229736
had been stuck since 2025-05-20 on subject code 2.11, pruned from the
taxonomy.
modifiedDocJson is a submission-time snapshot stored as json, so it is the
one place the foreign-key graph does not reach: neither case involved an
entity-delete controller, and every constraint in the database was satisfied
at every moment. validateAndUpdateDocument already re-validates parent for
the same reason, but the seven m2m collections had no equivalent guard.
- Add DocumentService.resolveM2MMembers, reporting the members that no longer
resolve and returning the primary keys as read back from the target table
- Auto-reject a stale modification: clear modifiedDocJson, record the reason
in validationComment and notify the author, mirroring the parent-cycle path
- Return 400 from PUT /documents/{id}/new-entities when a client-supplied id
does not resolve, instead of surfacing the violation as a 500
- Trim both sides when matching keys: t_subject.code is bpchar(8),
t_language.id bpchar(3) and t_country.iso bpchar(2), and node-postgres
returns those blank-padded, so a bpchar = text comparison casts the column
and never matches
- Write back the canonical key, keeping padding out of the junction tables
- Collapse duplicate members, which were a 23505 primary-key violation on the
junction composite key
- Report null and non-numeric members instead of letting Waterline raise
E_INVALID_CRITERIA, turning one 500 into another
- Truncate the auto-rejection reason to fit validation_comment varchar(300)
Closes #1815
- resolveM2MMembers: all seven collections, absent members, blank-padded subject/language/country codes, duplicate collapse, and null or non-numeric members reported rather than thrown - multiple-validate: a snapshot naming a destroyed caver is auto-rejected with a REJECT notification and no VALIDATE notification, a blank-padded subject code still applies as the canonical key, and the rest of the batch keeps processing - update-with-new-entities: 400 for an unknown caver id and for an unknown subject code, asserting nothing was written The blank-padded cases are the regression guard for production. The test schema is generated from the model definitions by Waterline migrate: drop, so those columns come out as character varying rather than bpchar (see test/customSQL.js), and the trap is invisible from the column side. It is still reproducible from the member side - a padded value in the snapshot against an unpadded stored key - which is exactly the production asymmetry. Dropping the trim from normalizeMemberKey fails four of these tests.
- /documents/{id}/new-entities: the 400 now covers a parent cycle and an
unknown id in any of the seven m2m collection fields
- /documents/validate: describe auto-rejection - modifiedDocJson cleared,
validationComment prefixed "Auto-rejected:", REJECT notification sent to
the author - and note that the rest of the batch still succeeds
Paul-AUB
requested changes
Sep 24, 2026
Paul-AUB
left a comment
Contributor
There was a problem hiding this comment.
Issues (Must Fix)
- [api/controllers/v1/document/multiple-validate.js:45] Auto-rejection leaves the document in the refused state instead of restoring its last validated state. A pending edit sets
isValidatedtofalseanddateValidationtonull. This helper clearsmodifiedDocJsonand setsdateValidation, but never restoresisValidated.find-all.jsthen excludes the record from both the default validated list and the moderation queue (isValidated: falseis only returned whendateValidationis null), so a stale linked entity makes the original document disappear rather than returning it to its last validated state. Please restore the validation flag for this rejection path and assert it in the stale-member route test; the existing parent-cycle asymmetry is also worth fixing now that this helper extends it to the new path. - [api/services/DocumentService.js:908] Finite numeric values are not necessarily valid IDs, so malformed members can still produce the 500 this guard is intended to prevent. For the serial/int4-backed collections, values such as
1.5or2147483648passNumber.isFinite(Number(key)). Waterline accepts them as numbers, but PostgreSQL rejects them when evaluating the integer primary-key criterion, soModel.find()throws before the value can be reported inmissing. The direct endpoint therefore returns 500 instead of 400, and the ordinary update path can persist the same value inmodifiedDocJson, recreating a permanently failing moderation item. Please validate the integer PK domain (including range) or translate criterion/adapter failures into unresolved members, with route/service coverage for fractional and out-of-range IDs.
Suggestions (Should Consider)
- [api/controllers/v1/document/multiple-validate.js:132] Consider closing the time-of-check/time-of-use gap between resolution and replacement. Both controllers resolve members before opening their write transaction. A target row can be deleted after
Model.find()succeeds but beforereplaceCollection()inserts the junction row, which still yields a 23503/500 and, on validation, leaves the modification stuck. Resolving under an appropriate row-locking strategy, or catching the rolled-back FK failure and applying the same auto-reject/400 policy, would make the new guarantee hold under concurrent deletes as well.
This branch was successfully 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.
🤔 What
Validating a pending document modification could fail with a
500that never cleared, leaving the document permanently unvalidatable. Fixes #1815.modifiedDocJsonstill exists beforereplaceCollectionwrites it500400fromPUT /documents/{id}/new-entitieswhen a client-supplied id does not resolve🤷♂️ Why
modifiedDocJsonis a snapshot of a proposed edit, taken at submission time and stored asjson— the one place the foreign-key graph does not reach. Postgres will hold a reference to a row that no longer exists, indefinitely, and the failure only surfaces when a moderator next validates the document.replaceM2MCollectionshanded those stored ids straight toreplaceCollection, which destroys the junction rows and re-inserts them. Postgres rejected the insert with23503, the request failed with a500, and every retry failed the same way — the contributor's edit was stuck with no way for a moderator to clear it.This fired twice in production on document 14958 (caver 23871, deleted). Document 229736 had been stuck since 2025-05-20 on subject code
2.11, pruned from the taxonomy — and that second case is why the guard belongs at validation time rather than in the delete controllers: no entity-delete controller was ever involved, and every constraint in the database was satisfied at every moment.validateAndUpdateDocumentalready re-validatesparentat validation time for exactly this reason — "the hierarchy may have changed since the modification was submitted". The seven m2m collections had no equivalent guard. This extends that principle and reuses the same auto-reject mechanism.🔍 How
DocumentService.resolveM2MMembers(collectionData, db)returns{ missing, resolved }: the members that no longer resolve, and the primary keys as read back from the target table. The target model is derived fromTDocument.attributes[field].collectionrather than a second hardcoded map, so the two cannot drift apart.Two call sites, two policies:
PUT /documents/validatemodifiedDocJson, prefixvalidationCommentwithAuto-rejected:, notify the author withREJECT. A stale snapshot is not the moderator's fault, and the rest of the batch keeps processing.PUT /documents/{id}/new-entities400listing the unknown ids. Here they come straight from the client, so it is a bad request.Three subtleties worth a reviewer's attention:
t_subject.codeisbpchar(8),t_language.idbpchar(3),t_country.isobpchar(2). node-postgres returns those padded, so a snapshot stores subject2as'2 '. Abpchar = textcomparison casts the column to text — stripping its padding while leaving it on the parameter — so the two never match. Without trimming both sides the check reports every short code as missing and would have discarded valid edits. Confirmed against production: 229736's snapshot held"2.11 ".j_document_subject.code_subjectand the populate join would find nothing — silent data loss. Resolving to the stored key also collapses duplicates such as['1.0', '1.0 '], which were a23505primary-key violation on the junction composite key.validation_commentisvarchar(300). The reason lists the missing members, so it is truncated to fit; the untruncated text always goes to the log.null, empty and non-numeric members are now reported rather than letting Waterline raiseE_INVALID_CRITERIA, which would have turned one500into another.Out of scope. The batch is still non-atomic: a document that throws aborts the request after earlier documents have already committed, and fixing it properly means replacing the
204-with-no-body response with a per-document report, which the front end reads. Tracked in #1817, which needs a coordinated front-end change. This PR reduces how often it can fire without removing the fragility.Production state. 229736's snapshot was repaired directly in the database (the pruned subject key removed from
documentData) so its pending edit applies rather than being auto-rejected by this change. 14958 no longer shows the dangling author and needs no repair.🧪 Testing
npm test— 19 new tests, all green.Negative control: remove the
trim()fromnormalizeMemberKeyinDocumentService.jsand four tests fail — both padded-code service tests, the duplicate-collapse test, and the padded-subject route test. The test schema is generated from the model definitions by Waterlinemigrate: 'drop', so those columns come out ascharacter varyingrather thanbpcharand the trap is invisible from the column side; the tests reproduce it from the member side instead, which is exactly the production asymmetry.To exercise the fix by hand: stage a
modifiedDocJsonnaming a caver, delete the caver, then validate the document. Expect204,modifiedDocJsoncleared,validationCommentstartingAuto-rejected:, and aREJECTnotification to the author — not a500.Unrelated pre-existing failure:
test/integration/4_routes/Changes/get-recent-comment-relevance-swap.test.jsfails 4 tests in shard 3, identically ondevelopwith and without this branch. It passes in isolation, so it is a shard-ordering interaction.📸 Previews
N/A — API only, no UI surface.