[object Object]

← back to Homesonspec

TK-11125: add stableStringify + payloadHash canonical hash helper (shared)

671fbb261a5eb3ef46fff05e468303ba0a0321a3 · 2026-09-02 13:49:09 -0700 · Steve Abrams

Forward-only dedup guard support (spec TK-11125-impl-spec-2026-09-02).
Canonical key-sorted JSON hash so a payload compared across a Postgres
JSONB round-trip is order-independent; any real change yields a different
hash (guard can only ever SKIP on exact match, never suppress a change).
Pure code, no schema change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016JRL7REtkaBnRrfHk42iYm

Files touched

Diff

commit 671fbb261a5eb3ef46fff05e468303ba0a0321a3
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Sep 2 13:49:09 2026 -0700

    TK-11125: add stableStringify + payloadHash canonical hash helper (shared)
    
    Forward-only dedup guard support (spec TK-11125-impl-spec-2026-09-02).
    Canonical key-sorted JSON hash so a payload compared across a Postgres
    JSONB round-trip is order-independent; any real change yields a different
    hash (guard can only ever SKIP on exact match, never suppress a change).
    Pure code, no schema change.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_016JRL7REtkaBnRrfHk42iYm
---
 packages/shared/src/index.ts             |  1 +
 packages/shared/src/payload-hash.test.ts | 72 ++++++++++++++++++++++++++++++++
 packages/shared/src/payload-hash.ts      | 46 ++++++++++++++++++++
 3 files changed, 119 insertions(+)

diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index 3c1fe390..657f0724 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -1,3 +1,4 @@
 export * from "./canonical";
 export * from "./geo";
+export * from "./payload-hash";
 export * from "./queue";
