A tenant flips a setting in the app — say, switching their accounting basis, or turning on a confirmation email — and nothing changes. The value in the database reads back exactly what they chose. The behaviour still uses the old default. Nothing errors. This is one of the most convincing bugs there is, because every surface looks correct.
The cause is almost always the same: a raw settings read that didn't unwrap the envelope.
Two shapes for one column
A tenant setting's value lives in a single JSONB column, but it arrives in two shapes depending on who wrote it:
- The governed write path (the audited, revertible settings service the UI and the agent both use) wraps the value as an envelope:
{ "v": false },{ "v": 360 },{ "v": "self_perform" }. - Legacy paths, seeds, and defaults write the bare scalar:
false,360,"self_perform".
So the same key can be stored bare on one tenant and wrapped on another — and it flips from bare to wrapped the first time someone edits it through the app. A reader that does if (row.value === "accrual") matches the bare form and silently misses the wrapped one. The setting works right up until it's changed, then quietly stops.
The rule: one unwrapper, at the read boundary
Never compare a raw .value to a scalar. Route it through the one canonical unwrapper, which strips the { v: ... } envelope, tolerates the JSON-text form, and returns bare values unchanged — so it is safe to apply everywhere:
import { coerceTenantSettingValue } from "@/lib/settings/raw-tenant-setting";
const v = coerceTenantSettingValue(row.value);
if (v === "accrual") { /* ... */ }
For anything beyond a hot path, prefer the full resolve-setting path, which applies the same unwrap plus tier-gating, per-scope resolution, and the registry default. Reach for the raw read only where an import cycle or a genuinely hot loop rules the resolver out — and even then, unwrap.
Why a test, not just a helper
The reason this bug recurs is that unwrapping is easy to forget and invisible when forgotten — a green build, a passing unit test (whose seed happened to be bare), and a correct-looking database row all coexist with the defect. A single audit of the settings readers turned up eight that pulled the value raw. One was worse than a missing unwrap: a finance read queried the wrong column name entirely, so the read always errored, was caught, and returned the default — the setting had never once loaded. Two others were boolean helpers that handled strings and 1/0 but not the object, so a confirmation email would have silently stopped the moment a tenant toggled it on.
The durable fix isn't the eight edits — it's the guard that stops the ninth. A structural test walks every file that reads a tenant_settings value and fails the build unless it goes through a recognised unwrapper. The helper handles the value; the test handles the class. It caught two of those eight itself. This is the same shape as the one-write-path discipline: put the correctness in one place, and lock the boundary with a test that fails loudly when a new caller skips it.
The principle
A silent default is worse than a crash: a crash gets fixed, a silent default ships and waits. Configuration that "works until you change it" is configuration that doesn't work. Put the unwrap at the read boundary, make one helper own it, and let a structural test — not human vigilance — keep every reader honest.