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.
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 →