diff --git a/packages/shared/src/payload-hash.test.ts b/packages/shared/src/payload-hash.test.ts
new file mode 100644
index 00000000..76b54b30
--- /dev/null
+++ b/packages/shared/src/payload-hash.test.ts
@@ -0,0 +1,72 @@
+import { describe, expect, it } from "vitest";
+import { payloadHash, stableStringify } from "./payload-hash";
+
+// A FieldValue-shaped envelope, the real payload shape (schemas: FieldValue).
+const fv = (value: unknown, raw: string | null = null) => ({
+  value,
+  raw,
+  evidenceText: raw,
+  sourceUrl: "fixture://x",
+  confidence: value === null ? 0 : 1,
+});
+
+describe("stableStringify", () => {
+  it("is independent of object key insertion order (the JSONB round-trip case)", () => {
+    const a = { price: fv(500000), beds: fv(4), city: fv("Leander") };
+    const b = { city: fv("Leander"), beds: fv(4), price: fv(500000) };
+    expect(stableStringify(a)).toBe(stableStringify(b));
+  });
+
+  it("sorts keys at every nesting depth", () => {
+    expect(stableStringify({ b: { z: 1, a: 2 }, a: 1 })).toBe(
+      '{"a":1,"b":{"a":2,"z":1}}',
+    );
+  });
+
+  it("preserves array order (order is semantic)", () => {
+    expect(stableStringify([3, 1, 2])).toBe("[3,1,2]");
+    expect(stableStringify([1, 2, 3])).not.toBe(stableStringify([3, 2, 1]));
+  });
+
+  it("preserves null and distinguishes it from missing", () => {
+    expect(stableStringify({ a: null })).toBe('{"a":null}');
+    expect(stableStringify(null)).toBe("null");
+    // undefined property is dropped (JSON semantics)
+    expect(stableStringify({ a: undefined, b: 1 })).toBe('{"b":1}');
+  });
+
+  it("handles primitives", () => {
+    expect(stableStringify("x")).toBe('"x"');
+    expect(stableStringify(42)).toBe("42");
+    expect(stableStringify(true)).toBe("true");
+  });
+});
+
+describe("payloadHash", () => {
+  it("gives identical hashes for reordered-but-equal payloads (skip case)", () => {
+    const p1 = { price: fv(500000), beds: fv(4) };
+    const p2 = { beds: fv(4), price: fv(500000) };
+    expect(payloadHash(p1)).toBe(payloadHash(p2));
+  });
+
+  it("gives a DIFFERENT hash for a real field change (never wrongly skips)", () => {
+    const base = { price: fv(500000), beds: fv(4) };
+    const priceDrop = { price: fv(450000), beds: fv(4) };
+    const bedsUp = { price: fv(500000), beds: fv(5) };
+    const addedField = { price: fv(500000), beds: fv(4), sqft: fv(2415) };
+    expect(payloadHash(priceDrop)).not.toBe(payloadHash(base));
+    expect(payloadHash(bedsUp)).not.toBe(payloadHash(base));
+    expect(payloadHash(addedField)).not.toBe(payloadHash(base));
+  });
+
+  it("distinguishes a changed provenance envelope even when value is equal", () => {
+    // raw/evidenceText/confidence are part of the stored payload → part of identity.
+    const a = { price: { value: 500000, raw: "$500,000", evidenceText: null, sourceUrl: "u", confidence: 1 } };
+    const b = { price: { value: 500000, raw: "$500000", evidenceText: null, sourceUrl: "u", confidence: 1 } };
+    expect(payloadHash(a)).not.toBe(payloadHash(b));
+  });
+
+  it("returns a 64-char sha256 hex", () => {
+    expect(payloadHash({ a: fv(1) })).toMatch(/^[0-9a-f]{64}$/);
+  });
+});
diff --git a/packages/shared/src/payload-hash.ts b/packages/shared/src/payload-hash.ts
new file mode 100644
index 00000000..663cbbcf
--- /dev/null
+++ b/packages/shared/src/payload-hash.ts
@@ -0,0 +1,46 @@
+import { createHash } from "node:crypto";
+
+/**
+ * Canonical, key-sorted JSON serialization. Two structurally-equal values
+ * always produce the same string regardless of key insertion order — which is
+ * essential because the same object round-tripped through Postgres JSONB comes
+ * back with keys in an arbitrary order. Arrays keep their order (order is
+ * semantically meaningful there); object keys are sorted lexicographically at
+ * every depth. `undefined` object properties are dropped (JSON semantics);
+ * `null` is preserved.
+ */
+export function stableStringify(value: unknown): string {
+  if (value === null || typeof value !== "object") {
+    // Primitives + null: JSON.stringify handles number/string/boolean/null.
+    // (undefined at the top level yields "undefined" — callers pass objects.)
+    return JSON.stringify(value) ?? "null";
+  }
+  if (Array.isArray(value)) {
+    return `[${value.map((v) => stableStringify(v)).join(",")}]`;
+  }
+  const obj = value as Record<string, unknown>;
+  const parts: string[] = [];
+  for (const key of Object.keys(obj).sort()) {
+    const v = obj[key];
+    if (v === undefined) continue; // match JSON.stringify: drop undefined props
+    parts.push(`${JSON.stringify(key)}:${stableStringify(v)}`);
+  }
+  return `{${parts.join(",")}}`;
+}
+
+/**
+ * Stable content hash of an extracted per-field payload (the `normalized.fields`
+ * envelope map, which is what StagedRecord.payload stores verbatim). Used by the
+ * extract stage to detect that a *new* raw snapshot carries byte-identical
+ * property data as the latest generation — i.e. only volatile page bytes moved,
+ * not the actual data — so a duplicate StagedRecord generation can be skipped
+ * (TK-11125, forward-only, history-preserving).
+ *
+ * Hashing the canonical form (not raw JSON text) makes the comparison immune to
+ * JSONB key reordering. Any genuine difference produces a different hash, so the
+ * caller falls through and mints a new generation — the guard can only ever
+ * *skip* on an exact canonical match, never suppress a real change.
+ */
+export function payloadHash(fields: unknown): string {
+  return createHash("sha256").update(stableStringify(fields)).digest("hex");
+}

← c97856c6 defer SourceEvidence reindex pending bloat evidence  ·  back to Homesonspec  ·  TK-11125: forward-only spurious-generation guard in extractS 319168ad →