An official website of the Disclosure Foundation
Datasets

v1 · Previous stable standard

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

api/observations/route.ts
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:

Terminal
npx @disclosureos/cli validate ./import/*.json

Reading 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. validateObservation and parseEnrichedObservation check structure; dangling claim refs (a claim citing sensor:x when no such sensor exists) are surfaced as warnings by disclosureos 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.

On this page

The decision
Pattern: API ingestion
Pattern: form input
Pattern: batch import
Reading issues
What validation does not do
The one rule