← back to Homesonspec

packages/validation/src/types.ts

66 lines

import type { ExtractedEntityType, ExtractedRecord } from "@homesonspec/schemas";

/**
 * Deterministic validation layer. Rules — not the LLM — decide whether a
 * staged record can publish.
 *
 * Severity semantics:
 *   error  → blocks publish (verdict: rejected)
 *   review → publishable only after human approval (verdict: needs_review)
 *   warn   → publishes, logged as a ValidationEvent
 */
export type Severity = "error" | "review" | "warn";

export type Verdict = "publishable" | "needs_review" | "rejected";

export interface RuleContext {
  /** Read-only DB lookups (dup check, prior price, FK existence). */
  db: RuleDb;
  sourceKey: string;
  now: Date;
}

/** The narrow read-only surface rules may touch — keeps rules testable with a stub. */
export interface RuleDb {
  activeHomeExistsAtAddress(normalizedAddress: string, zip: string | null, excludeCanonicalKey: string): Promise<boolean>;
  publishedPriceForCanonicalKey(canonicalKey: string): Promise<number | null>;
  communityExists(communitySlugOrName: string, builderSlug: string): Promise<boolean>;
  builderExists(builderSlug: string): Promise<boolean>;
}

export type RuleResult =
  | { passed: true }
  | { passed: false; message: string; details?: Record<string, unknown> };

export interface ValidationRule {
  id: string;
  severity: Severity;
  entityTypes: ExtractedEntityType[];
  check(record: StagedCandidate, ctx: RuleContext): RuleResult | Promise<RuleResult>;
}

/** What the pipeline hands to validation: extracted record + canonical key. */
export interface StagedCandidate extends ExtractedRecord {
  canonicalKey: string;
}

export interface ValidationEventInput {
  ruleId: string;
  severity: Severity;
  passed: boolean;
  message?: string;
  details?: Record<string, unknown>;
}

export interface ValidationOutcome {
  events: ValidationEventInput[];
  verdict: Verdict;
}

/** Helper: pull a typed field value out of the FieldValue envelope. */
export function fieldValue<T>(record: StagedCandidate, field: string): T | null {
  const entry = record.fields[field];
  if (!entry) return null;
  return (entry.value as T) ?? null;
}