fix(providers): retire the duplicate customerio slug and pin the published counts#731
Merged
Conversation
…blished counts
The registry carried TWO slugs for one real provider. `customerio` and `customer_io`
had byte-identical schemes — same `x-cio-signature`/`x-cio-timestamp` headers, same hex
encoding, same `v0:{ts}:{body}` signed message, same tolerance — and both rendered as
"Customer.io", so the provider picker and the docs directory each listed Customer.io
twice. `customer_io` survives: it matches the convention every other dotted-domain brand
follows (cal_com, checkout_com, incident_io, merge_dev). PROVIDERS is now 141.
Retiring a slug is not a vocabulary edit. `provider_secrets.provider` is plain `text`,
and the engine picks an adapter from the REGISTERED provider — a slug that stops
resolving is DROPPED, not rejected. An endpoint registered under `customerio` would not
error and would not warn; it would simply stop verifying and start landing events
unverified. So the alias lives in `canonicalProvider`, the one place stored free text
becomes a Provider, and the code does not depend on the migration having run:
- webhooks-spec: RETIRED_PROVIDER_ALIASES + a shared `canonicalProvider`, replacing the
engine's local copy. A retired slug resolves; the write path (`ProviderSchema`) still
rejects it, so nothing new registers under one.
- db reads: events + provider-secret rows canonicalise on the way out, so a legacy value
can never reach `EventSummarySchema`/`ProviderSecretSummarySchema` (both strict) and
break a whole list. The public API, the SDKs and the dashboard never see the dead slug.
- migration 0095: hygiene, not a rescue. Rewrites the stored rows so the data stops
disagreeing with the vocabulary and the provider FILTER returns historical rows. The
events rewrite is bounded by the endpoints that registered the slug (revoke is a soft
delete and nothing hard-deletes, so that set is exhaustive) and is skipped entirely
when there are none, rather than scanning the largest table in the schema.
Verified against a real Postgres 14: rewrites correctly including a revoked secret's
endpoint, idempotent on re-run, and a statement-level tripwire proves the events UPDATE
never executes in the expected empty case.
Also corrects two counts that had already drifted with nothing to catch them. The docs
split the registry "around 109 config-driven / the remaining 33 bespoke" — a pair that
summed to the headline and matched the code in neither half, because Twilio has both a
config row and a bespoke adapter and `registry.ts` prefers the bespoke one. The real
partition is 108/33. `published-counts.test.ts` now pins every published count to the
registry, and asserts the provider directory lists exactly the live set — the check that
would have caught this duplicate at the source, which no count ever could.
Mutation-verified: dropping the alias makes the legacy secret verify `false` (silently);
a stale count, an emptied manifest entry, and a live slug smuggled into RETIRED_PROVIDERS
each fail. The w1-batch3 KAT is retargeted rather than deleted — it was Customer.io's only
replay-window and header-metadata coverage.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XiZ2XWyU6gUDa9rT8iYqvL
choraria
added a commit
to webhook-co/webhook-go
that referenced
this pull request
Jul 21, 2026
… provider slug (#15) webhook.git carried TWO registry slugs for one real provider: `customerio` and `customer_io` had byte-identical schemes (same `x-cio-signature`/`x-cio-timestamp` headers, same hex encoding, same `v0:{ts}:{body}` signed message, same tolerance). `customer_io` survives — it matches the convention every other dotted-domain brand follows. Spec-side that is 8 removed enum members and nothing else. Keeping this copy current is what keeps the route-coverage ratchet honest: it validates against this file, so a stale copy would keep passing against an old route list and a newly-shipped route could reach production with no Go method. Source: webhook-co/webhook#731 Claude-Session: https://claude.ai/code/session_01XiZ2XWyU6gUDa9rT8iYqvL Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The AI review was right: the alias was proven on the WRITE path (the engine ingest
regression) and asserted nowhere on the read paths this PR also changed. That is the
failure mode the repo already knows — a guard whose tests don't run the guard.
Both reads parse through a STRICT provider enum, and both validate per row inside a list
mapper, so a single legacy row does not degrade its own entry — it throws and takes the
WHOLE list with it. These tests pin exactly that:
- events: `listEvents`, `getEvent` and the org-wide browse all return a `customerio` row
as `customer_io`, with the other events still present.
- provider secrets: `listEndpointProviderSecrets` (which had no test at all) reports the
live slug, with the endpoint's other secrets still visible alongside it.
Each fixture is inserted straight into SQL — every write path rejects the slug now, so
bypassing them is the only way to reproduce the row an existing database already holds —
and each block first asserts the column really does hold the dead slug, so a fixture that
silently normalised could not make the rest pass vacuously.
Mutation-verified against a real Postgres: reverting the three mappers to `r.provider`
fails all four, and restoring them passes. Full db suite green (100 files, 1544 tests),
including the up -> down -> up reversibility run over migration 0095.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XiZ2XWyU6gUDa9rT8iYqvL
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Workflows to automatically generate PRs for you. |
choraria
marked this pull request as ready for review
July 21, 2026 17:25
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Workflows to automatically generate PRs for you. |
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.
The duplicate
The registry carried two slugs for one real provider.
customerioandcustomer_iohadbyte-identical schemes — same
x-cio-signature/x-cio-timestampheaders, same hex encoding, samev0:{ts}:{body}signed message, same tolerance — and both rendered as "Customer.io", so the/verifyprovider picker and the docs directory each listed Customer.io twice.
customer_iosurvives: it matches the convention every other dotted-domain brand follows (cal_com,checkout_com,incident_io,merge_dev).PROVIDERSis now 141.Why this isn't just a vocabulary edit
provider_secrets.provideris plaintextwith no enum and no CHECK, and the engine picks an adapterfrom the registered provider. A slug that stops resolving is dropped, not rejected — so an
endpoint registered under
customeriowould not error and would not warn. It would simply stopverifying and start landing events unverified.
So the alias lives in
canonicalProvider, the one place stored free text becomes aProvider, and thecode does not depend on the migration having run:
webhooks-spec—RETIRED_PROVIDER_ALIASES+ a sharedcanonicalProvider, replacing theengine's local copy. A retired slug resolves; the write path (
ProviderSchema) still rejects it, sonothing new can register under one.
packages/dbreads — events and provider-secret rows canonicalise on the way out, so a legacyvalue can never reach
EventSummarySchema/ProviderSecretSummarySchema(both strict) and break awhole list. The public API, the generated SDKs and the dashboard never see the dead slug.
with the vocabulary and the provider filter returns historical rows.
The migration is bounded, and that's verified
eventsis the largest table and its onlyproviderindex leads withendpoint_id, so a barewhere provider = 'customerio'would scan all of it. The affected endpoints are knowable up front, andthe set is exhaustive, not a heuristic: the engine names an event's provider from the endpoint's
registered secret; revoke is a soft delete and nothing hard-deletes
provider_secrets; and both tablescascade from
endpoints/orgs, so there are no orphan events. ADOblock skips the events statemententirely when there are none, making "we did not touch events" a certainty rather than a planner choice.
Exercised against a real Postgres 14 (matching CI):
UPDATEnever executesTwo counts that were already wrong
The docs split the registry "around 109 config-driven / the remaining 33 bespoke" — a plausible pair
that summed to the headline and matched the code in neither half. Twilio has both a config row and
a bespoke adapter, and
registry.tsprefers the bespoke one, so its config row never runs. The realpartition is 108 / 33. Nobody noticed, because a number typed into prose has no relationship to the
registry it describes.
published-counts.test.tsnow pins every published count to the live registry across docs, themarketing site and the README, and asserts the provider directory lists exactly the live set — the
check that would have caught this duplicate at the source, which no count ever could (the number was
self-consistently wrong).
Mutation-verified
Every guard here was checked red before being trusted:
canonicalProvider→ the legacy secret verifiesfalse, silently. That'sthe exact failure mode, reproduced.
RETIRED_PROVIDERS→ fails.The
w1-batch3known-answer test was retargeted rather than deleted — it was Customer.io's onlyreplay-window and header-metadata coverage (
s8-crypto-findscovers verify + framing only).Test plan
pnpm lint✅ (402 guard tests) ·pnpm typecheck✅ 32/32 ·pnpm test✅ 28/28test-db(CI) covers up → down → up reversibility;migrate:downis deliberately a no-op — the twoslugs are indistinguishable once normalised, so restoring the wrong ones would be worse than leaving
them.
production. The code is safe either way (the alias means nothing breaks if it hasn't run yet).
@webhook-co/sdkis semver-relevant. The generatedProviderunion loses"customerio". Nothing could legitimately have been using it — it verifiedidentically to
customer_io— but it is a type-level breaking change worth a release note.🤖 Generated with Claude Code
https://claude.ai/code/session_01XiZ2XWyU6gUDa9rT8iYqvL