Ship a change behind a flag
You end with a change deployed switched off, both sides tested, turned on with no deploy, and the flag removed.
On this page
A feature flag lets you merge and deploy a change switched off, then turn it on for some users or for everyone without another deploy. Jadpo knows every read of every flag, so a misspelt flag is a compile error, both sides of each flag must have a test, and removing a flag is one command. This guide puts a shop's new one-page checkout behind a flag, switches it on in a running app, and removes the flag.
You start from an app with a GET / route that runs an action
called start_checkout, which answers with how many pages the checkout
takes. A line in policy.jadpo lets anyone call it, since it reaches no
entity that would say who may.
Build an API shows how to write a route.
Declare the flag
Every flag is declared once, in flags.jadpo at the root of the project.
default: is what everyone gets until you change it. owner: and
expires: make sure a flag doesn't stay for good: past that date
jadpo check fails with an error naming the owner.
flags.jadpo:
flags { /// The one-page checkout replacing the three-step form. new_checkout: Bool { default: false owner: "payments" expires: "2099-01-31" }}
A flag with more than two values is an enum instead of a Bool, which is how
you compare two layouts.
Read it
An action reads the flag as flags.new_checkout. The value stays the same
for the whole operation, so one request never sees it change halfway
through. You don't pass the caller: Jadpo already knows who is asking.
domains/, changed:
use shop/types { CheckoutShown }/// The checkout this customer is shown.action start_checkout() -> CheckoutShown { if (flags.new_checkout) { return CheckoutShown { page_count: 1 } } return CheckoutShown { page_count: 3 }}
Actions and a route's run: arguments can read a flag. Functions and
queries can't, so you pass them its value as a parameter.
Test both sides
jadpo check won't pass until tests reach this read with every value the
flag can have, so the path you are about to switch on is known to work:
error No test reaches this read of flags.new_checkout
--> domains/shop/actions/start_checkout.jadpo:5:9
|5 | if (flags.new_checkout) { | ^^^^^^^^^^^^^^^^^^ not tested with every value
- reason
- Each read of a flag is tested with every value it can have, so each branch is known to work before the flag is switched. No test reaches it. A test that doesn't name the flag in its
flags:gives it thedefault:,false. - next
- Add a test that reaches this read and leaves
new_checkoutat its default,false(agent) - rule
- flag.
value_ untested - help
- https://jadpo.
dev/docs/errors/flag. value_ untested
A test chooses a value with flags:, and a flag it doesn't name has its
default:.
domains/:
use shop/types { CheckoutShown }test "the three-step form is shown by default" { var shown: CheckoutShown = start_checkout() assert shown.page_count == 3}test "the new checkout takes one page" { flags: { new_checkout: true } var shown: CheckoutShown = start_checkout() assert shown.page_count == 1}
A flag's state lives in your app's own database, in tables Jadpo owns. The first flag needs a migration to make them:
jadpo migrate new add_flagscompared the entities with an empty database System tables + table jadpo_flag_changes system table for flag changes + table jadpo_flags system table for flags + table jadpo_housekeeping system table for housekeepingWrote database/migrations/20261104T1115_add_flags/ snapshot.json the whole schema at this point postgres.sql 4 statements: create table, create indexnext: jadpo check confirms the migration matches the entities
jadpo testtested: 2 passed, 2 action testsNow you can merge and deploy. Nobody sees the new checkout yet.
Switch it on
With the app running under jadpo dev, the checkout takes three pages:
curl http://localhost:3000/checkout{"page_count":3}jadpo flags changes the flag in the running app's database, with no deploy:
jadpo flags set new_checkout trueSet `new_checkout` was false for everyone (the default) now true for everyone logged ann · role jadpo applies within 10 s, on every server
Each server refreshes its flags every few seconds, so ask again a little later:
curl http://localhost:3000/checkout{"page_count":1}If it goes wrong, one command puts everyone back on the default. Nothing is redeployed, and every change is logged with who made it:
jadpo flags reset new_checkoutReset `new_checkout`: everyone gets its default was true for everyone now false for everyone (the default) logged ann · role jadpo applies within 10 s, on every server
jadpo flags finds the database as other operator commands do: a running
jadpo dev's, the address its connection's setting gives, or the one --url
names. So production's flags are changed with production's address.
Roll it out a little at a time
A flag doesn't have to be all or nothing. --percent gives a value to a
share of signed-in users, and --users to the users you name, by their id.
A named user comes first, then the share, then everyone else.
jadpo flags set new_checkout --percent 10Set `new_checkout` was false for everyone (the default) now true for 10% of users; false (the default) for everyone else logged ann · role jadpo applies within 10 s, on every server
The same user always gets the same answer, and raising 10% to 20% keeps the first 10% in. A caller who isn't signed in gets the everyone value, and so do schedules and subscribers, which have no caller.
jadpo flags list shows where each flag stands:
jadpo flags listflag everyone users shares changed state new_checkout false – true 10% 2026-11-04 * rolling out1 flag: main /private/var/folders/c3/bd2p8dtn3vs2wk9ld2fmk4k00000gn/T/jadpo-pg-75891-0/jadpo_f811f521d4_0_dev_main
It marks a flag "ready to remove" once every caller has had one value for seven days.
Show it to a browser
A page that has to know a flag's value can read it from the app. Add
client: true to the flag, and Jadpo generates GET /, answered for
whoever the request signed in as. A flag without client: true never leaves
the server.
Remove the flag
Once the change has proved itself, the flag and the old code have to go. Jadpo knows every read, so it does that for you:
jadpo flags remove new_checkout --keep truedomains/shop/actions/start_checkout.jadpo ~ flags.new_checkout 1 read, 1 `if` decided by true domains/shop/actions/start_checkout.test.jadpo - test "the three-step form is shown by default" tested a branch that goes - new_checkout: true in test "the new checkout takes one page" flags.jadpo - new_checkout flag, the last: `flags { }` stays, keeping its tablesRemoved `new_checkout` kept true tests 1 removed, each tested a branch that wentnext: jadpo test runs the tests that are left
The if has gone, along with the three-step branch and its test, and the
project still checks. Review the diff and commit it as you would any change.
Read more
Feature flags,
Flags: declared, read and tested,
Flags: changed, listed and removed,
jadpo flags and
Test settings.
Not built yet
- Giving a named tenant a fixed value isn't built yet. Name the tenant's
users with
--usersfor now. - Erasing a user doesn't yet remove their
idfromjadpo_flagsandjadpo_flag_changes, so a user named with--usersstays there. Prefer--percentwhere you can.
Markdown for agents: /guides/guide-ship-a-change-behind-a-flag.md.Built from Jadpo main at 94ab778. Jadpo's CI compiles this guide's code and runs its tests.