← 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
A packages/schemas/src/index.tsA packages/shared/src/canonical.test.tsA packages/shared/src/canonical.tsA packages/shared/src/geo.test.tsA packages/shared/src/geo.tsA packages/shared/src/index.tsA packages/shared/src/queue.tsA packages/validation/src/engine.tsA packages/validation/src/index.tsA packages/validation/src/rules.test.tsA packages/validation/src/rules.tsA packages/validation/src/types.ts
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 →