Input Validation
Every value that did not originate inside the process is untrusted. This
includes the browser the team wrote, the mobile app the team shipped, and the
partner API the team integrated.
Validation is not a formality applied to request bodies. It is the decision
about which values are allowed to exist inside the system at all.
1. Where validation belongs
At the trusted boundary: the first point where the value crosses into code
that assumes it is well formed. Everything past that point may assume it.
| Boundary |
Trusted point |
| HTTP handler |
before the handler body reads any field |
| server action |
first statement of the action |
| webhook |
after signature verification, before payload use |
| queue consumer |
on message deserialisation |
| CLI |
on argument parsing |
| scheduled job |
on the parameters it reads from storage |
| third party response |
before its fields are used |
Client side validation exists for ergonomics. It never replaces the server
side check, and the server never assumes it ran. Both exist. One is trusted.
2. Protocol
- Enumerate the inputs the change introduces or touches: body fields,
query parameters, route parameters, headers, cookies, form fields,
uploaded files, and anything read from an external response.
- Find the project's validation system. Use it. Introducing a second
validation library into a project that already has one is a defect, not an
improvement.
- Write the schema before the handler logic, so the handler receives a
typed, valid value and never a raw one.
- Decide the failure response: status code, error shape, whether field
level detail is safe to return. Match the shape the project already
returns.
- Apply the type rules of section 3 to every field.
- Add the cross field rules of section 4.
- Run the adversarial matrix of section 5 as tests, not as thought.
- Verify that the handler cannot be reached with an invalid value by any
path, including a second route that shares the same service.
3. Rules by type
| Type |
Mandatory constraints |
| string |
trimmed or explicitly not, minimum, maximum, allowed character set where it matters |
| identifier |
exact format, and ownership checked separately, format is not authorization |
| number |
integer or decimal, minimum, maximum, finite, rejection of NaN and Infinity |
| money |
integer minor units or a decimal type, never a float, currency validated against an allowlist |
| boolean |
real boolean, not a truthy string |
| date |
parseable, bounded range, timezone handling stated, no silent local time assumption |
| enum |
closed allowlist, never a free string compared later |
| array |
maximum length, element schema, duplicate policy stated |
| object |
unknown keys rejected or stripped, decided explicitly |
| file |
size limit, MIME sniffed from content not from the header, extension allowlist, filename sanitised, storage path never derived from the client name |
| URL |
scheme allowlist, host allowlist when fetched server side, no redirect following into private ranges |
| email |
one validation, then normalised, never used as a display value unescaped |
| phone |
normalised to a canonical form, country handling stated |
| HTML or rich text |
sanitised with an allowlist at the sink, never with a blocklist |
| JSON string |
parsed inside a guarded block, then validated as an object |
Two rules apply to every type: a maximum length exists, and the value is
rejected rather than coerced when it does not match.
4. Cross field and contextual rules
Field level validity is not request validity.
- range coherence: start before end, minimum below maximum;
- conditional requirement: field B required when field A has a given value;
- mutual exclusion: exactly one of two fields present;
- referential existence: the referenced row exists, checked in the same
transaction as the write that depends on it;
- ownership: the referenced row belongs to the caller, which is authorization
and belongs to
security-audit, but is verified here as a boundary rule;
- state coherence: the transition is legal from the current state;
- quota: the caller has not exceeded a limit;
- idempotency: a repeated request with the same key produces the same effect
once.
5. Adversarial test matrix
Each of these becomes a test, per input, where applicable.
| Case |
Expected |
| missing field |
rejected, not defaulted silently |
| null |
rejected unless nullable is declared |
| empty string |
rejected or trimmed to rejection, decided explicitly |
| whitespace only |
same as empty |
| minimum minus one |
rejected |
| minimum |
accepted |
| maximum |
accepted |
| maximum plus one |
rejected |
| wrong type |
rejected, never coerced |
| extremely long value |
rejected before it reaches storage or a log |
| Unicode, emoji, combining marks, right to left marks |
handled, length counted in a stated unit |
| null bytes and control characters |
rejected |
| leading zeros, plus signs, scientific notation on numbers |
rejected or normalised, decided |
| numeric string where a number is expected |
rejected or coerced, decided |
| duplicate array elements |
policy applied |
| deeply nested object |
depth limit enforced |
prototype polluting keys such as __proto__ and constructor |
rejected or stripped |
| SQL metacharacters |
stored safely, never concatenated |
| script tags and event handler attributes |
escaped at the render sink |
| path traversal sequences |
rejected, path resolved and confirmed inside the root |
| a URL pointing at localhost, a private range, or a cloud metadata address |
rejected when fetched server side |
| two identical requests sent concurrently |
one effect, or a documented conflict |
6. Prohibitions
- No validation in the client only.
- No blocklist where an allowlist is possible.
- No sanitising instead of rejecting, when rejecting is possible.
- No silent coercion that changes the meaning of the value.
- No validation library added when the project already has one.
- No schema declared and never applied. A declared schema without a call to
parse at the boundary is worse than none, because it looks safe.
- No error message that reveals internal structure, existence of an account,
or the reason a lookup failed when that reason is sensitive.
7. Deliverable
Inputs every field the change accepts, with its source
Schema the schema file and where it is parsed
Failure status code, error shape, what is disclosed
Cross field the rules and where they run
Tests the matrix cases turned into tests
Gaps inputs left unvalidated, with the reason, or none
8. Auto-critique
Score from 0 to 5: coverage of every input, validation at the correct
boundary, use of the project's existing system, correctness of the type rules,
cross field completeness, adversarial cases actually turned into tests, no
information disclosure in errors.
Threshold: no axis below 3, average at least 4. Any input reaching a side
effect unvalidated is an automatic failure regardless of other scores.
9. Interfaces
- Upstream:
project-exploration, architecture-design.
- Lateral:
backend-engineering, frontend-engineering,
fullstack-engineering.
- Downstream:
testing-quality for the matrix, security-audit for the
authorization half, code-review-protocol for verification.
1---2name: input-validation3description: Input Validation4---56# Input Validation78Every value that did not originate inside the process is untrusted. This9includes the browser the team wrote, the mobile app the team shipped, and the10partner API the team integrated.1112Validation is not a formality applied to request bodies. It is the decision13about which values are allowed to exist inside the system at all.1415## 1. Where validation belongs1617At the trusted boundary: the first point where the value crosses into code18that assumes it is well formed. Everything past that point may assume it.1920| Boundary | Trusted point |21|---|---|22| HTTP handler | before the handler body reads any field |23| server action | first statement of the action |24| webhook | after signature verification, before payload use |25| queue consumer | on message deserialisation |26| CLI | on argument parsing |27| scheduled job | on the parameters it reads from storage |28| third party response | before its fields are used |2930Client side validation exists for ergonomics. It never replaces the server31side check, and the server never assumes it ran. Both exist. One is trusted.3233## 2. Protocol34351. **Enumerate the inputs** the change introduces or touches: body fields,36 query parameters, route parameters, headers, cookies, form fields,37 uploaded files, and anything read from an external response.382. **Find the project's validation system.** Use it. Introducing a second39 validation library into a project that already has one is a defect, not an40 improvement.413. **Write the schema** before the handler logic, so the handler receives a42 typed, valid value and never a raw one.434. **Decide the failure response**: status code, error shape, whether field44 level detail is safe to return. Match the shape the project already45 returns.465. **Apply the type rules** of section 3 to every field.476. **Add the cross field rules** of section 4.487. **Run the adversarial matrix** of section 5 as tests, not as thought.498. **Verify** that the handler cannot be reached with an invalid value by any50 path, including a second route that shares the same service.5152## 3. Rules by type5354| Type | Mandatory constraints |55|---|---|56| string | trimmed or explicitly not, minimum, maximum, allowed character set where it matters |57| identifier | exact format, and ownership checked separately, format is not authorization |58| number | integer or decimal, minimum, maximum, finite, rejection of NaN and Infinity |59| money | integer minor units or a decimal type, never a float, currency validated against an allowlist |60| boolean | real boolean, not a truthy string |61| date | parseable, bounded range, timezone handling stated, no silent local time assumption |62| enum | closed allowlist, never a free string compared later |63| array | maximum length, element schema, duplicate policy stated |64| object | unknown keys rejected or stripped, decided explicitly |65| file | size limit, MIME sniffed from content not from the header, extension allowlist, filename sanitised, storage path never derived from the client name |66| URL | scheme allowlist, host allowlist when fetched server side, no redirect following into private ranges |67| email | one validation, then normalised, never used as a display value unescaped |68| phone | normalised to a canonical form, country handling stated |69| HTML or rich text | sanitised with an allowlist at the sink, never with a blocklist |70| JSON string | parsed inside a guarded block, then validated as an object |7172Two rules apply to every type: a maximum length exists, and the value is73rejected rather than coerced when it does not match.7475## 4. Cross field and contextual rules7677Field level validity is not request validity.7879- range coherence: start before end, minimum below maximum;80- conditional requirement: field B required when field A has a given value;81- mutual exclusion: exactly one of two fields present;82- referential existence: the referenced row exists, checked in the same83 transaction as the write that depends on it;84- ownership: the referenced row belongs to the caller, which is authorization85 and belongs to `security-audit`, but is verified here as a boundary rule;86- state coherence: the transition is legal from the current state;87- quota: the caller has not exceeded a limit;88- idempotency: a repeated request with the same key produces the same effect89 once.9091## 5. Adversarial test matrix9293Each of these becomes a test, per input, where applicable.9495| Case | Expected |96|---|---|97| missing field | rejected, not defaulted silently |98| null | rejected unless nullable is declared |99| empty string | rejected or trimmed to rejection, decided explicitly |100| whitespace only | same as empty |101| minimum minus one | rejected |102| minimum | accepted |103| maximum | accepted |104| maximum plus one | rejected |105| wrong type | rejected, never coerced |106| extremely long value | rejected before it reaches storage or a log |107| Unicode, emoji, combining marks, right to left marks | handled, length counted in a stated unit |108| null bytes and control characters | rejected |109| leading zeros, plus signs, scientific notation on numbers | rejected or normalised, decided |110| numeric string where a number is expected | rejected or coerced, decided |111| duplicate array elements | policy applied |112| deeply nested object | depth limit enforced |113| prototype polluting keys such as `__proto__` and `constructor` | rejected or stripped |114| SQL metacharacters | stored safely, never concatenated |115| script tags and event handler attributes | escaped at the render sink |116| path traversal sequences | rejected, path resolved and confirmed inside the root |117| a URL pointing at localhost, a private range, or a cloud metadata address | rejected when fetched server side |118| two identical requests sent concurrently | one effect, or a documented conflict |119120## 6. Prohibitions121122- No validation in the client only.123- No blocklist where an allowlist is possible.124- No sanitising instead of rejecting, when rejecting is possible.125- No silent coercion that changes the meaning of the value.126- No validation library added when the project already has one.127- No schema declared and never applied. A declared schema without a call to128 parse at the boundary is worse than none, because it looks safe.129- No error message that reveals internal structure, existence of an account,130 or the reason a lookup failed when that reason is sensitive.131132## 7. Deliverable133134```135Inputs every field the change accepts, with its source136Schema the schema file and where it is parsed137Failure status code, error shape, what is disclosed138Cross field the rules and where they run139Tests the matrix cases turned into tests140Gaps inputs left unvalidated, with the reason, or none141```142143## 8. Auto-critique144145Score from 0 to 5: coverage of every input, validation at the correct146boundary, use of the project's existing system, correctness of the type rules,147cross field completeness, adversarial cases actually turned into tests, no148information disclosure in errors.149150Threshold: no axis below 3, average at least 4. Any input reaching a side151effect unvalidated is an automatic failure regardless of other scores.152153## 9. Interfaces154155- Upstream: `project-exploration`, `architecture-design`.156- Lateral: `backend-engineering`, `frontend-engineering`,157 `fullstack-engineering`.158- Downstream: `testing-quality` for the matrix, `security-audit` for the159 authorization half, `code-review-protocol` for verification.