Feature flags that cannot lie
Most flag systems answer “is this on?”. A flag guarding money has to answer a harder question: is the thing it promises actually deployed?
A feature flag is a promise. Most of the time, breaking that promise renders a button in the wrong place. Sometimes it takes a user's money and sends it to the empty string.
I build a multichain coffee storefront called ArtisanalBrew. It runs liquid staking across Ethereum Sepolia, BNB testnet and Solana devnet, and it has a registry of nine chain definitions where six are switched off because their contracts don't exist yet. Every one of those switches is a claim about infrastructure I may or may not have deployed.
The flag marketplacePayment turned on for a chain with no escrow contract doesn't render a broken button. It renders a working button that moves funds to an address that is "". The usual flag lifecycle — flip it on, deploy the backend shortly after — has a window where the claim is simply false. This is about removing the window.
The rule
That's it. The rest is enforcement. What makes it work isn't cleverness — it's that the claim and the proof are the same artifact. You cannot commit one without the other, because they are one file.
What a manifest looks like
A deployment manifest is a whole chain definition, not a patch. It carries the addresses and the capabilities together:
{
"schemaVersion": "1",
"chainKey": "solana-devnet",
"cluster": "devnet",
"programId": "EbkKufsajUNzD3bLhRpb2d8XT5fHvz9e8hND111hQJxh",
"cafeMint": "C7g7g34QzvmAiP4HMmdjWLgfV9Y8FSF4GcAXK97HLQEg",
"administrator": "D5iN8pAhbzXN9Duq75GsKo6VPHwWzBbtYc1s5KH2CJGA",
"programBinarySha256": "14ba0f2848ebbcf749a949d617bd68c911dde…",
"capabilities": {
"walletLogin": true,
"liquidStaking": true,
"rewardFunding": true,
"reconciliation": true
}
}Note what isn't there: faucet is absent, so it's off. The registry ships that chain disabled by default, and the manifest replaces the built-in definition wholesale rather than merging into it. There is no way to inherit enabled: true from a default while supplying a partial deployment — you supply everything, or you get the disabled built-in.
The validator is the feature
Validation runs in the registry's constructor, which runs when the DI container is built. An invalid manifest doesn't degrade a feature — it refuses to start the process. Nothing half-configured ever serves a request.
if (chain.Capabilities.LegacyExit &&
string.IsNullOrWhiteSpace(chain.Deployment.LegacyPool))
throw new InvalidOperationException(
$"Chain '{chain.Key}' enables legacy exit without a pool deployment.");
// Marketplace payment needs a settlement venue: the liquid rail settles
// through the ERC-8183 escrow, the legacy rail verifies a native transfer
// against its legacy pool. Either satisfies "no capability without a
// contract behind it"; a manifest carrying neither fails closed.
if (chain.Capabilities.MarketplacePayment
&& chain.Family == ChainFamily.Evm
&& string.IsNullOrWhiteSpace(chain.Deployment.AgenticEscrow)
&& string.IsNullOrWhiteSpace(chain.Deployment.LegacyPool))
throw new InvalidOperationException(
$"Chain '{chain.Key}' enables marketplace payment without an escrow or legacy pool deployment.");The second rule is the interesting one, because it's an OR. There genuinely are two settlement venues. The right way to relax a rule like this is to name the alternative explicitly — a reviewer can see at a glance that the requirement was widened, not dropped. A boolean escape hatch would have been three characters shorter and unreviewable.
The five hops
A capability travels five hops from disk to screen. Stopping at any of them makes it invisible, and that is the intended failure mode rather than a bug to route around:
| Hop | Where | What it does |
|---|---|---|
| 1 | deployments/*.json | Declares capabilities plus the addresses backing them |
| 2 | BlockchainManifestLoader | Reads the manifest, replaces the built-in definition |
| 3 | ChainRegistry.Validate | Throws if a flag is on without its deployment |
| 4 | ChainsController | Filters on .Enabled before serialising |
| 5 | Chain selector UI | Renders only what the API returned |
When something doesn't show up, you walk the hops in order and stop at the first failure. Nine times in ten the answer is that the system is working correctly and I hadn't deployed the contract yet.
The step that keeps paying off
When enabling a capability, there's one step I'd skip if I were being efficient, and it's the one that keeps catching real gaps: verify it fails closed first. Blank the address, start the app, confirm the exception names your chain and your capability. Then restore it.
Thirty seconds, and it proves the gate is actually wired to your new flag rather than silently absent. A capability with no matching validation rule is the one way to defeat the entire design, and it looks exactly like a capability that works.
What it costs
Two things, and I want to be honest about both.
- You cannot demo ahead of your deployments. No flipping a flag to show someone a screen. If you need a demo path, deploy to a local chain and write a local manifest — which is friction, and is also the point.
- Every new capability needs a validation rule. If you can't name the deployment address it requires, that's a signal: what you have is a UI preference, and it belongs in configuration instead.
Outside blockchain
Nothing here is really about chains. The same shape applies anywhere a flag promises that something external exists: a feature needing a migration, a region needing a provisioned bucket, a pricing tier needing a configured billing plan. Four moving parts:
- The flag and the proof of backing live in one committed artifact.
- Validation runs at startup, not first use — failure is loud and early.
- The default is off, and turning it on is impossible without the proof.
- There is no bypass.
The version of this most teams already have is a config flag plus a runbook line saying “make sure the bucket exists.” That line gets skipped exactly once, in a hurry, and it becomes someone's bad afternoon. Turning it into a startup assertion costs about an hour.
I packaged the full version — the five hops, every validation rule, the secrets boundary and the diagnostic walk — as a Claude Code skill you can drop into a project. It's on the skills page, along with two others.