Summary
A board whose name is whitespace-only (e.g. " ") is accepted by the client and by Swagger validation, but rejected by Mongoose, so the board can never sync. Because the sync engine never rolls back the PENDING status on failure, the board is re-pushed on every sync cycle indefinitely.
Symptoms
Two API error signatures, one root cause:
| Handler |
Code |
Message |
cboard-api api/controllers/board.js:37 createBoard |
409 |
Error saving board |
cboard-api api/controllers/board.js:263 updateBoard |
500 |
Error saving board. (trailing period + space) |
Client-side these surface as repeated Sync exceptions from phase: 'pushBoard'.
Root cause
Every gate in the chain lets a whitespace-only name through, and the last one rejects it:
src/components/Board/TileEditor/TileEditor.component.js:532 — disableSubmit={!currentLabel}. '' is falsy so an empty label is correctly blocked, but ' ' is truthy, so Save is enabled.
src/components/Board/TileEditor/TileEditor.component.js:331-332 — handleLabelChange clears labelKey, so the new board gets name: ' ', nameKey: ''.
src/components/Board/Board.container.js:588 / :1172 — folder board is created with name: tile.label.
src/components/Board/Board.utils.js:62 — extractBoardName returns board.name unchanged because ' ' is truthy.
cboard-api api/swagger/swagger.yaml:1522-1532 — name is required with type: string and no minLength, so ' ' passes. (required here means the key must be present, not non-empty.)
cboard-api api/models/Board.js:8-12 — name: { type: String, required: true, trim: true }. The trim setter runs on assignment, before the required validator, so ' ' becomes '' and validation fails.
The PUT handler merges rather than overwrites (for (let key in updateData) board[key] = updateData[key]), so a payload that omits name is harmless. The failure specifically requires name present and blank-after-trim — which is why the stored DB row still holds the original name: every write was rejected, so the server value was never touched.
author is vulnerable in exactly the same way. transformBoardForUser (src/components/Board/Board.utils.js:83) sets author: userName || userEmail; a display name of ' ' is truthy, survives the ||, and dies at the same trim + required pair. name, author and email are the only three required + trim fields on the model.
Why it never self-recovers
src/components/Board/Board.reducer.js:236-247 — UPDATE_BOARD replaces the board wholesale and sets SYNC_STATUS.PENDING. The blank name is persisted locally before the API call and is never rolled back on failure; the catch at src/components/Board/Board.actions.js:888 only logs and calls trackSyncException. The board stays PENDING and is re-pushed every cycle forever — one bad board produces unbounded telemetry.
Telemetry gap
The API already returns the precise Mongoose message (error: err.message at board.js:38 and board.js:262), which names the failing field. But src/api/api.js returns the raw axios rejection, whose .message is only "Request failed with status code 500", and trackSyncException (src/components/Board/Board.sync.analytics.js:30-39) records only exception plus { phase, boardId }. The server's field-level diagnosis was being discarded at the client boundary, which is why the failing field had to be inferred rather than read.
Notes on scope
- Empty labels are not the cause.
disableSubmit correctly blocks '', so a folder board can never be created with an empty name. Only whitespace gets through.
- Commit 78866f6 is not the regression. That commit changed
extractBoardName's fallback from 'Untitled Board' to '', but that branch requires a board with neither name nor nameKey. All 44 default boards in src/api/boards.json carry a nameKey that derives a non-blank name (verified: zero with neither, zero whose nameKey last segment is blank), and the tile path always supplies a truthy name. The '' branch appears unreachable — it is a latent hazard, not the live bug. The absence of logs before the sync-engine deploy is explained by 7a4c2d47 introducing the retry loop, not by 78866f65.
nameKey exists in neither the Swagger schema nor the Mongoose model, so it is dropped on every round trip. Combined with the wholesale UPDATE_BOARD replace, any board that has been pulled once has no local nameKey, making the extractBoardName fallback dead for those boards.
Fixes
Open question
The originally-reported board reads people in the DB, which derives from nameKey: cboard.symbol.people and is non-blank under every code path examined. No path was found that blanks that board's name. Either the failing board is not the one being inspected, or it is failing on author rather than name. The telemetry fix above should resolve this on the next sync cycle from an affected user.
Summary
A board whose
nameis whitespace-only (e.g." ") is accepted by the client and by Swagger validation, but rejected by Mongoose, so the board can never sync. Because the sync engine never rolls back thePENDINGstatus on failure, the board is re-pushed on every sync cycle indefinitely.Symptoms
Two API error signatures, one root cause:
cboard-apiapi/controllers/board.js:37createBoardError saving boardcboard-apiapi/controllers/board.js:263updateBoardError saving board.(trailing period + space)Client-side these surface as repeated
Syncexceptions fromphase: 'pushBoard'.Root cause
Every gate in the chain lets a whitespace-only name through, and the last one rejects it:
src/components/Board/TileEditor/TileEditor.component.js:532—disableSubmit={!currentLabel}.''is falsy so an empty label is correctly blocked, but' 'is truthy, so Save is enabled.src/components/Board/TileEditor/TileEditor.component.js:331-332—handleLabelChangeclearslabelKey, so the new board getsname: ' ',nameKey: ''.src/components/Board/Board.container.js:588/:1172— folder board is created withname: tile.label.src/components/Board/Board.utils.js:62—extractBoardNamereturnsboard.nameunchanged because' 'is truthy.cboard-apiapi/swagger/swagger.yaml:1522-1532—nameisrequiredwithtype: stringand nominLength, so' 'passes. (requiredhere means the key must be present, not non-empty.)cboard-apiapi/models/Board.js:8-12—name: { type: String, required: true, trim: true }. Thetrimsetter runs on assignment, before therequiredvalidator, so' 'becomes''and validation fails.The PUT handler merges rather than overwrites (
for (let key in updateData) board[key] = updateData[key]), so a payload that omitsnameis harmless. The failure specifically requiresnamepresent and blank-after-trim — which is why the stored DB row still holds the original name: every write was rejected, so the server value was never touched.authoris vulnerable in exactly the same way.transformBoardForUser(src/components/Board/Board.utils.js:83) setsauthor: userName || userEmail; a display name of' 'is truthy, survives the||, and dies at the sametrim+requiredpair.name,authorandemailare the only threerequired+trimfields on the model.Why it never self-recovers
src/components/Board/Board.reducer.js:236-247—UPDATE_BOARDreplaces the board wholesale and setsSYNC_STATUS.PENDING. The blank name is persisted locally before the API call and is never rolled back on failure; the catch atsrc/components/Board/Board.actions.js:888only logs and callstrackSyncException. The board staysPENDINGand is re-pushed every cycle forever — one bad board produces unbounded telemetry.Telemetry gap
The API already returns the precise Mongoose message (
error: err.messageatboard.js:38andboard.js:262), which names the failing field. Butsrc/api/api.jsreturns the raw axios rejection, whose.messageis only"Request failed with status code 500", andtrackSyncException(src/components/Board/Board.sync.analytics.js:30-39) records onlyexceptionplus{ phase, boardId }. The server's field-level diagnosis was being discarded at the client boundary, which is why the failing field had to be inferred rather than read.Notes on scope
disableSubmitcorrectly blocks'', so a folder board can never be created with an empty name. Only whitespace gets through.extractBoardName's fallback from'Untitled Board'to'', but that branch requires a board with neithernamenornameKey. All 44 default boards insrc/api/boards.jsoncarry anameKeythat derives a non-blank name (verified: zero with neither, zero whosenameKeylast segment is blank), and the tile path always supplies a truthyname. The''branch appears unreachable — it is a latent hazard, not the live bug. The absence of logs before the sync-engine deploy is explained by7a4c2d47introducing the retry loop, not by78866f65.nameKeyexists in neither the Swagger schema nor the Mongoose model, so it is dropped on every round trip. Combined with the wholesaleUPDATE_BOARDreplace, any board that has been pulled once has no localnameKey, making theextractBoardNamefallback dead for those boards.Fixes
e.response.data.errorandstatusintrackSyncExceptionforphase: 'pushBoard', so production reports the failing field instead of requiring inference.disableSubmit={!currentLabel || !currentLabel.trim()}inTileEditor.name,authorandemailat the wire inAPI.createBoard/API.updateBoard. This remains the only fix that covers boards which never pass throughtransformBoardForUser— a user-owned board goes straight toupdateApiBoardand bypasses it, as do boards already persisted in localStorage with a bad name. Without it, existing affected boards are not repaired.DEFAULT_BOARD_NAMEfallback inextractBoardName, and trimuserNamebefore the||intransformBoardForUser.syncMetathat skips a board past a threshold and resets on local edit or success — a change to the sync state machine that interacts with the graduation logic inclassifyBoardsForPush, so it warrants its own issue.Open question
The originally-reported board reads
peoplein the DB, which derives fromnameKey: cboard.symbol.peopleand is non-blank under every code path examined. No path was found that blanks that board's name. Either the failing board is not the one being inspected, or it is failing onauthorrather thanname. The telemetry fix above should resolve this on the next sync cycle from an affected user.