Vibes DIY
Vibes DIY / Blog
From the build log

Ship it dark: the scariest change is the smallest line you write

When social-graph notifications went live on Vibes DIY — someone asks to follow you and your phone buzzes; a new follower shows up in your notifications and the weekly digest — the pull request that actually turned the feature on was the smallest one in the whole effort. Around ninety lines: one helper and two call sites. The PR that sounds the scariest, the one that takes a dormant feature and makes it fire for real, was the least risky line anyone wrote.

That wasn't luck. It's what happens when you ship a feature as a sequence of dark slices — each one landing on main, deployed to production, doing nothing visible — and let the order of the merges be the rollout guard instead of a feature flag.

A single light switch lit by a shaft of sunlight on an otherwise dark wall, set in the post's teal-green hue with the overline 'Rollout ordering' and the title.
Everything is wired up in the dark. The last, smallest PR just flips the switch.

Slice one: a schema that can't fire

The first PR added the new notification types and widened the database — a nullable appSlug column on the Notifications table — and then it deployed to production and did absolutely nothing. The emit path that would use the new shape wasn't merged yet.

The interesting part is the test that shipped with it. It didn't assert the feature works; it asserted the opposite. An app-less notification row must not fan out to the live bell. The test pins the branch closed, so nothing can accidentally reach it before the rest of the stack is ready. "Dark launch" here doesn't mean a flag you toggle later — it means a genuinely unreachable code path, plus a test whose job is to keep it unreachable.

That's the first thing worth internalizing: a dark launch can be less machinery than a feature flag, not more. A flag is infrastructure you build, gate on, and eventually have to remember to delete. An unreachable branch guarded by a test is just… code you haven't wired up yet.

Slice two: widen the wire, in order

One nullable column sounds trivial. But between the database and a notification on someone's phone, that appSlug field passed through four independently-typed checkpoints: the emit input, the queue context, the notification service's own runtime validator, and the client-side event type. Two more copy maps — the web notification labels and the push-payload builder — had to describe the new type in wording that stays identical (there's a test that enforces the parity, word for word).

The ordering is the whole lesson. Schema first (dark), wire and surfaces second, emitters last. Widen every validator to accept the new nullable shape before anything ever produces it. Do it in that order and there is no deploy moment — not one — where a running validator sees a shape it doesn't recognize. A client deployed last week keeps working, because the field it never heard of is nullable and every checkpoint between it and the emitter was taught to shrug at null before the emitter existed.

Get posts like this in your inbox

One email field. Real updates. No algorithm required.

One of the validators carries the rule as a comment, and it's the entire rollout philosophy compressed into a sentence:

must stay at least as wide as the emitter's shape, or app-less events die at the DO hop

Widen the wire second. Never first, never last.

Slice three: keep the renderer dumb

The third slice added "new followers" to the weekly digest email, and it ran into an org-chart question dressed up as a code question. Accounts on Vibes DIY can hold multiple handles, so a "you got a follower" line needs to say which of your handles was followed. That labeling could live in the renderer — parse the reference, join against the handles table — or it could live in the emitter, baked into the notification body when the event is written.

A storage-shape invariant made the call for us. The inbox was already designed so that notification bodies are self-contained: a list of them renders with no second query, no join, no lookup. Honor that invariant and the answer is forced — the emitter writes "which handle was followed" into the body, and the digest renderer stays dumb. It just prints what it's given.

This is a quiet, underrated kind of design pressure. A decision you made once, about how data is shaped at rest, reaches forward months later and settles an argument about where logic belongs — before anyone has to have the argument.

Slice four: the tiny switch

By the time the activation PR landed, the schema existed, the wire accepted the new shape end to end, and the digest knew how to render it. So turning the feature on was a small helper and two call sites — the places a follow already happens now also emit a notification.

Its one clever detail is the dedupe key. Rather than a new "already notified" table, the helper folds a 30-day epoch bucket into the existing unique index on (userId, dedupeKey). That turns "notify once, ever" into "notify once per pair per 30-day window" with zero new storage: a rapid follow → unfollow → follow can't spam you, but a genuine re-follow months later still reaches you. The riskiest-sounding change in the stack — the one that makes it real — is a helper and an index trick, because every hard part shipped already, separately, in the dark.

Then: deployed ≠ works

Here's the part it would have been easy to skip. When the release activated the emitters, the code was live in production — and the notifications table had zero new rows. Live but dormant. "Deployed" is not "works," and the only honest way to close that gap is to make the thing happen.

So we did: a throwaway test app under a QA handle, a real follow driven from one account to another in a signed-in headless browser, and then watch for the durable notification row to land in the production database — which it did, about 120 milliseconds after the follow edge was written. A couple of real gotchas surfaced only because we drove it for real: the emit runs in the main service worker, not the queue consumer you'd assume from the architecture; and the two QA accounts already followed each other, so the first follow() call was a silent no-op until we unfollowed and re-followed to force a fresh emit. Neither of those shows up when "the tests pass." End-to-end verification is not a formality on top of a green suite — it's a different question, and sometimes the only one that catches a dormant path.

The counter-example: a slice with no consumer

Dark-slicing is a good tool, and like every good tool it has a failure mode: shipping a slice and never walking the ladder to the end.

We did exactly that, once, on a different feature. Bring-your-own-key had a password field, careful redaction so a non-owner never sees the stored key, and storage round-trip tests — everything except the part where the key actually authenticates a request. Every consumer of the resolved model read the model id and quietly dropped the key on the floor; every outbound call still went out signed with the platform's own key. Nothing was marked TODO. The gap was silent precisely because storage and redaction were each their own well-scoped tickets that closed cleanly — nobody owned the word "consumption."

That's the same shape as a feature flag left off forever, just quieter: a schema that lands before its consumer does is half a feature wearing the disguise of a finished one. The discipline that makes ordered dark slices safe — landing the risky groundwork early and inert — is the very same discipline that can strand a feature one inch short, if you let the last slice slip. Dark slices only pay off if you keep walking to activation, and then keep walking to verification.

The takeaway

Merge ordering was our rollout guard the whole way through. Not a flag, not an environment variable — the plain fact that the emitting slice does not merge until the surfaces that tolerate its output are already on main. Get the sequence right and each slice is individually boring: a nullable column, a widened validator, a dumber renderer. Boring, deployable, reversible. And when every hard thing has already shipped inert, the change that finally lights the feature up is the smallest, calmest line in the stack.

The scariest-sounding PR should be the safest one you write. Order the dark slices so it is — and then go prove it fires.

Build on a platform that ships this way

Every vibe gets a synced database, an access model, and a server side — shipped in careful order so you don't have to.

Start building →

Enjoyed this? Get the next post by email

One email field. Real updates. No algorithm required.

Prefer a feed? RSS · Atom