0011 — Validation is seven layers with two opposite trust models
Status: Accepted · 2026-08-08
Context
Section titled “Context”“Validate the input” is one sentence covering at least seven genuinely different problems in oto, and the two most important of them want opposite behaviour.
An API request body is written by an authenticated user who can read the error and fix it. Rejecting it loudly with a precise field path is the correct, kind response.
An Alertmanager webhook body is written by software oto does not control, over a protocol whose
published documentation is incomplete (routeLabels is on the wire and absent from the docs), and
whose retry classifier permanently deletes any notification answered with a 4xx. Applying
API-style strictness there — DisallowUnknownFields, reject-on-first-violation, 400 on a bad
label — silently destroys alerts during the exact window when the customer’s cluster is on fire.
A single shared “validation layer” is therefore not a simplification; it is a defect.
Separately: bounds get written down three times (a struct tag, a domain constructor, a DDL CHECK) and drift. When they drift, a 422 becomes a 500 — the user is told “internal error” for something they could have fixed.
Decision
Section titled “Decision”Seven layers, each with a named library, a named trust model and a named failure shape (SPEC §L).
- API DTOs —
go-playground/validator/v10, invoked only throughhttpx.Bind[T]so no handler can forget.RegisterTagNameFuncmakes every reported path a JSON name.DisallowUnknownFieldsis ON. Result: 422 + RFC 9457violations[]of{field, code, message}, with a closed tag→code map. - Untrusted inbound — hand-written bounds (B1–B17, every value literal). Lenient decode,
hard bounds, per-alert rejection into
ingest_rejections, and still 202. The only permitted 4xx are 401, 413 and undecodable-400, all genuinely permanent. Redaction runs before the raw persist. - Domain invariants — value objects with unexported fields and
New…() (T, error)constructors; a single totalTransitionfunction is the only way an occurrence changes state. There is no optionalValidate()anywhere. Illegal states are unrepresentable. - Provider config —
santhosh-tekuri/jsonschema/v6, draft 2020-12, compiled fromembed.FSat boot. The same bytes validate server-side and render the settings form, so there is one source of truth and the UI needs no code when a provider is added. - Outbound render — 18 explicit Block Kit checks before any Slack call. A violation is a
deaddelivery with the offending payload persisted, never a truncated send. - Persistence — CHECK/NOT NULL/UNIQUE/FK as the last line of defence. A CHECK violation reaching HTTP is a 500 and an alert, because it means layers 1–3 have a hole.
- Frontend —
valibot(tree-shakeable, ~10× smaller than zod), used for forms and for parsing API responses. Responses parselooseObject(additive server changes must not break a deployed UI); forms parsestrictObject.
Drift is closed by four CI gates: Go DTOs → OpenAPI, server → OpenAPI (schemathesis), OpenAPI →
TS types, OpenAPI → valibot — plus TestValidatorMatchesDDL, which asserts every custom-rule
regex is byte-identical to its DDL CHECK.
The repository layer never validates a business rule (that is the service’s job; duplicating
it produces two subtly different rulebooks) but does reject malformed row models and is the
single place SQLSTATEs become errs.Kind.
Consequences
Section titled “Consequences”- A bound now lives in three files, and changing one without the others fails CI. That friction is the feature: it is what stops a 422 silently becoming a 500.
- Error responses are uniform enough that the SolidJS form layer maps
violations[].fieldonto controls mechanically, with no per-endpoint code. - The ingest path has a property test (
TestIngestNever4xx) and a fuzz target, because “we returned 400 once” is a data-loss incident, not a bug report. - Seven layers is more machinery than a small service needs. It is justified here because oto’s entire premise is “never lies”, and because half the input is hostile by construction.
Alternatives rejected
Section titled “Alternatives rejected”- One shared validation layer: would apply user-input strictness to Alertmanager payloads and delete alerts. Non-viable.
ozzo-validation/ hand-rolled: nicer for conditional rules, but far less ecosystem support and noRegisterTagNameFuncequivalent, which is what makes JSON field paths free.zodon the frontend: ubiquitous, but 3–4× the bundle for identical ergonomics on a dashboard where the alert table is already the bundle budget.- Generating Go DTOs from OpenAPI (
oapi-codegen): would remove gate G1, but reintroduces the codegen coordination dependency between independent implementation agents that C19/C20 removed. A drift test is cheaper than a drift generator here. - Validating only at the edge: the classic mistake. It makes every internal caller a trusted caller, and workers (reconciler, reaper, notifier) are internal callers that write domain state.