The bug report was one screenshot. A textarea with a live counter reading 5,982 / 6,000 characters, and directly under it a red error from the server: "too long (maximum 6000 characters)".
Both statements were true. That is what made it interesting.
Two counters, one textarea
The frontend counter was doing the obvious thing:
jobDescription.length // React state, straight from textarea.value
The backend validator was doing the equally obvious thing:
@job_description.length > MAX_LENGTH
Same limit, 6,000. Same text, apparently. Different answers.
The first instinct is a Unicode mismatch: JavaScript's .length counts UTF-16
code units, Ruby's counts characters, so an emoji is two in JS and one in Ruby.
But that runs the wrong way. JS would count more than Ruby, not less. Our
frontend said 5,982 and the backend said something over 6,000. The server was
seeing a longer string than the browser had.
Longer means characters were added in transit. Nothing in our code adds characters. So something in the transport did.
The transport did it
The form has an optional PDF upload. When a file is attached, the client sends
the request as multipart/form-data instead of JSON, and the text field rides
along as a multipart part.
Multipart is defined by an RFC that dates back to email, and it carries email's
line-ending rules with it. When a browser (or FormData) serialises a text
field into a multipart body, it normalises every bare \n in the value to
\r\n. This is standard, specified behaviour, and every browser does it.
The pasted job description had roughly a hundred lines. Each newline was one character in the textarea and in the JSON path. In the multipart path each became two. A hundred extra bytes turned 5,982 into something north of 6,000 before the server ever saw it.
The JSON path (paste-only, no file) sends the value verbatim, so the same text passed. That is why the bug was intermittent: it only fired when the user had attached a PDF and pasted a description near the limit and that description had many lines. Three conditions, all reasonable, none of them wrong.
Where the fix goes
There were three places we could put a fix, and only one of them is right.
Loosen the server cap to allow for CRLF. No. The cap exists for a reason and "allow ~2% slack for line endings" is a number that will be wrong for the next input.
Make the frontend counter count CRLF too. Also no. The counter would then show a number that depends on whether the user has attached a file, which is baffling, and JSON submissions would still be counted the old way.
Normalise on the server before validating. Yes. Line-ending style is a
transport artefact, not user content. The user typed a newline; whether it
arrives as \n or \r\n is a property of the pipe, not of the text. So the
service that receives the text does, first thing:
@job_description = job_description.to_s.gsub(/\r\n?/, "\n")
Then it counts, then it validates, then it builds the prompt. Now the number
the server checks is the number the user saw. As a bonus the model gets clean
\n text either way, and the same normalisation runs on the extracted PDF
text, which had its own habit of arriving with stray carriage returns.
The regression test builds a JD whose LF length is under the cap and whose CRLF length is over it, submits the CRLF version, and asserts it is not rejected as too long. Ten lines. It would have failed on the old code.
The lesson, which is not about CRLF
The specific fact is worth filing: multipart form encoding turns \n into
\r\n, so a text field's length can differ from what the browser measured,
and only when the request happens to be multipart.
The general lesson is the one we keep relearning in different clothes. Whenever two layers each enforce a limit on "the same" value, ask whether they are looking at the same bytes. Between a browser and a server there is a serialiser, a transport, a parser and a decoder, and each one is allowed to change the representation. Line endings. Unicode normalisation. Trailing whitespace. Percent-encoding. Any of them can turn "5,982" into "6,003" without anyone writing a bug.
The fix pattern is always the same: pick one canonical form, normalise to it at the boundary where you receive the value, and only then measure. If your frontend and backend validators are supposed to agree (ours are literally parity-tested for the limits themselves), they also have to agree on what they are measuring. The limits matched perfectly. The strings did not.
