Delivery receipts: tell "she ignored it" apart from "it never arrived" - #1
Merged
Conversation
message_log.status had four states and none of them was delivered. Two lines
below it sat provider_message_id — the exact handle a receipt is matched
against — and nothing ever came back to match. sent_at meant a provider
accepted the payload; settled_at meant the dispatcher finished; neither meant
the handset showed anything.
That matters more here than in most systems. A no-show engine exists to
separate "she read it and didn't reply" from "it never arrived", and in the log
both were status = 'sent' with no inbound row. T-3h with onlyIfUnconfirmed
fired identically for both. So did the digest's unconfirmed list.
Worse, workflow 03 already set StatusCallback to /webhook/deskbell/message-status
on every SMS and no workflow served that path, so Twilio had been posting every
receipt at a 404.
* message_log.status now carries claimed -> sent -> delivered/read, with
undelivered and failed, plus delivered_at, provider_status and
provider_status_at. Applying schema.sql again is the upgrade path.
* New workflow 11 receives Twilio receipts on the URL 03 was already
advertising. WhatsApp Cloud allows one webhook per app, so 04 stops
dropping its status payloads and forwards them instead.
* Receipts arrive out of order and more than once. deskbell.delivery_rank()
makes every status write monotonic — in 11 and in all four settles — so a
late 'sent' cannot undo a 'delivered' and a duplicate updates nothing.
* 05, 07 and 08 record provider_message_id. Follow-ups, recalls and waitlist
offers were unreconcilable even in principle; the missed-call text-back had
no message_log row at all, so it now claims one before it sends.
Acting on it, since a receipt nothing reads is decoration:
* A stage whose last attempt did not reach the customer is retried on a
different channel. This also fixes a silent dead end that predates
receipts: a refused send left sent_stages untouched and looked due forever,
while the claim it already held dropped every retry via ON CONFLICT DO
NOTHING. The retry claims appointment|stage|r<n> instead, capped by
reliability.maxRedeliveryAttempts.
* The daily digest splits "unconfirmed" into reached-no-reply and
never-reached. The second is a wrong number, not a reluctant customer.
* deskbell.v_undelivered is the fault list. It answers a different question
from v_stuck_sends, which finds sends we abandoned rather than sends a
provider refused.
* A stage whose claim is held by an unsettled attempt reports
attempt_in_flight rather than due, because a decision the database
silently overrules is a log that lies.
A channel with no receipts still reports nothing, and 'sent' means unknown
everywhere it is read — not delivered, and not failed.
Also documents the four sender identities in .env.example: Twilio, WhatsApp,
VAPI and email are configured separately and will otherwise chase one person
about one appointment from three different numbers, only one of which has
replies routed anywhere.
78 tests, up from 63.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Prompted by a cold email that read the repo properly rather than the README.
The finding
message_log.statushad four states and none of them wasdelivered:Two lines below sat
provider_message_id— the exact handle a delivery receiptis matched against — and nothing ever came back to match it.
sent_atmeant aprovider accepted the payload.
settled_atmeant the dispatcher finished withthe row.
v_stuck_sendsfound sends we abandoned. None of them answeredwhether the T-24h reminder reached the handset.
That matters more here than in most repos. A no-show engine exists to separate
"she read it and didn't reply" from "it never arrived", and in the log
both were
status = 'sent'with no row ininbound_messages. T-3h withonlyIfUnconfirmedfired identically for both. So did the digest's unconfirmedlist.
Two things the email couldn't see from outside made it worse:
StatusCallbackto/webhook/deskbell/message-statuson every SMS, and no workflow served that path — Twilio had been posting
every receipt at a 404, and retrying it.
provider_message_idwas written by exactly one node. Workflows 07 and 08settled to
sentwithout it, so follow-ups, recalls and waitlist offers wereunreconcilable even in principle. Workflow 05 created no
message_logrow atall.
What changed
Recording it
message_log.statuscarriesclaimed→sent→delivered/read, plusundeliveredandfailed, withdelivered_at,provider_statusandprovider_status_atalongside.data/schema.sqlis idempotent andre-applying it is the upgrade.
advertising. WhatsApp Cloud allows one webhook URL per app, so 04 stops
dropping its status payloads and forwards them here instead.
sentanddeliveredwebhooks routinely land the wrong way round.deskbell.delivery_rank()makes every status write monotonic, in workflow 11 and in all four settles, so
a late
sentcan't undo adeliveredand a duplicate updates nothing.provider_message_id; the missed-call text-backclaims a
message_logrow before it sends, like every other send does.Acting on it, because a receipt nothing reads is decoration
different channel. This also fixes a silent dead end that predates receipts:
a refused send left
sent_stagesuntouched and looked due forever, while theclaim it already held dropped every retry via
ON CONFLICT DO NOTHING. Theretry claims
appointment|stage|r<n>, capped byreliability.maxRedeliveryAttempts(default 1).reached. The second is a wrong number in the booking system, not a reluctant
customer.
deskbell.v_undeliveredis the resulting fault list.attempt_in_flightrather thandue— a decision the database silentlyoverrules is a log that lies.
What it deliberately does not claim. Channels differ in what they report:
SMS gives delivered/undelivered, WhatsApp adds read, email reports nothing. A
message on a channel with no receipts stays
sent, andsentmeans unknowneverywhere it's read — not delivered, not failed.
Also
.env.examplenow documents the four sender identities. Twilio, WhatsApp, VAPIand email are configured separately, so out of the box the reminder, the SMS
fallback and the voice escalation can each arrive from a different number — and
only one of them has replies routed anywhere, which makes the others a dead end
that still costs money.
Verification
Docker wasn't running locally, so
schema.sqlhas not been applied to a realPostgres here — CI does that twice on every commit and is the check to watch on
this PR.