Build notes
The WhatsApp contacts we silently stopped scoring
We did not find this because a customer complained. Nobody could have complained. We found it because our own health check told us something was wrong, and the honest version of this note admits the uncomfortable part first: it had probably been wrong for two nights before anyone looked.
That is the more useful story to publish. Not "look how reliable we are," but "here is exactly how we find out when we are not," on the specific night it mattered.
What the health check actually showed
Klaros runs three Cloudflare Queues, enrichment, campaign, and outcome, plus a dead-letter queue for
anything that exhausts its retries. Cloudflare Queues bindings cannot report queue depth directly, so there
is no dashboard where a pileup is simply visible. We built one: every message that lands in the DLQ is also
written as a row in a dlq_events table, and a GET /health/ready endpoint counts
them. Without that table, a dead-letter pileup exists only in wrangler tail output, which is to
say it exists nowhere anyone is watching.
Querying dlq_events against production directly, not guessing, showed a specific and telling
shape: 68 dead-lettered messages, 100% of them enrich_contact, with
timestamps that lined up exactly with two consecutive runs of the nightly 0 3 * * *
enrichment cron.
That pattern ruled out "flaky" or "one contact's bad data" immediately. A uniform message type, an exact cron alignment, on both nights, meant something structural was breaking every single message in the batch. Not some of them. All of them.
The bug
The root cause, once found, was almost insultingly small. Three UPDATE graph_contacts SET ...
statements set updated_at = ?. That column has never existed on graph_contacts.
The real column is last_updated.
-- what every enrichment write attempted:
UPDATE graph_contacts SET credibility_json = ?, updated_at = ? WHERE id = ?
-- what graph_contacts actually has:
-- ... credibility_json, last_updated, canonical_id, phone_hash, ...
Error: no such column: updated_at
Every attempt to persist an enrichment result threw that error, retried, and eventually gave up. Not a logic bug. Not an AI failure, those code paths are already wrapped defensively in their own try/catch. A one-word schema mismatch sitting underneath otherwise careful code, in a function with no test coverage to catch it.
What actually depends on this
enrich_contact is not a peripheral background job. It is the mechanism that turns a phone
number into a scored, tiered lead, and it runs one of two paths.
A zero-cost path checks first: a shared canonical_claims table, keyed only by a hashed phone
number, holds facts (company, role, location) that any customer's AI enrichment has previously surfaced
about a real-world contact. If a different customer has an overlapping contact, they get that enrichment for
free. The table stores a hash and a claim value, nothing else, no raw contact record and no field that
identifies which workspace contributed it, so this works without exposing one customer's contact to another.
Failing that, an AI extraction path runs the contact's WhatsApp bio through Workers AI, budget-guarded per workspace, to pull structured claims (name, role, company, industry) and assign a credibility score from 0 to 100 and a tier of unknown, low, medium, or high. Either path is meant to register a signal that downstream logic uses to decide who actually deserves outreach.
None of that ran, for either path, for two nights. Every contact that should have been scored stayed permanently unscored: no tier, no credibility signal, nothing visibly wrong on any dashboard, because the failure mode was "silently retries forever," not "throws where a person can see it."
We have written before about digital distance, the gap between what a person believes a business knows about them and what it actually knows. Enrichment exists to close that gap without anyone having to ask for it, so a first-time number becomes a contact the business already understands a little, before the first reply is even typed. That is the specific thing that was quietly not happening. Every contact who passed through this bug stayed exactly what they were the moment they first messaged: a stranger.
The other half of enrichment
The same consumer also handles shared media and links, and it is worth naming what was at risk alongside the contact scoring, because it shows this is a real capability, not "read the message." For a shared PDF, image, or link, it derives a topic, a recommended action of process now, snooze, or ignore, a business-relevance judgment from keyword signal detection (pricing, proposal, quote, meeting, demo, and similar), and drafts a suggested response. All of it is budget-guarded per workspace, which is the same transparent-cost discipline behind the pricing page: AI spend stays bounded and visible rather than open-ended.
What two nights of silence actually cost
For roughly two days, every contact that should have entered the enrichment pipeline got nothing. No credibility tier, no extracted profile claims, no business-signal detection on anything they shared. To a business owner using Klaros, this would have looked like new contacts simply not getting smarter over time. No error. No warning. Nothing to click on. That is the real cost, stated plainly, and naming it is what makes the rest of this note worth trusting.
The two shortcuts we did not take
We chose: fix the write, not catch the error.
Swallow the failure quietly, or make the write succeed?
Wrapping the three UPDATE statements in a try/catch and logging the error would have stopped
the retry storm and the DLQ pileup in an afternoon. It would also have left every contact exactly as
unscored as before, just more quietly. A caught error is not a fix for a feature that is supposed to do
something. The write had to actually succeed.
We chose: watch the test fail before trusting it.
Assert the fixed behaviour, or prove the regression test can catch the bug?
handleEnrichmentBatch had zero real test coverage. The only similarly named test file in the
repo exercises a different codebase entirely, through a hand-rolled mock database that string-matches SQL
text instead of executing it. That kind of mock can never catch a bad column name, it just returns
whatever the test author told it to return, which is exactly the shim that let this survive.
So the fix was not only the one-line change. It was five new tests written against the project's real-schema harness, actual migrations, actual SQLite, throwing on bad SQL instead of silently returning empty. And before calling it done, we temporarily reintroduced the bug to confirm the new tests actually failed, for the right reason, then restored the fix and ran the full suite.
$ git stash # briefly restore updated_at
$ node --test test/uni-cloud-enrichment-consumer.test.mjs
✖ zero-cost path (canonical_claims match) persists credibility_json without throwing
Error: no such column: updated_at
✖ AI-extraction path (no canonical_claims, has about text) also persists without throwing
Error: no such column: updated_at
$ git stash pop # restore last_updated
$ node --test test/uni-cloud-enrichment-consumer.test.mjs
✔ 5 tests passed
$ npm test
✔ 3,016 tests passed
A test that has never been observed to fail is not evidence that it works, it is an assumption wearing a checkmark. That last step is the part most postmortems skip, and it is the part that actually proves the fix rather than asserting it.
Why this was catchable at all
Two things made this findable instead of permanent. First, /health/ready is a real endpoint, on
infrastructure a workspace runs itself. A black-box SaaS WhatsApp Business Solution
Provider does not hand you a queue depth counter to query, you wait for their support queue to notice,
if anyone ever does. Klaros is built to self-host, and an operator running
their own deployment can query dlq_events the same way we just did, on their own account,
without asking us. That is a structurally different position from renting a seat on someone else's platform,
whether that platform is Interakt, WATI,
or Twilio's WhatsApp API.
Second, the fix methodology: writing the test, then deliberately breaking the code again to watch it fail, then restoring it and running everything, is a level of rigour that is easy to claim in a sales deck and rare to actually demonstrate. This note is the demonstration: a specific number, 68, a specific root cause, one wrong column name, a specific proof, watch the test fail, then watch it pass, on infrastructure a prospect can inspect themselves if they self-host.
What we have not fixed
This would be a dishonest note if it ended at the tidy part.
/health/ready still returns HTTP 200 with a nonzero DLQ count sitting quietly inside the JSON
body. A dead-letter pileup does not flip the endpoint to a failing status, by design, because it means
something upstream is failing repeatedly, not that the deployment itself is broken. But that also means
nothing pages anyone. Klaros separately ships a proactive WhatsApp ping to the workspace owner for critical
health_events, number quality drops, auth errors, cost budget breaches, and that pipeline does
not yet cover a DLQ pileup. Today, someone still has to think to check. That is the honest gap, and it is
the obvious next piece of this same system.
What this is actually for
We would rather ship the receipt than the adjective. A specific number is checkable in a way "reliable" never is. This note is the same discipline behind the billing audit we published earlier: before we asked anyone to pay us, we opened the room where the money moves and wrote down what we found there, including the parts that were not flattering. The contact intelligence layer deserved the same treatment, and now it has real test coverage where before it had none.
Get the next one on WhatsApp
Message the line and type NOTES. The latest one comes straight back, in the same thread, from the number that sends everything else. Reply STOP whenever you like and it stops.
Send NOTES on WhatsAppAsk our WhatsApp number what it costs
The most honest demo we have is the product itself. Message the line and type pricing:
you will get our live catalog as a WhatsApp list, and a payment link on whatever you tap. If you self-host,
/health/ready is live on your own deployment the moment it is up, query it yourself.
+91 97893 77634 · you message first, so nothing reaches you without your say-so.
Written 6 August 2026. We append when the facts change. Related: what we found in our own billing, security, the belief this comes from, all build notes.
