Audit your nouns
Published on
Partway through building a webhook delivery service, I asked an agent to do something that felt like housekeeping: “Can you gather our nouns and verbs in the @src/ please?” It came back with a nouns table, the verbs laid out in pipeline order, and a short list at the end labelled “naming debt the map exposes”. The first item on that list: Webhook meant three different things.
It was the work item, the thing with a retry counter that moves through the queues. It was a prefix meaning “this domain”, stuck to the front of types like WebhookEvent and WebhookEntityConfig. And it was the HTTP artifact itself, the signed request that sendWebhook puts on the wire. Three concepts, one word.
I thought I was looking at naming debt, the kind of thing you fix with a careful rename and a lint rule. I wasn’t. Each of those meanings represented a decision nobody had made yet. What identifier should a recipient dedupe on? Does the work item own its payload or just carry it? The words were fighting because the model underneath them was still unresolved.
That set the pattern for the rest of the session, and it kept repeating: a name feels wrong, you push on why, and you may find a requirement that nobody wrote down. Decide on that requirement, and the name becomes obvious. The rename is just the decision, written down.
The system itself is nothing exotic: events come in, get fanned out to however many endpoints a recipient has configured, and go out signed, with retries across a 30-day window. Which means it’s full of nouns: events, destinations, deliveries, attempts. And from the sender’s side, one hugely overloaded word sitting on top of all of them: webhook. This post walks through the sharpest fight those nouns picked, then two smaller ones, then the artifact that came out of it: a vocabulary doc that turned out to be an old idea from a book I’d never properly read.
The id that was two ids
Halfway through the session, I typed a sentence that looks like pedantry: “We need to differentiate what a webhookId is and what a deliveryId is.”
Here’s the setup. We consume from a queue of events, and each one fans out based on the recipient’s configuration: an event with three destinations becomes three deliveries, each with its own retry schedule and its own attempt counter. Every delivery gets an identity of the form {event_id}:{destination key}. Internal plumbing, so far so good.
The problem was that this same identifier was also the webhook-id header we sent to recipients, per the Standard Webhooks spec. That header is not plumbing. It’s the recipient’s idempotency key, the thing they dedupe on when we retry, and it gets signed into the message. One value, wearing two hats: internal transport identity and external contract.
Asking which name the value deserved forced the real clarifying question: which of our identifiers should recipients use to dedupe? An id like this has three possible scopes: per event, per destination, or per attempt. Svix scopes theirs to the event: every endpoint sees the same id, reused across every retry, and their docs tell recipients to use it as an idempotency key. GitHub scopes theirs to the delivery: each endpoint gets its own GUID, held constant across redeliveries. And nobody chooses per attempt on purpose. It’s where you land when nobody decides, because an id whose scope was never chosen degrades to the narrowest one.
We went event-scoped. The webhook id a recipient sees is the event id: shared across all destinations of the event, stable across every attempt, signed into the message, and the id they’d quote in a support ticket. The delivery id stays internal: {event_id}:{key}, used for retry cancellation, audit trails and isolation, never shown to anyone. Two ids, two distinct jobs, neither overloaded.
Notice what just happened, though. A naming question changed the signature on our messages, the signing of our contract. Recipients verify signatures against that header, so picking the wrong id and fixing it after launch means breaking every consumer’s verification. Made before anything shipped, the whole decision cost one schema field and one argument. Free today, expensive later.
The legacy system this service replaces is the cautionary tale: its X-Webhook-ID header changed on every retry attempt, an idempotency key that defeated its own purpose, because nobody had ever decided what the id meant. The narrowest scope, reached by default. That bug deserves its own post.
The pattern repeats
Two more, quicker.
Later, looking at the work item type:
Wait, a delivery has a payload and attempts?
Feels like a small question. It isn’t. The struct was three concepts flattened into one bag, and pulling them apart gave us the model:
- An event is the thing that happened. It owns the payload.
- A webhook is the artifact a recipient receives: the payload plus the signature and headers we present it with.
- A delivery is the effort of transporting it: the attempt counter and the retry history. It carries its event whole, as cargo:
delivery.event.
The trio rhymes with the three meanings the audit flagged at the start, and that’s the point: the overloaded word and the flattened struct were the same undecided model wearing different clothes. A delivery has a payload the way a courier van has a parcel.
The flat bag existed for a fine reason: stored deliveries have to be self-contained so the retry worker can redeliver without re-fetching anything. But that’s denormalisation for storage, not a claim about the domain, and the struct had quietly promoted a storage convenience into the model.
The second one cuts the other way. While restructuring that type, the agent proposed grouping the destination fields, url and secret and content type, under a bag it named wire.
Why’re we calling the destination info wire?
No good answer came back, because the name had been invented on the spot instead of asking what the fields were. Pushing on it led somewhere better than a rename: those fields don’t belong on the delivery at all. Snapshotting a destination’s url and secrets into 30-day retry storage freezes exactly the things secret rotation and endpoint disabling exist to change, and the system already had a resolver whose whole job was fetching fresh config. The fields got marked interim, to die when resolve-at-send lands. The bad name wasn’t hiding a new requirement; it was a loose thread leading back to an existing one the new struct was about to violate.
Worth saying plainly: the agent coins bad names too. The audit works because contested names get pushed on regardless of who proposed them.
The doc that fell out
All of this had to live somewhere, so the agent wrote docs/vocabulary.md: a nouns table, the verbs in pipeline order, a hierarchy diagram. The doc opens with its own job description:
New code and reviews should use these words with these meanings; drift here becomes drift in thinking.
I debated naming it glossary over vocabulary, but neither gives it the weight it deserves. It’s a record of decisions:
Destination: a configured place to send events: key, url, scheme, secrets, status. Long-lived; exists with zero events.
“Exists with zero events” is not a definition. It’s a lifecycle decision: destinations aren’t created by traffic, they’re configuration with independent existence. Every row is like this. The meaning column is where the decisions ended up.
I opted to include a naming debt section, a stateful ledger of the places where the code still disagreed with the language: the Webhook type that should be Delivery, a function popping “webhooks” that were actually stored retries. Usually I’d eschew this sort of thing and tell myself “that’s what version control is for”. But version control records the renames already made. The ledger records the ones still owed, and a debt that sits in the same file as the definitions it contradicts is much harder to ignore. A later revision of the doc marks the wave resolved, with a date.
That warning line about drift reads, at first, like drift is failure. Living with the ledger taught me the opposite. The language moved because understanding moved: the webhook id decision, the cargo model, none of that was in anyone’s head when the first types were written. Naming debt is just code lagging behind refined requirements, and the ledger is the queue between them. What the doc polices isn’t change. It’s unrecorded change.
(The ledger even caught the doc itself once: Orchestrator appeared in the verbs table with no row in the nouns table, used but never defined. The fix was a table row, not a rename.)
The pattern had a name the whole time
The vocabulary doc turned out to be an old idea. It’s effectively Ubiquitous Language, from Eric Evans’ Domain-Driven Design, the 2003 blue book I’d circled for years through conference talks and podcasts without ever properly reading. I routinely reach for this shared naming and understanding, but at a higher altitude: services, products, the boundaries between them. Never down at the implementation level, where the types and functions live. That version arrived by intuition, and the practice already had a name, a chapter, and twenty years of literature behind it.
Evans’ idea, compressed: one shared language between the people who understand the domain and the code that implements it. Same words in conversation, in docs, in class names. The glossary isn’t the point; the enforcement loop is. When speech and code disagree, one of them changes.
Restated in this post’s terms: when speech and code disagree, a requirement is undecided somewhere. The loop is a requirements review that runs every time someone says a sentence about the system.
What knowing the name earlier would have bought me is the search term. “Ubiquitous language” unlocks the prior art on exactly the problems I’d hit, including bounded contexts, the pattern for when one word legitimately means two things in different parts of a system. And permission: I’d treated the vocabulary doc as a personal quirk. It’s a practice, with a chapter.
Fowler’s short write-up is the friendly citation; the book is there when you want the whole argument.
Where this runs out
A caveat before anything else: the audit had material to work with. This codebase carries typed schemas and docs that record decisions, tensions, and rejected alternatives, so the agent’s table had something to disagree with. Run the same prompt over a codebase of utility modules and data bags and you’ll get a shorter table and quieter tensions. The audit amplifies signal that’s already there; it can’t conjure it.
The churn was real too: the work item struct went through, in the agent’s words at the time, its third structural rework of the day. But each rework tracked a requirement being refined, not a whim. I name for clarity and aim for self-documenting code as a default, and the tell throughout this session was how hard that had become. The missing decisions were making naming more difficult than it needed to be. Hard-to-name is a symptom; the reworks were the price of treating it before launch rather than after.
Which points at the skill this exercise actually needs: telling contested from ugly. A contested name has two meanings fighting over one word, and pulling the thread finds a requirement. An ugly name has one meaning and a poor label, and pulling the thread finds a rename. Only the first kind pays for the excavation. I don’t have a mechanical test; unease that survives writing the definition down is the closest I’ve got.
And the purist objection: ubiquitous language is, by definition, shared between domain experts and developers, and this was a conversation between me and an agent, making me the domain expert developer wearer of whichever hat the sentence needed. I’ll give that half a point. But the loop itself didn’t care who was speaking. The agent investigated my nouns and audited them against whatever context the repo could give it. My vocabulary disqualified the agent’s inventions. Building out the doc refereed both directions. A language that keeps two very different speakers honest is doing its job.
Run it on yours
The test is reasonably transferable, and it’s cheap. List your system’s nouns, and check that scope creep or iterative development hasn’t resulted in conflicts, overloads or redundancy (I’m looking at you, WebhookEvent, in the webhook repo). Where two meanings share a word, or two words share a meaning, odds are there’s an undecided requirement underneath, waiting to be made explicit.
The gathering step costs five to ten minutes: a single one-sentence prompt, and an agent comes back with the table and the tensions pre-flagged. What it can’t do is the deciding. Whether recipients dedupe per event or per destination, whether a work item owns its payload or carries it: those are your requirements, and they were always going to be decided by someone. The only question is whether that happens in a naming argument before launch, by someone trying to make sense of your work three years from now, or in an incident review after.
The same audit flagged more than nouns, as it happens. The codebase turned out to be full of patterns I’d built without knowing their names: bulkheads, competing consumers, a staged event-driven architecture. That’s the next post.