Jadpo is experimental and not ready for production. These docs follow the compiler's main branch as of 5 Nov 2026. What's built · Install 0.3.0
jadpo
ReferenceFeature flags

Built-in services

Feature flags

partly builtMissing: turning a flag on for a named tenant; erasing a person from the flag tables. See What is built.

On this page

Ship a change behind a flag does the whole job, start to finish.

A feature flag lets you ship code switched off, then turn it on for some users, a share of users, or everyone, without a deploy. Its state is in your app's PostgreSQL database, in a table Jadpo owns. Each server holds a copy in memory and rereads it at most every five seconds, so a change applies within ten seconds and reading a flag runs no query. A percentage rollout is a hash of the flag's name and the user's id, so the same user always gets the same answer.

A flag is temporary. You add it with an expires: date, roll the change out, and remove the flag once everyone has one value; jadpo check fails once a flag is past its date, so none stays by accident. The dates here are far ahead only so these examples never expire. In your app, pick one a few weeks or months off.

You declare every flag once, with its type, its default, its owner and the date it expires. flags.jadpo, at the root of the project:

example
flags {    /// The one-page checkout replacing the three-step form.    new_checkout: Bool {        default: false        owner: "payments"        expires: "2099-01-31"    }}

flags { }, Bool and its settings are built into Jadpo. new_checkout is your name for the flag and "payments" whoever answers for it. The /// comment above a flag is its description, as for every declaration. app.jadpo needs nothing for it: a project's one database holds the flags.

An action reads it as flags.new_checkout. CheckoutSteps is a type of your app, since an action's result is always a named type.

domains/ordering/types/checkout_steps.jadpo:

example
/// How many pages the checkout takes.type CheckoutSteps = Int {    min: 1    max: 3}

domains/ordering/actions/checkout_page_count.jadpo:

example
use ordering/types { CheckoutSteps }/// How many pages this customer's checkout takes.action checkout_page_count() -> CheckoutSteps {    if (flags.new_checkout) {        return 1    }    return 3}

Code reads a flag in actions and in a route's run: arguments. Functions and queries can't read one (flag.read_context), so you pass them its value as a parameter. As with clock.now, the value is fixed for the whole operation, so one request never sees a flag change halfway through.

Every read needs a test for each value, and a test chooses the value with flags:.

domains/ordering/actions/checkout_page_count.test.jadpo:

example
test "the three-step form is shown by default" {    assert checkout_page_count() == 3}test "the new checkout takes one page" {    flags: {        new_checkout: true    }    assert checkout_page_count() == 1}

On a first run, jadpo migrate new initial writes the migration and jadpo records update the records, as jadpo check's next: lines say. Commit what they write.

A flag with named values

A flag is a Bool, or an enum whose variants carry no data. An enum names its values, so it suits comparing two layouts as well as choosing among more.

domains/ordering/types/checkout_layout.jadpo, a type of your app:

example
/// How the checkout's form is laid out.type CheckoutLayout = Enum {    classic    single_column}

flags.jadpo, with checkout_layout and its use line new:

example
use ordering/types { CheckoutLayout }flags {    /// The one-page checkout replacing the three-step form.    new_checkout: Bool {        default: false        owner: "payments"        expires: "2099-01-31"    }    /// Which layout the checkout uses while we compare two.    checkout_layout: CheckoutLayout {        default: CheckoutLayout.classic        owner: "payments"        expires: "2099-12-15"    }}

domains/ordering/actions/layout_shown.jadpo:

example
use ordering/types { CheckoutLayout }/// The layout this customer's checkout uses.action layout_shown() -> CheckoutLayout {    return flags.checkout_layout}

domains/ordering/actions/layout_shown.test.jadpo, a test for each variant:

example
use ordering/types { CheckoutLayout }test "the classic layout is shown by default" {    assert layout_shown() == CheckoutLayout.classic}test "a test can give the single-column layout" {    flags: {        checkout_layout: CheckoutLayout.single_column    }    assert layout_shown() == CheckoutLayout.single_column}

Changing a flag

A flag's current state lives in your app's own database, in a table Jadpo owns, jadpo_flags, in the database application { flags: { store: } } names, or the only one there is. With several databases and none named, the flags are flag.store_required. You change it while the app runs, with jadpo flags against that database (jadpo flags). Each change is logged with who made it: the name the operator's machine gives and the database role that made it. Whoever may write to the database may change a flag, as for every operator command; Jadpo has no operator accounts of its own.

Each server holds the flag values in its own memory and refreshes them every few seconds, so a change applies within ten seconds. Flags are never held in the cache. If the database can't be read, each server keeps the last values it had, or the defaults, so reading a flag never fails a request.

Flags aren't a capability, and no outside flag service fills them.

text
jadpo flags set new_checkout --users 0192f1c4-7a3e-7c1d-9b2a-5e6f7a8b9c0d --percent 10jadpo flags set checkout_layout --split classic=50,single_column=50jadpo flags list

The database is found as jadpo schedules finds its own: the one --url names, else the address its connection's setting gives, else a running jadpo dev's. There is no --env flag: an environment is its configuration, so production's flags are changed with production's address.

Who gets which value

The first rule that matches wins:

  1. Named users get a fixed value.
  2. Named tenants get a fixed value. This rule comes once tenancy is settled for flags (Tenancy).
  3. A percentage of users, chosen by a stable hash of the flag's name and the user's id. The same user always gets the same answer, raising 10% to 20% keeps the first 10% in, and each flag picks a different 10%.
  4. Everyone else gets the flag's current value, at first its default.

The runtime knows the caller, as it does for policy, so you don't pass it. Schedules and subscribers have no caller (Policy and background work), so they get the everyone value. An action that needs a user's variant later puts the value in the event it emits.

What the compiler checks

  • flags.new_chekout is a compile error, flag.unknown, like any unknown name.
  • Every flag read needs a test for each value. A test chooses the value with flags: { new_checkout: true }, beside clock: and config: (Test settings), for every caller in the test; a flag it doesn't name has its default:. jadpo check fails until the tests that reach each read cover both true and false, or every variant.
  • A flag past its expires: date is an error naming its owner, flag.expired, and you can't decline it. jadpo check judges the date against today, and jadpo build against the commit's date, so rebuilding a merged revision is never blocked by a date passing. To keep a flag longer you edit the date, so keeping one is a reviewed choice.
  • Whether a flag has sat at 100% or 0% is runtime state the compiler can't see, so jadpo flags list marks those flags "ready to remove": every caller has had one value for seven days.

Removing a flag

jadpo flags remove new_checkout --keep true rewrites your source: each read becomes true, the branch that can no longer run goes, the declaration goes, and test overrides for the removed value are deleted. Jadpo can do this exactly because it knows every read.

A test that gave the flag a removed value, or left it at a default: that is removed, and reached a read, tested the branch that has gone, so it goes too; a test that gave it the kept value loses only that line. The command lists every change file by file, and a removal whose result wouldn't check is undone, so the project always checks after it. The flag's row in the database is left: a row no flag has is never read. flags { } stays when its last flag goes, with the tables.

Flags in browsers and apps

A flag with client: true is also returned, worked out for the caller, by a generated GET /flags route in the OpenAPI document. Each API gets one under its prefix, answered for whoever the request signed in as, so a page reads the values its user gets. A flag without client: true never leaves the server.

json
{ "new_checkout": true, "checkout_layout": "single_column" }

What flags are not for

Flags are for releasing code. What a customer's plan includes is your app's data, guarded by policy. A setting that differs between environments is configuration (Settings: config.jadpo).

Details

The exact rules, for when you hit an edge case.

Flag rules: declaring, reading, testing

How a flag is declared, how your code reads it, and how a test sets it.

Flag rules: changing, listing, removing

How jadpo flags changes and lists a flag's value, and how a flag is removed from the code.

Markdown for agents: /docs/feature-flags.md.Built from Jadpo main at aee2e8f.