Skip to content

Commit c3c1c8a

Browse files
authored
refactor: deterministic state-file key ordering (#15)
## ELI5 **Problem.** Every push rewrites `.vapi-state.<env>.json`. JavaScript's `JSON.stringify` keeps whatever order keys happened to land in — and state sections get rebuilt from multiple sources (push, pull, bootstrap) with unpredictable insertion order. Result: about half of every state diff is just lines moving up and down without any actual change. Reviewers stopped reading state diffs because they were mostly noise, which defeats the point of versioning the file. **What this fix does.** Adds a `sortedKeysReplacer` that runs during `JSON.stringify` and emits object keys alphabetically at every nesting level. Arrays stay in their original order (squad member ordering, tool destination priority, etc. are semantic). State writes go through this replacer. **Outcome you'll notice.** The first push after this lands produces a **big one-time diff** of pure reordering across every customer. That's the cost of landing the fix — please don't read the first state diff post-merge, it's churn. Every diff after that shows only real changes: new UUIDs, removed entries, hashes changing. Reviewing state files becomes useful again. --- JS's JSON.stringify honors insertion order. State sections get rebuilt from multiple sources (push, pull, bootstrap) with unpredictable insertion order, so ~half of every state-file diff is pure reorderings that hide the real changes. - src/state-serialize.ts (NEW): sortedKeysReplacer (recursive alphabetical key sort, arrays untouched) + canonicalize (also drops null/undefined leaves; reused by Stack F/G). Kept config-free so tests can import without triggering config.ts's CLI parser. - src/state.ts: saveState now passes sortedKeysReplacer to JSON.stringify. Atomic-write pattern preserved. - tests/state-key-order.test.ts: pin byte-identical serialization across insertion orders, recursion, array preservation, primitive handling, idempotence. Closes improvements.md #17. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
1 parent c7aa30b commit c3c1c8a

4 files changed

Lines changed: 160 additions & 2 deletions

File tree

improvements.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ you which stack PR closes the row.**
6868
| 14 | Multi-file push undocumented | Discoverability | None | RESOLVED 2026-04-30 (Stack A) |
6969
| 15 | Scoped push rewrites entire state file | Pre-existing drift sweeps into focused commits | #4 | Open (Stack J planned) |
7070
| 16 | No CLI runner for simulation suites | Engine pushes them, can't run them | None | Open (Stack E planned) |
71-
| 17 | State file key-order churn produces noisy diffs | Reorderings hide real changes | None | Open (Stack B planned) |
71+
| 17 | State file key-order churn produces noisy diffs | Reorderings hide real changes | None | RESOLVED 2026-04-30 (Stack B) |
7272
| 18 | Structured-output `name` capped at 40 chars (no warning) | Push fails partway after partial application | None | Open (Stack D planned) |
7373
| 19 | No `maxTokens` floor warning for tool-using assistants | `maxTokens: 1` bricks the assistant silently | None | Open (Stack D planned) |
7474
| 20 | Prompt vocabulary leaks into TTS | `Reason.` becomes verbal contaminant | None | Open (Stack D heuristic planned) |

src/state-serialize.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
// Pure serialization helpers for the state file (and snapshot files).
2+
//
3+
// Kept config-free so tests can import without triggering the CLI argument
4+
// parser in `config.ts` (which `process.exit(1)`s when no env is supplied).
5+
6+
// JSON.stringify replacer that emits object keys in alphabetical order at
7+
// every nesting level. Without this, the state file diff includes pure
8+
// reorderings every time a resource map gets rebuilt from multiple sources
9+
// (push, pull, bootstrap) — about half the diff lines are insertion-order
10+
// churn rather than semantic change. Reviewers stop reading state diffs
11+
// closely as a result, which defeats the point of versioning the file.
12+
//
13+
// Arrays are returned as-is so existing array order (e.g. squad members,
14+
// tool destinations) is preserved.
15+
export function sortedKeysReplacer(_key: string, value: unknown): unknown {
16+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
17+
return value;
18+
}
19+
const sorted: Record<string, unknown> = {};
20+
for (const k of Object.keys(value as Record<string, unknown>).sort()) {
21+
sorted[k] = (value as Record<string, unknown>)[k];
22+
}
23+
return sorted;
24+
}
25+
26+
// Canonicalize a value: sort object keys at every level, drop null/undefined
27+
// leaves recursively, leave array order intact. Produces a stable shape
28+
// regardless of insertion order or transient nullish leaves the API may
29+
// emit. Used by Stack F (content hashes) and Stack G (drift detection) —
30+
// kept here so the helpers stay co-located.
31+
export function canonicalize(value: unknown): unknown {
32+
if (value === null || value === undefined) return undefined;
33+
if (Array.isArray(value)) {
34+
const out: unknown[] = [];
35+
for (const item of value) {
36+
const c = canonicalize(item);
37+
if (c !== undefined) out.push(c);
38+
}
39+
return out;
40+
}
41+
if (typeof value !== "object") return value;
42+
const sorted: Record<string, unknown> = {};
43+
for (const k of Object.keys(value as Record<string, unknown>).sort()) {
44+
const c = canonicalize((value as Record<string, unknown>)[k]);
45+
if (c !== undefined) sorted[k] = c;
46+
}
47+
return sorted;
48+
}

src/state.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
import { existsSync, readFileSync } from "fs";
22
import { rename, writeFile } from "fs/promises";
33
import { STATE_FILE_PATH, VAPI_ENV } from "./config.ts";
4+
import { sortedKeysReplacer } from "./state-serialize.ts";
45
import type { StateFile } from "./types.ts";
56

