[object Object]

← back to Homesonspec

shared utilities (canonicalKey/geo/queue) + zod schemas + validation engine with 10 rules, 31 unit tests green

d6358033658b73e4687f9ff7ef9a3707566130d8 · 2026-07-22 11:19:55 -0700 · Steve Abrams

Files touched

Diff

commit d6358033658b73e4687f9ff7ef9a3707566130d8
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Jul 22 11:19:55 2026 -0700

    shared utilities (canonicalKey/geo/queue) + zod schemas + validation engine with 10 rules, 31 unit tests green
---
 packages/schemas/src/index.ts         | 134 ++++++++++++++++++++++
 packages/shared/src/canonical.test.ts |  47 ++++++++
 packages/shared/src/canonical.ts      |  74 ++++++++++++
 packages/shared/src/geo.test.ts       |  45 ++++++++
 packages/shared/src/geo.ts            |  83 ++++++++++++++
 packages/shared/src/index.ts          |   3 +
 packages/shared/src/queue.ts          |  42 +++++++
 packages/validation/src/engine.ts     |  41 +++++++
 packages/validation/src/index.ts      |   3 +
 packages/validation/src/rules.test.ts | 177 +++++++++++++++++++++++++++++
 packages/validation/src/rules.ts      | 208 ++++++++++++++++++++++++++++++++++
 packages/validation/src/types.ts      |  65 +++++++++++
 12 files changed, 922 insertions(+)

