The Column Width Belongs in the Validator, Not in MySQL
Length validation must mirror the column width, not leave it to the database. When 31 characters reach the query, MySQL strict mode answers 500.
TL;DR
The KotaPortal admin form accepted 31 characters but threw a 500 error. The tipe_cabor column was declared VARCHAR(30). Validation was added to prevent this error.
There was a moment that always played out the same in this API: the KotaPortal admin form accepted 31 characters, the save button worked, and the screen answered with a 500 Internal Server Error. The tipe_cabor column in api/migrations/000082_create_organizations.up.sql:14 really is declared VARCHAR(30). The first guess was the usual one: the length validation has not been written yet.
That guess was half right and half wrong, and the wrong half is the more interesting one.
What was correct and what missed
What was correct: a field without a limit existed, and validateUpsert in api/internal/organisasi/validator.go ended up carrying one more block:
if len(s) > 30 {
return fmt.Errorf("%w: tipe_cabor maksimal 30 karakter", ErrValidation)
}
The order matters: whitespace is trimmed first, and only then is the length compared against 30. The same cap already lives for kontak at 100 characters and masa_bakti at 20 characters, so the cap list in that file now lines up one for one with the column widths.
What missed: validation is not the only guard standing in front of the column. MySQL 8.0 enables STRICT_TRANS_TABLES as part of its default SQL mode, and the documentation, archived on 2026-10-11 because the live manual page was unavailable, states the position of that mode plainly: "Strict SQL mode applies to the following errors", with the explanation "A value is invalid if it has the wrong data type for the column or might be out of range" [1]. An overlong value is not data that gets truncated quietly; it is a condition that is rejected. A value that passes the validator still meets that limit at the end of the query.
Why the status code decides who gets blamed
This is where the fault sits. The handler has two doors:
if errors.Is(err, ErrValidation) {
response.Error(w, http.StatusBadRequest, "VALIDATION", err.Error())
}
The ErrValidation sentinel decides which door is used. An error that bypasses the validator ends up at the second one, and nothing holds it back there: the query error goes out as-is, as INTERNAL_ERROR, carrying details that do not belong in front of a form user. A 31-character value that reaches the query directly has only two choices left in that file, and the wrong choice carries no warning at all.
The difference is not cosmetic. 400 means the request itself is at fault, and MDN states the consequence: "Clients that receive a 400 response should expect that repeating the request without modification will fail with the same error" [3], so the client stops repeating it and fixes the input. 500 means "the server encountered an unexpected condition that prevented it from fulfilling the request" [4], and a client that receives that has no reason to stop trying the same thing. The value moves from the first group to the second without anybody being told why.
Tests run in pairs
The fix did not stop at the validator. api/tests/unit/organisasi/service_test.go adds the case {"tipe_cabor 31", ...}, which must be rejected, and at the same time extends the boundary request with TipeCabor: strPtr(strings.Repeat("t", 30)) under the note "boundary tepat batas harus lolos" (a boundary exactly at the limit must be accepted). One character of margin is the entire content of that pair: a cap that rejects 30 characters is exactly as broken as no cap at all.
A cap list like this does look like clerical work: 30 here, 100 there, 20 in another column. That is its name. Every time a migration adds a new text column, a cap line joins it and a boundary test joins it beside it. One line in a validator is far cheaper than an incident report that mentions a 500 in front of a user.
OWASP puts the same rule in general form: strings need "Length limits and the characters or structure required by the field" [2], guided by the principle "Define what the application accepts and reject values outside those rules" [2]. The column width is a contract the validator has to carry to its limit, not something handed over to a database error message. What the message looks like afterwards is a separate matter, already covered in the validation message swallowed by a generic error.
Technical note: len(s) on Go text counts bytes while VARCHAR(30) counts characters. For the Latin-accented enum values in use here the two coincide, and that difference was not tested in this cycle.