Validating at Boundaries
Where to validate, which validator to use, and how to handle issues
Validate once, at the boundary where untrusted data enters — then trust the types inside. This guide covers the practical patterns.
The decision
Pattern: API ingestion
import { parseEnrichedObservation } from '@disclosureos/schema';
export async function POST(request: Request) {
const result = parseEnrichedObservation(await request.json());
if (!result.success) {
return Response.json({ errors: result.issues }, { status: 400 });
}
await db.observations.insert(result.data);
return Response.json({ id: result.data.id }, { status: 201 });
}Default to parseEnrichedObservation at ingestion boundaries even if today's payloads are core-only — the day a partner starts sending observable claims, you'll accept them instead of rejecting (or worse, storing records you never validated the slots of).
Pattern: form input
Guards narrow individual fields before you assemble the record:
import { isObjectShape, isTimeOfDay } from '@disclosureos/records/guards';
import { isConfidence } from '@disclosureos/records/shared';
const shape = isObjectShape(form.shape) ? form.shape : undefined;
const timeOfDay = isTimeOfDay(form.timeOfDay) ? form.timeOfDay : undefined;Then let the factory be the final check — createObservation parses and throws with precise messages.
Pattern: batch import
Collect issues per row instead of failing the batch:
import { validateObservation } from '@disclosureos/records';
const failures: { row: number; issues: ValidationIssue[] }[] = [];
for (const [i, record] of records.entries()) {
const issues = validateObservation(record);
if (issues.length > 0) failures.push({ row: i, issues });
}For files, the CLI does this with formatted output:
npx @disclosureos/cli validate ./import/*.jsonReading issues
Every validator returns the same shape:
[
{ path: 'temporal.date', message: 'Invalid input' },
{ path: 'location.latitude', message: 'Too big: expected number to be <=90' },
{ path: 'origin[0].confidence', message: 'Too big: expected number to be <=1' },
]Paths use dot/index notation and include the slot name for enriched records, so errors can be mapped straight onto form fields or import-report columns.
What validation does not do
- It doesn't check completeness. A record with only the required fields passes. Coverage is measured by completeness scoring, not validity.
- It doesn't resolve evidence refs.
validateObservationandparseEnrichedObservationcheck structure; dangling claim refs (a claim citingsensor:xwhen no such sensor exists) are surfaced as warnings bydisclosureos validate. - It doesn't judge plausibility. The standard records claims and their evidence; it does not adjudicate them.
The one rule
Never .parse() an enriched record with ObservationSchema
Zod strips unknown keys, so the slots vanish silently. validateObservation (non-mutating) and parseEnrichedObservation (slot-aware) exist precisely to avoid this. Full explanation: Type Safety & Validation.