diff --git a/packages/schemas/src/index.ts b/packages/schemas/src/index.ts
new file mode 100644
index 0000000..57522c6
--- /dev/null
+++ b/packages/schemas/src/index.ts
@@ -0,0 +1,134 @@
+import { z } from "zod";
+
+/**
+ * FieldValue — the per-field evidence envelope. Every extracted fact carries
+ * its own provenance. `value: null` means the source did not state it;
+ * values are NEVER inferred or guessed.
+ */
+export const fieldValueSchema = <T extends z.ZodTypeAny>(valueSchema: T) =>
+  z.object({
+    value: valueSchema.nullable(),
+    raw: z.string().nullable(),
+    evidenceText: z.string().nullable(),
+    sourceUrl: z.string(),
+    confidence: z.number().min(0).max(1),
+  });
+
+export type FieldValue<V> = {
+  value: V | null;
+  raw: string | null;
+  evidenceText: string | null;
+  sourceUrl: string;
+  confidence: number;
+};
+
+export const entityTypeSchema = z.enum([
+  "community",
+  "floor_plan",
+  "inventory_home",
+  "incentive",
+  "sales_office",
+  "lot",
+]);
+export type ExtractedEntityType = z.infer<typeof entityTypeSchema>;
+
+export const canonicalHintsSchema = z.object({
+  builderSlug: z.string().min(1),
+  communityName: z.string().nullish(),
+  address: z.string().nullish(),
+  lotNumber: z.string().nullish(),
+  builderInventoryId: z.string().nullish(),
+  lat: z.number().nullish(),
+  lon: z.number().nullish(),
+  planName: z.string().nullish(),
+});
+
+export const constructionStatusSchema = z.enum([
+  "PLANNED",
+  "UNDER_CONSTRUCTION",
+  "MOVE_IN_READY",
+]);
+
+export const homeTypeSchema = z.enum([
+  "SINGLE_FAMILY",
+  "TOWNHOME",
+  "CONDO",
+  "DUPLEX",
+  "OTHER",
+]);
+
+/** Payload shape for an extracted inventory home (staged, pre-publish). */
+export const inventoryHomePayloadSchema = z.object({
+  street: fieldValueSchema(z.string()),
+  city: fieldValueSchema(z.string()),
+  state: fieldValueSchema(z.string().length(2)),
+  zip: fieldValueSchema(z.string().regex(/^\d{5}$/)),
+  price: fieldValueSchema(z.number().positive()),
+  beds: fieldValueSchema(z.number().int()),
+  bathsTotal: fieldValueSchema(z.number()),
+  sqft: fieldValueSchema(z.number().int()),
+  stories: fieldValueSchema(z.number().int()),
+  garageSpaces: fieldValueSchema(z.number().int()),
+  homeType: fieldValueSchema(homeTypeSchema),
+  constructionStatus: fieldValueSchema(constructionStatusSchema),
+  estCompletionDate: fieldValueSchema(z.string()), // ISO date string
+  lotNumber: fieldValueSchema(z.string()),
+  builderInventoryId: fieldValueSchema(z.string()),
+  lat: fieldValueSchema(z.number()),
+  lon: fieldValueSchema(z.number()),
+  planName: fieldValueSchema(z.string()),
+});
+export type InventoryHomePayload = z.infer<typeof inventoryHomePayloadSchema>;
+
+/** Payload shape for an extracted community. */
+export const communityPayloadSchema = z.object({
+  name: fieldValueSchema(z.string()),
+  street: fieldValueSchema(z.string()),
+  city: fieldValueSchema(z.string()),
+  state: fieldValueSchema(z.string().length(2)),
+  zip: fieldValueSchema(z.string().regex(/^\d{5}$/)),
+  county: fieldValueSchema(z.string()),
+  metro: fieldValueSchema(z.string()),
+  lat: fieldValueSchema(z.number()),
+  lon: fieldValueSchema(z.number()),
+  hoaFeeMonthly: fieldValueSchema(z.number()),
+  schoolDistrict: fieldValueSchema(z.string()),
+  ageRestricted: fieldValueSchema(z.boolean()),
+});
+export type CommunityPayload = z.infer<typeof communityPayloadSchema>;
+
+/** Payload shape for an extracted incentive. */
+export const incentivePayloadSchema = z.object({
+  title: fieldValueSchema(z.string()),
+  description: fieldValueSchema(z.string()),
+  valueUsd: fieldValueSchema(z.number()),
+  expiresAt: fieldValueSchema(z.string()), // ISO date; null → evergreenLabel required
+});
+export type IncentivePayload = z.infer<typeof incentivePayloadSchema>;
+
+export const extractedRecordSchema = z.object({
+  entityType: entityTypeSchema,
+  canonicalHints: canonicalHintsSchema,
+  fields: z.record(z.string(), fieldValueSchema(z.unknown())),
+});
+export type ExtractedRecord = z.infer<typeof extractedRecordSchema>;
+
+const payloadSchemas: Partial<Record<ExtractedEntityType, z.ZodTypeAny>> = {
+  inventory_home: inventoryHomePayloadSchema,
+  community: communityPayloadSchema,
+  incentive: incentivePayloadSchema,
+};
+
+/** Validate a record's fields against its entity-specific payload schema. */
+export function validatePayload(record: ExtractedRecord):
+  | { ok: true }
+  | { ok: false; issues: string[] } {
+  const schema = payloadSchemas[record.entityType];
+  if (!schema) return { ok: true }; // entity types without a strict payload contract yet
+  const parsed = schema.safeParse(record.fields);
+  if (parsed.success) return { ok: true };
+  return {
+    ok: false,
+    issues: parsed.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`),
+  };
+}
diff --git a/packages/shared/src/canonical.test.ts b/packages/shared/src/canonical.test.ts
new file mode 100644
index 0000000..4c90b59
--- /dev/null
+++ b/packages/shared/src/canonical.test.ts
@@ -0,0 +1,47 @@
+import { describe, expect, it } from "vitest";
+import { canonicalKey, normalizeAddress } from "./canonical.js";
+
+describe("normalizeAddress", () => {
+  it("lowercases, strips punctuation, abbreviates street words", () => {
+    expect(normalizeAddress("123 Oak Street")).toBe("123 oak st");
+    expect(normalizeAddress("123 OAK ST.")).toBe("123 oak st");
+    expect(normalizeAddress("456 N. Mesquite Boulevard, #2")).toBe("456 n mesquite blvd 2");
+  });
+  it("returns empty string for null/undefined", () => {
+    expect(normalizeAddress(null)).toBe("");
+    expect(normalizeAddress(undefined)).toBe("");
+  });
+});
+
+describe("canonicalKey", () => {
+  const base = {
+    builderSlug: "meridian-homes",
+    communityName: "Cedar Bend",
+    address: "123 Oak Street",
+    lotNumber: "42",
+    builderInventoryId: "MH-1001",
+    lat: 30.312345,
+    lon: -97.712345,
+    planName: "The Juniper",
+  };
+
+  it("is deterministic", () => {
+    expect(canonicalKey(base)).toBe(canonicalKey({ ...base }));
+  });
+
+  it("collapses cosmetic address differences", () => {
+    expect(canonicalKey(base)).toBe(canonicalKey({ ...base, address: "123 oak st." }));
+  });
+
+  it("collapses sub-meter GPS jitter (5-decimal rounding)", () => {
+    expect(canonicalKey(base)).toBe(canonicalKey({ ...base, lat: 30.312346999 }));
+  });
+
+  it("forks identity on a different lot", () => {
+    expect(canonicalKey(base)).not.toBe(canonicalKey({ ...base, lotNumber: "43" }));
+  });
+
+  it("forks identity on a different builder", () => {
+    expect(canonicalKey(base)).not.toBe(canonicalKey({ ...base, builderSlug: "other-builder" }));
+  });
+});
diff --git a/packages/shared/src/canonical.ts b/packages/shared/src/canonical.ts
new file mode 100644
index 0000000..8df7d41
--- /dev/null
+++ b/packages/shared/src/canonical.ts
@@ -0,0 +1,74 @@
+import { createHash } from "node:crypto";
+
+/**
+ * Canonical inventory identity. The same physical home may surface on a
+ * builder's national site, a division site, a community page, or a feed —
+ * this key collapses them to one record. Components are normalized before
+ * hashing so cosmetic differences ("123 Oak St." vs "123 oak street")
+ * don't fork identities.
+ */
+export interface CanonicalHints {
+  builderSlug: string;
+  communityName?: string | null;
+  address?: string | null;
+  lotNumber?: string | null;
+  builderInventoryId?: string | null;
+  lat?: number | null;
+  lon?: number | null;
+  planName?: string | null;
+}
+
+const STREET_ABBREVIATIONS: Record<string, string> = {
+  street: "st", "st.": "st",
+  avenue: "ave", "ave.": "ave", av: "ave",
+  boulevard: "blvd", "blvd.": "blvd",
+  drive: "dr", "dr.": "dr",
+  lane: "ln", "ln.": "ln",
+  road: "rd", "rd.": "rd",
+  court: "ct", "ct.": "ct",
+  circle: "cir", "cir.": "cir",
+  place: "pl", "pl.": "pl",
+  trail: "trl", "trl.": "trl",
+  parkway: "pkwy", "pkwy.": "pkwy",
+  terrace: "ter", "ter.": "ter",
+  way: "way",
+  north: "n", south: "s", east: "e", west: "w",
+  "n.": "n", "s.": "s", "e.": "e", "w.": "w",
+};
+
+/** Lowercase, strip punctuation, collapse whitespace, abbreviate street words. */
+export function normalizeAddress(address: string | null | undefined): string {
+  if (!address) return "";
+  return address
+    .toLowerCase()
+    .replace(/[#,.]/g, " ")
+    .split(/\s+/)
+    .filter(Boolean)
+    .map((word) => STREET_ABBREVIATIONS[word] ?? word)
+    .join(" ")
+    .trim();
+}
+
+function norm(value: string | null | undefined): string {
+  return (value ?? "").toLowerCase().replace(/\s+/g, " ").trim();
+}
+
+/** Round to 5 decimals (~1.1m) so GPS jitter doesn't fork identities. */
+function latlon5(value: number | null | undefined): string {
+  if (value === null || value === undefined || Number.isNaN(value)) return "";
+  return value.toFixed(5);
+}
+
+export function canonicalKey(hints: CanonicalHints): string {
+  const parts = [
+    norm(hints.builderSlug),
+    norm(hints.communityName),
+    normalizeAddress(hints.address),
+    norm(hints.lotNumber),
+    norm(hints.builderInventoryId),
+    latlon5(hints.lat),
+    latlon5(hints.lon),
+    norm(hints.planName),
+  ];
+  return createHash("sha256").update(parts.join("|")).digest("hex");
+}
diff --git a/packages/shared/src/geo.test.ts b/packages/shared/src/geo.test.ts
new file mode 100644
index 0000000..d7570ea
--- /dev/null
+++ b/packages/shared/src/geo.test.ts
@@ -0,0 +1,45 @@
+import { describe, expect, it } from "vitest";
+import { bboxAround, haversineMiles, latLonInState, stateForZip } from "./geo.js";
+
+describe("bboxAround", () => {
+  it("produces a symmetric box around the center", () => {
+    const box = bboxAround(30.3, -97.7, 10);
+    expect((box.minLat + box.maxLat) / 2).toBeCloseTo(30.3, 6);
+    expect((box.minLon + box.maxLon) / 2).toBeCloseTo(-97.7, 6);
+    expect(box.maxLat - box.minLat).toBeCloseTo(20 / 69, 3);
+  });
+});
+
+describe("haversineMiles", () => {
+  it("Austin to Dallas is ~182 miles", () => {
+    const d = haversineMiles(30.2672, -97.7431, 32.7767, -96.797);
+    expect(d).toBeGreaterThan(170);
+    expect(d).toBeLessThan(195);
+  });
+  it("zero distance for identical points", () => {
+    expect(haversineMiles(30, -97, 30, -97)).toBe(0);
+  });
+});
+
+describe("latLonInState", () => {
+  it("Austin is in TX", () => {
+    expect(latLonInState(30.2672, -97.7431, "TX")).toBe(true);
+  });
+  it("Phoenix is not in TX", () => {
+    expect(latLonInState(33.4484, -112.074, "TX")).toBe(false);
+  });
+  it("unknown state returns null (cannot verify, not failure)", () => {
+    expect(latLonInState(30, -97, "XX")).toBeNull();
+  });
+});
+
+describe("stateForZip", () => {
+  it("787xx is TX, 850xx is AZ, 275xx is NC", () => {
+    expect(stateForZip("78701")).toBe("TX");
+    expect(stateForZip("85001")).toBe("AZ");
+    expect(stateForZip("27513")).toBe("NC");
+  });
+  it("unknown prefix returns null", () => {
+    expect(stateForZip("99999")).toBeNull();
+  });
+});
diff --git a/packages/shared/src/geo.ts b/packages/shared/src/geo.ts
new file mode 100644
index 0000000..5d8aa2a
--- /dev/null
+++ b/packages/shared/src/geo.ts
@@ -0,0 +1,83 @@
+/**
+ * All geo math lives here. v1 uses plain lat/lon Decimal columns + btree
+ * indexes; a later PostGIS adoption swaps this module + one migration
+ * without touching callers.
+ */
+
+export interface BBox {
+  minLat: number;
+  maxLat: number;
+  minLon: number;
+  maxLon: number;
+}
+
+const MILES_PER_DEGREE_LAT = 69.0;
+
+/** Bounding box around a center point. Good enough for search-radius UX. */
+export function bboxAround(lat: number, lon: number, radiusMiles: number): BBox {
+  const dLat = radiusMiles / MILES_PER_DEGREE_LAT;
+  const dLon = radiusMiles / (MILES_PER_DEGREE_LAT * Math.cos((lat * Math.PI) / 180));
+  return {
+    minLat: lat - dLat,
+    maxLat: lat + dLat,
+    minLon: lon - dLon,
+    maxLon: lon + dLon,
+  };
+}
+
+/** Great-circle distance in miles. */
+export function haversineMiles(lat1: number, lon1: number, lat2: number, lon2: number): number {
+  const R = 3958.8;
+  const toRad = (d: number) => (d * Math.PI) / 180;
+  const dLat = toRad(lat2 - lat1);
+  const dLon = toRad(lon2 - lon1);
+  const a =
+    Math.sin(dLat / 2) ** 2 +
+    Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
+  return 2 * R * Math.asin(Math.sqrt(a));
+}
+
+/**
+ * Per-state bounding boxes (contiguous-US states we validate against).
+ * Used by the geo.latlon-in-state rule. Coarse boxes are fine — the rule
+ * catches gross errors (wrong hemisphere, swapped lat/lon), not surveying.
+ */
+export const STATE_BBOXES: Record<string, BBox> = {
+  TX: { minLat: 25.8, maxLat: 36.5, minLon: -106.7, maxLon: -93.5 },
+  AZ: { minLat: 31.3, maxLat: 37.0, minLon: -114.9, maxLon: -109.0 },
+  NC: { minLat: 33.8, maxLat: 36.6, minLon: -84.4, maxLon: -75.4 },
+  FL: { minLat: 24.4, maxLat: 31.0, minLon: -87.7, maxLon: -80.0 },
+  CA: { minLat: 32.5, maxLat: 42.0, minLon: -124.5, maxLon: -114.1 },
+  CO: { minLat: 36.9, maxLat: 41.0, minLon: -109.1, maxLon: -102.0 },
+  GA: { minLat: 30.3, maxLat: 35.0, minLon: -85.7, maxLon: -80.8 },
+  TN: { minLat: 34.9, maxLat: 36.7, minLon: -90.4, maxLon: -81.6 },
+  SC: { minLat: 32.0, maxLat: 35.3, minLon: -83.4, maxLon: -78.5 },
+  NV: { minLat: 35.0, maxLat: 42.0, minLon: -120.0, maxLon: -114.0 },
+};
+
+export function latLonInState(lat: number, lon: number, state: string): boolean | null {
+  const box = STATE_BBOXES[state.toUpperCase()];
+  if (!box) return null; // unknown state → rule reports "cannot verify", not failure
+  return lat >= box.minLat && lat <= box.maxLat && lon >= box.minLon && lon <= box.maxLon;
+}
+
+/**
+ * ZIP3 prefix → state. Covers the prefixes used by our validated states.
+ * The geo.state-zip-agreement rule returns null (cannot verify) for
+ * prefixes not in this table rather than guessing.
+ */
+export const ZIP3_TO_STATE: Record<string, string> = {
+  // Texas (Austin metro + neighbors)
+  "733": "TX", "786": "TX", "787": "TX", "789": "TX", "750": "TX", "751": "TX",
+  "752": "TX", "760": "TX", "770": "TX", "773": "TX", "775": "TX", "782": "TX",
+  // Arizona (Phoenix metro)
+  "850": "AZ", "851": "AZ", "852": "AZ", "853": "AZ", "857": "AZ", "859": "AZ", "863": "AZ",
+  // North Carolina (Raleigh metro + neighbors)
+  "275": "NC", "276": "NC", "277": "NC", "278": "NC", "270": "NC", "271": "NC",
+  "272": "NC", "273": "NC", "274": "NC", "280": "NC", "281": "NC", "282": "NC",
+};
+
+export function stateForZip(zip: string): string | null {
+  const prefix = zip.slice(0, 3);
+  return ZIP3_TO_STATE[prefix] ?? null;
+}
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
new file mode 100644
index 0000000..9d7c174
--- /dev/null
+++ b/packages/shared/src/index.ts
@@ -0,0 +1,3 @@
+export * from "./canonical.js";
+export * from "./geo.js";
+export * from "./queue.js";
diff --git a/packages/shared/src/queue.ts b/packages/shared/src/queue.ts
new file mode 100644
index 0000000..ca6761c
--- /dev/null
+++ b/packages/shared/src/queue.ts
@@ -0,0 +1,42 @@
+/**
+ * Thin queue facade over pg-boss so a later broker swap (BullMQ etc.)
+ * touches this file only. Pipeline stages are pure functions — the queue
+ * merely transports work; tests and the CLI invoke stages directly.
+ */
+import PgBoss from "pg-boss";
+
+let boss: PgBoss | null = null;
+
+export async function getQueue(): Promise<PgBoss> {
+  if (!boss) {
+    const url = process.env.DATABASE_URL;
+    if (!url) throw new Error("DATABASE_URL is required for the job queue");
+    boss = new PgBoss(url);
+    await boss.start();
+  }
+  return boss;
+}
+
+export async function enqueue<T extends object>(jobName: string, payload: T): Promise<string | null> {
+  const queue = await getQueue();
+  await queue.createQueue(jobName);
+  return queue.send(jobName, payload);
+}
+
+export async function work<T extends object>(
+  jobName: string,
+  handler: (payload: T) => Promise<void>,
+): Promise<void> {
+  const queue = await getQueue();
+  await queue.createQueue(jobName);
+  await queue.work<T>(jobName, async (jobs) => {
+    for (const job of jobs) await handler(job.data);
+  });
+}
+
+export async function stopQueue(): Promise<void> {
+  if (boss) {
+    await boss.stop();
+    boss = null;
+  }
+}
diff --git a/packages/validation/src/engine.ts b/packages/validation/src/engine.ts
new file mode 100644
index 0000000..7de159f
--- /dev/null
+++ b/packages/validation/src/engine.ts
@@ -0,0 +1,41 @@
+import type {
+  RuleContext,
+  StagedCandidate,
+  ValidationEventInput,
+  ValidationOutcome,
+  ValidationRule,
+} from "./types.js";
+
+/**
+ * Run every applicable rule and fold the results into one verdict.
+ * Deterministic: same record + same DB state → same verdict, always.
+ *
+ *   any failed error  → rejected
+ *   any failed review → needs_review
+ *   otherwise         → publishable (failed warns log but don't block)
+ */
+export async function runValidation(
+  record: StagedCandidate,
+  rules: ValidationRule[],
+  ctx: RuleContext,
+): Promise<ValidationOutcome> {
+  const events: ValidationEventInput[] = [];
+
+  for (const rule of rules) {
+    if (!rule.entityTypes.includes(record.entityType)) continue;
+    const result = await rule.check(record, ctx);
+    events.push({
+      ruleId: rule.id,
+      severity: rule.severity,
+      passed: result.passed,
+      message: result.passed ? undefined : result.message,
+      details: result.passed ? undefined : result.details,
+    });
+  }
+
+  const failedError = events.some((e) => !e.passed && e.severity === "error");
+  const failedReview = events.some((e) => !e.passed && e.severity === "review");
+
+  const verdict = failedError ? "rejected" : failedReview ? "needs_review" : "publishable";
+  return { events, verdict };
+}
diff --git a/packages/validation/src/index.ts b/packages/validation/src/index.ts
new file mode 100644
index 0000000..c8dcd35
--- /dev/null
+++ b/packages/validation/src/index.ts
@@ -0,0 +1,3 @@
+export * from "./types.js";
+export * from "./rules.js";
+export * from "./engine.js";
diff --git a/packages/validation/src/rules.test.ts b/packages/validation/src/rules.test.ts
new file mode 100644
index 0000000..266f63d
--- /dev/null
+++ b/packages/validation/src/rules.test.ts
@@ -0,0 +1,177 @@
+import { describe, expect, it } from "vitest";
+import { runValidation } from "./engine.js";
+import {
+  ALL_RULES,
+  bigPriceChange,
+  fkIntegrity,
+  incentiveExpirationOrLabel,
+  latLonInStateRule,
+  noDuplicateActiveAddress,
+  plausibleBaths,
+  plausibleBeds,
+  priceWithinBounds,
+  sqftPositive,
+  stateZipAgreement,
+} from "./rules.js";
+import type { RuleContext, RuleDb, StagedCandidate } from "./types.js";
+
+function fv<T>(value: T | null, raw?: string): {
+  value: T | null; raw: string | null; evidenceText: string | null; sourceUrl: string; confidence: number;
+} {
+  return {
+    value,
+    raw: raw ?? (value === null ? null : String(value)),
+    evidenceText: raw ?? null,
+    sourceUrl: "https://fixtures.local/page",
+    confidence: value === null ? 0 : 1,
+  };
+}
+
+function makeHome(fields: Record<string, ReturnType<typeof fv>> = {}): StagedCandidate {
+  return {
+    entityType: "inventory_home",
+    canonicalKey: "test-key-1",
+    canonicalHints: {
+      builderSlug: "meridian-homes",
+      communityName: "Cedar Bend",
+      address: "123 Oak St",
+      lotNumber: "42",
+    },
+    fields: {
+      street: fv("123 Oak St"),
+      city: fv("Austin"),
+      state: fv("TX"),
+      zip: fv("78701"),
+      price: fv(449_990, "$449,990"),
+      beds: fv(4, "4 Beds"),
+      bathsTotal: fv(2.5),
+      sqft: fv(2450),
+      lat: fv(30.3123),
+      lon: fv(-97.7123),
+      ...fields,
+    },
+  };
+}
+
+const stubDb: RuleDb = {
+  activeHomeExistsAtAddress: async () => false,
+  publishedPriceForCanonicalKey: async () => null,
+  communityExists: async () => true,
+  builderExists: async () => true,
+};
+
+const ctx: RuleContext = { db: stubDb, sourceKey: "test", now: new Date("2026-07-22T00:00:00Z") };
+
+describe("price.within-bounds", () => {
+  it("passes in-bounds and null price (never guessed)", async () => {
+    expect((await priceWithinBounds.check(makeHome(), ctx)).passed).toBe(true);
+    expect((await priceWithinBounds.check(makeHome({ price: fv<number>(null) }), ctx)).passed).toBe(true);
+  });
+  it("fails out-of-bounds", async () => {
+    expect((await priceWithinBounds.check(makeHome({ price: fv(12_000) }), ctx)).passed).toBe(false);
+    expect((await priceWithinBounds.check(makeHome({ price: fv(50_000_000) }), ctx)).passed).toBe(false);
+  });
+});
+
+describe("geo.state-zip-agreement", () => {
+  it("passes agreeing pair and unknown prefix (cannot verify ≠ fail)", async () => {
+    expect((await stateZipAgreement.check(makeHome(), ctx)).passed).toBe(true);
+    expect((await stateZipAgreement.check(makeHome({ zip: fv("99999") }), ctx)).passed).toBe(true);
+  });
+  it("fails disagreeing pair", async () => {
+    expect((await stateZipAgreement.check(makeHome({ zip: fv("85001") }), ctx)).passed).toBe(false);
+  });
+});
+
+describe("geo.latlon-in-state", () => {
+  it("passes coords inside the state", async () => {
+    expect((await latLonInStateRule.check(makeHome(), ctx)).passed).toBe(true);
+  });
+  it("fails coords outside the state (e.g. Phoenix coords on a TX record)", async () => {
+    expect(
+      (await latLonInStateRule.check(makeHome({ lat: fv(33.4484), lon: fv(-112.074) }), ctx)).passed,
+    ).toBe(false);
+  });
+});
+
+describe("home.plausible-beds / baths / sqft", () => {
+  it("pass plausible values", async () => {
+    expect((await plausibleBeds.check(makeHome(), ctx)).passed).toBe(true);
+    expect((await plausibleBaths.check(makeHome(), ctx)).passed).toBe(true);
+    expect((await sqftPositive.check(makeHome(), ctx)).passed).toBe(true);
+  });
+  it("fail implausible values", async () => {
+    expect((await plausibleBeds.check(makeHome({ beds: fv(45) }), ctx)).passed).toBe(false);
+    expect((await plausibleBaths.check(makeHome({ bathsTotal: fv(30) }), ctx)).passed).toBe(false);
+    expect((await sqftPositive.check(makeHome({ sqft: fv(0) }), ctx)).passed).toBe(false);
+  });
+});
+
+describe("dup.no-active-address", () => {
+  it("fails when another active record holds the same normalized address", async () => {
+    const dupCtx: RuleContext = {
+      ...ctx,
+      db: { ...stubDb, activeHomeExistsAtAddress: async () => true },
+    };
+    expect((await noDuplicateActiveAddress.check(makeHome(), dupCtx)).passed).toBe(false);
+    expect((await noDuplicateActiveAddress.check(makeHome(), ctx)).passed).toBe(true);
+  });
+});
+
+describe("ref.fk-integrity", () => {
+  it("fails when builder or community is unknown", async () => {
+    const noBuilder: RuleContext = { ...ctx, db: { ...stubDb, builderExists: async () => false } };
+    const noCommunity: RuleContext = { ...ctx, db: { ...stubDb, communityExists: async () => false } };
+    expect((await fkIntegrity.check(makeHome(), noBuilder)).passed).toBe(false);
+    expect((await fkIntegrity.check(makeHome(), noCommunity)).passed).toBe(false);
+    expect((await fkIntegrity.check(makeHome(), ctx)).passed).toBe(true);
+  });
+});
+
+describe("price.big-change", () => {
+  it("flags >15% movement for review; small moves pass", async () => {
+    const withPrev = (prev: number): RuleContext => ({
+      ...ctx,
+      db: { ...stubDb, publishedPriceForCanonicalKey: async () => prev },
+    });
+    expect((await bigPriceChange.check(makeHome(), withPrev(300_000))).passed).toBe(false); // 449990 vs 300k = +50%
+    expect((await bigPriceChange.check(makeHome(), withPrev(445_000))).passed).toBe(true);  // ~1%
+    expect((await bigPriceChange.check(makeHome(), ctx)).passed).toBe(true);                // no prior
+  });
+});
+
+describe("incentive.expiration-or-label", () => {
+  const incentive = (expiresAt: string | null): StagedCandidate => ({
+    entityType: "incentive",
+    canonicalKey: "inc-1",
+    canonicalHints: { builderSlug: "meridian-homes" },
+    fields: { title: fv("Closing cost credit"), expiresAt: fv(expiresAt) },
+  });
+  it("passes a parseable date and a null (maps to evergreen label at publish)", async () => {
+    expect((await incentiveExpirationOrLabel.check(incentive("2026-09-30"), ctx)).passed).toBe(true);
+    expect((await incentiveExpirationOrLabel.check(incentive(null), ctx)).passed).toBe(true);
+  });
+  it("fails an unparseable date", async () => {
+    expect((await incentiveExpirationOrLabel.check(incentive("soon-ish"), ctx)).passed).toBe(false);
+  });
+});
+
+describe("runValidation verdict folding", () => {
+  it("clean record → publishable", async () => {
+    const { verdict, events } = await runValidation(makeHome(), ALL_RULES, ctx);
+    expect(verdict).toBe("publishable");
+    expect(events.length).toBeGreaterThanOrEqual(9);
+  });
+  it("failed error rule → rejected", async () => {
+    const { verdict } = await runValidation(makeHome({ price: fv(12_000) }), ALL_RULES, ctx);
+    expect(verdict).toBe("rejected");
+  });
+  it("failed review rule (big price change) → needs_review", async () => {
+    const reviewCtx: RuleContext = {
+      ...ctx,
+      db: { ...stubDb, publishedPriceForCanonicalKey: async () => 300_000 },
+    };
+    const { verdict } = await runValidation(makeHome(), ALL_RULES, reviewCtx);
+    expect(verdict).toBe("needs_review");
+  });
+});
diff --git a/packages/validation/src/rules.ts b/packages/validation/src/rules.ts
new file mode 100644
index 0000000..c101e92
--- /dev/null
+++ b/packages/validation/src/rules.ts
@@ -0,0 +1,208 @@
+import { latLonInState, stateForZip } from "@spechomes/shared";
+import { fieldValue, type StagedCandidate, type ValidationRule } from "./types.js";
+
+export const VALIDATOR_VERSION = "1.0.0";
+
+/** Configurable market bounds — a $12k or $50M "home" is an extraction error. */
+export const PRICE_BOUNDS = { min: 80_000, max: 5_000_000 };
+
+const homeOnly: StagedCandidate["entityType"][] = ["inventory_home"];
+
+export const priceWithinBounds: ValidationRule = {
+  id: "price.within-bounds",
+  severity: "error",
+  entityTypes: homeOnly,
+  check(record) {
+    const price = fieldValue<number>(record, "price");
+    if (price === null) return { passed: true }; // null price is legal (never guessed); rendering handles it
+    if (price < PRICE_BOUNDS.min || price > PRICE_BOUNDS.max) {
+      return {
+        passed: false,
+        message: `price ${price} outside market bounds [${PRICE_BOUNDS.min}, ${PRICE_BOUNDS.max}]`,
+        details: { price },
+      };
+    }
+    return { passed: true };
+  },
+};
+
+export const stateZipAgreement: ValidationRule = {
+  id: "geo.state-zip-agreement",
+  severity: "error",
+  entityTypes: ["inventory_home", "community"],
+  check(record) {
+    const state = fieldValue<string>(record, "state");
+    const zip = fieldValue<string>(record, "zip");
+    if (!state || !zip) return { passed: true }; // missing data handled elsewhere
+    const expected = stateForZip(zip);
+    if (expected === null) return { passed: true }; // unknown prefix → cannot verify, don't guess
+    if (expected !== state.toUpperCase()) {
+      return {
+        passed: false,
+        message: `zip ${zip} implies state ${expected}, record says ${state}`,
+        details: { zip, state, expected },
+      };
+    }
+    return { passed: true };
+  },
+};
+
+export const latLonInStateRule: ValidationRule = {
+  id: "geo.latlon-in-state",
+  severity: "error",
+  entityTypes: ["inventory_home", "community"],
+  check(record) {
+    const state = fieldValue<string>(record, "state");
+    const lat = fieldValue<number>(record, "lat");
+    const lon = fieldValue<number>(record, "lon");
+    if (!state || lat === null || lon === null) return { passed: true };
+    const inside = latLonInState(lat, lon, state);
+    if (inside === null) return { passed: true }; // no bbox for state → cannot verify
+    if (!inside) {
+      return {
+        passed: false,
+        message: `coordinates (${lat}, ${lon}) fall outside ${state}`,
+        details: { lat, lon, state },
+      };
+    }
+    return { passed: true };
+  },
+};
+
+export const plausibleBeds: ValidationRule = {
+  id: "home.plausible-beds",
+  severity: "error",
+  entityTypes: homeOnly,
+  check(record) {
+    const beds = fieldValue<number>(record, "beds");
+    if (beds === null) return { passed: true };
+    if (beds < 1 || beds > 8 || !Number.isInteger(beds)) {
+      return { passed: false, message: `implausible bedroom count ${beds}`, details: { beds } };
+    }
+    return { passed: true };
+  },
+};
+
+export const plausibleBaths: ValidationRule = {
+  id: "home.plausible-baths",
+  severity: "error",
+  entityTypes: homeOnly,
+  check(record) {
+    const baths = fieldValue<number>(record, "bathsTotal");
+    if (baths === null) return { passed: true };
+    if (baths < 0.5 || baths > 10) {
+      return { passed: false, message: `implausible bathroom count ${baths}`, details: { baths } };
+    }
+    return { passed: true };
+  },
+};
+
+export const sqftPositive: ValidationRule = {
+  id: "home.sqft-positive",
+  severity: "error",
+  entityTypes: homeOnly,
+  check(record) {
+    const sqft = fieldValue<number>(record, "sqft");
+    if (sqft === null) return { passed: true };
+    if (sqft < 400 || sqft > 15_000) {
+      return { passed: false, message: `implausible square footage ${sqft}`, details: { sqft } };
+    }
+    return { passed: true };
+  },
+};
+
+export const noDuplicateActiveAddress: ValidationRule = {
+  id: "dup.no-active-address",
+  severity: "error",
+  entityTypes: homeOnly,
+  async check(record, ctx) {
+    const address = record.canonicalHints.address;
+    if (!address) return { passed: true };
+    const { normalizeAddress } = await import("@spechomes/shared");
+    const normalized = normalizeAddress(address);
+    if (!normalized) return { passed: true };
+    const exists = await ctx.db.activeHomeExistsAtAddress(normalized, record.canonicalKey);
+    if (exists) {
+      return {
+        passed: false,
+        message: `another active inventory record already exists at "${normalized}"`,
+        details: { normalizedAddress: normalized },
+      };
+    }
+    return { passed: true };
+  },
+};
+
+export const fkIntegrity: ValidationRule = {
+  id: "ref.fk-integrity",
+  severity: "error",
+  entityTypes: homeOnly,
+  async check(record, ctx) {
+    const builderSlug = record.canonicalHints.builderSlug;
+    const communityName = record.canonicalHints.communityName;
+    if (!(await ctx.db.builderExists(builderSlug))) {
+      return { passed: false, message: `builder "${builderSlug}" does not exist`, details: { builderSlug } };
+    }
+    if (communityName && !(await ctx.db.communityExists(communityName, builderSlug))) {
+      return {
+        passed: false,
+        message: `community "${communityName}" does not exist for builder "${builderSlug}"`,
+        details: { communityName, builderSlug },
+      };
+    }
+    return { passed: true };
+  },
+};
+
+/** Large price movement is suspicious — publishable only after human review. */
+export const BIG_PRICE_CHANGE_PCT = 0.15;
+
+export const bigPriceChange: ValidationRule = {
+  id: "price.big-change",
+  severity: "review",
+  entityTypes: homeOnly,
+  async check(record, ctx) {
+    const price = fieldValue<number>(record, "price");
+    if (price === null) return { passed: true };
+    const previous = await ctx.db.publishedPriceForCanonicalKey(record.canonicalKey);
+    if (previous === null || previous === 0) return { passed: true };
+    const change = Math.abs(price - previous) / previous;
+    if (change > BIG_PRICE_CHANGE_PCT) {
+      return {
+        passed: false,
+        message: `price moved ${(change * 100).toFixed(1)}% (${previous} → ${price}) — review required`,
+        details: { previous, price, changePct: change },
+      };
+    }
+    return { passed: true };
+  },
+};
+
+export const incentiveExpirationOrLabel: ValidationRule = {
+  id: "incentive.expiration-or-label",
+  severity: "error",
+  entityTypes: ["incentive"],
+  check(record) {
+    const expiresAt = fieldValue<string>(record, "expiresAt");
+    // A null expiration is allowed ONLY because publish maps it to the
+    // explicit "expiration not provided" evergreen label. What is illegal
+    // is a present-but-unparseable date.
+    if (expiresAt !== null && Number.isNaN(Date.parse(expiresAt))) {
+      return { passed: false, message: `unparseable incentive expiration "${expiresAt}"`, details: { expiresAt } };
+    }
+    return { passed: true };
+  },
+};
+
+export const ALL_RULES: ValidationRule[] = [
+  priceWithinBounds,
+  stateZipAgreement,
+  latLonInStateRule,
+  plausibleBeds,
+  plausibleBaths,
+  sqftPositive,
+  noDuplicateActiveAddress,
+  fkIntegrity,
+  bigPriceChange,
+  incentiveExpirationOrLabel,
+];
diff --git a/packages/validation/src/types.ts b/packages/validation/src/types.ts
new file mode 100644
index 0000000..458fb76
--- /dev/null
+++ b/packages/validation/src/types.ts
@@ -0,0 +1,65 @@
+import type { ExtractedEntityType, ExtractedRecord } from "@spechomes/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, 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;
+}

← 1aad19c initial scaffold: pnpm monorepo, Prisma schema (published +  ·  back to Homesonspec  ·  collector adapter contract + meridian-homes fixture adapter 1e9a023 →