← 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
M packages/shared/src/index.tsA packages/shared/src/payload-hash.test.tsA packages/shared/src/payload-hash.ts
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 →