7+
// Re-export the pure helper so callers can pull it from `state.ts` (same
8+
// import line as loadState/saveState) without forcing the config-laden
9+
// module on test code that just wants the serializer.
10+
export { sortedKeysReplacer } from "./state-serialize.ts";
11+
612
// ─────────────────────────────────────────────────────────────────────────────
713
// State Management
814
// ─────────────────────────────────────────────────────────────────────────────
@@ -57,8 +63,16 @@ export async function saveState(state: StateFile): Promise<void> {
5763
// A crash or SIGINT mid-write leaves the original state intact rather than
5864
// truncating it. A truncated state file would silently wipe all UUID
5965
// mappings on the next load.
66+
// sortedKeysReplacer enforces deterministic key ordering across every
67+
// nested object so two semantically-equal state objects (with different
68+
// insertion orders from push/pull/bootstrap merges) always serialize
69+
// byte-identically. Without this, ~half of the state-file diff is pure
70+
// reordering, which trains reviewers to skim past it.
6071
const tmpPath = `${STATE_FILE_PATH}.tmp`;
61-
await writeFile(tmpPath, JSON.stringify(state, null, 2) + "\n");
72+
await writeFile(
73+
tmpPath,
74+
JSON.stringify(state, sortedKeysReplacer, 2) + "\n",
75+
);
6276
await rename(tmpPath, STATE_FILE_PATH);
6377
console.log(`💾 Saved state file: ${STATE_FILE_PATH}`);
6478
}

tests/state-key-order.test.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import test from "node:test";
2+
import assert from "node:assert/strict";
3+
import { sortedKeysReplacer } from "../src/state-serialize.ts";
4+
5+
// Stack B regression test — pin deterministic key ordering on state file
6+
// serialization. Two semantically equal state objects with different
7+
// insertion orders MUST serialize byte-identically. Without this, the state
8+
// file accumulates pure-reordering diffs that hide real changes.
9+
10+
test("sortedKeysReplacer emits top-level keys alphabetically", () => {
11+
const insertedABC = { c: 3, a: 1, b: 2 };
12+
const insertedCBA = { a: 1, b: 2, c: 3 };
13+
14+
const serializedABC = JSON.stringify(insertedABC, sortedKeysReplacer, 2);
15+
const serializedCBA = JSON.stringify(insertedCBA, sortedKeysReplacer, 2);
16+
17+
assert.equal(serializedABC, serializedCBA);
18+
assert.equal(
19+
serializedABC,
20+
`{
21+
"a": 1,
22+
"b": 2,
23+
"c": 3
24+
}`,
25+
);
26+
});
27+
28+
test("sortedKeysReplacer recursively sorts nested objects", () => {
29+
const a = {
30+
assistants: { z: "uuid-z", a: "uuid-a" },
31+
tools: { y: "uuid-y", b: "uuid-b" },
32+
};
33+
const b = {
34+
tools: { b: "uuid-b", y: "uuid-y" },
35+
assistants: { a: "uuid-a", z: "uuid-z" },
36+
};
37+
38+
assert.equal(
39+
JSON.stringify(a, sortedKeysReplacer, 2),
40+
JSON.stringify(b, sortedKeysReplacer, 2),
41+
);
42+
});
43+
44+
test("sortedKeysReplacer leaves array order intact", () => {
45+
// Array order is semantic for resource lists like `assistant_ids` —
46+
// sorting them would corrupt squad member ordering, tool destination
47+
// priority, etc. The replacer MUST NOT reorder arrays.
48+
const obj = { tags: ["zebra", "apple", "mango"] };
49+
const result = JSON.parse(JSON.stringify(obj, sortedKeysReplacer));
50+
assert.deepEqual(result.tags, ["zebra", "apple", "mango"]);
51+
});
52+
53+
test("sortedKeysReplacer handles deeply nested mixed structures", () => {
54+
const insertion1 = {
55+
z: { y: { x: 1, w: 2 }, v: [{ b: 1, a: 2 }, { d: 1, c: 2 }] },
56+
a: 0,
57+
};
58+
const insertion2 = {
59+
a: 0,
60+
z: { v: [{ b: 1, a: 2 }, { d: 1, c: 2 }], y: { w: 2, x: 1 } },
61+
};
62+
63+
assert.equal(
64+
JSON.stringify(insertion1, sortedKeysReplacer, 2),
65+
JSON.stringify(insertion2, sortedKeysReplacer, 2),
66+
);
67+
});
68+
69+
test("sortedKeysReplacer preserves null and primitive values", () => {
70+
const obj = {
71+
voicemailMessage: null,
72+
name: "test",
73+
count: 42,
74+
enabled: true,
75+
nothing: undefined, // JSON.stringify drops undefined naturally
76+
};
77+
const serialized = JSON.stringify(obj, sortedKeysReplacer, 2);
78+
const parsed = JSON.parse(serialized);
79+
assert.equal(parsed.voicemailMessage, null);
80+
assert.equal(parsed.name, "test");
81+
assert.equal(parsed.count, 42);
82+
assert.equal(parsed.enabled, true);
83+
assert.equal("nothing" in parsed, false);
84+
});
85+
86+
test("sortedKeysReplacer is stable: serializing twice yields identical output", () => {
87+
const state = {
88+
credentials: { c: "1", a: "2" },
89+
assistants: { z: "3", b: "4" },
90+
tools: { y: "5", x: "6" },
91+
};
92+
93+
const first = JSON.stringify(state, sortedKeysReplacer, 2);
94+
const second = JSON.stringify(JSON.parse(first), sortedKeysReplacer, 2);
95+
assert.equal(first, second);
96+
});

0 commit comments

Comments
 (0)