An official website of the Disclosure Foundation

Introduction

Overview

Modules

ObservationShared PrimitivesExtension SlotsCompanions

Guides

Composing an ObservationValidating at Boundaries

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.

Composing an Observation

Build a complete record step by step — from minimal facts to citable evidence

On this page

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