Skip to content

Smpl Flags overview

A feature flag in smplkit is a typed, named decision your code asks the platform about at runtime: should the new checkout flow be on for this user?, what's the retry timeout for this account?, which theme should this device render? Flags are environment-aware (different states in staging vs production), rules-driven (target by user, account, device, anything you can pass as a context), and typed (boolean, string, number, or JSON).

A concrete example

python
from smplkit import SmplClient, Op, Rule

with SmplClient(environment="production", service="web") as client:
    flag = client.flags.boolean_flag(
        "checkout-v2",
        default=False,
        description="Rolls out the new checkout experience.",
    )

    if flag.get():
        show_new_checkout()
    else:
        show_old_checkout()

Your code declares the flag once at startup. Every flag.get() evaluates locally — no network call. Behind the scenes, the SDK auto-registers the flag with the platform (via POST /api/v1/flags/bulk), keeps it in sync over WebSocket, and evaluates rules against your code's current context provider.

Flag types

A flag has exactly one type:

  • BOOLEAN — always two values; the kill switch / on-off pattern.
  • STRING — categorical choices (variant names, region codes, color labels).
  • NUMERIC — numeric tunables (timeouts, retry counts, thresholds).
  • JSON — structured config blobs (theme dictionaries, complex policy objects).

Constrained vs unconstrained

A flag can be constrained to a closed set of allowed values, or unconstrained:

  • Constrained (values array set). All defaults and rule outcomes must reference one of the listed values. The console renders dropdowns; renames cascade across the flag. Boolean flags are always constrained (with exactly two entries).
  • Unconstrained (values: null). Any value matching the type is accepted. Useful for tunables that legitimately take any number, or strings drawn from a large keyspace.

You can convert in either direction; conversions validate existing values to make sure nothing breaks.

Per-environment configuration

Every flag has a default at the top level (the absolute fallback) plus per-environment configuration:

json
{
  "id": "checkout-v2",
  "type": "BOOLEAN",
  "default": false,
  "environments": {
    "staging": {
      "enabled": true,
      "default": true,
      "rules": [...]
    },
    "production": {
      "enabled": true,
      "default": false,
      "rules": [...]
    }
  }
}

For a given environment:

  • enabled — the kill switch. When false, all rules are skipped and the environment-default (or top-level default) is served. When true, rules evaluate.
  • default — the per-environment default if no rule matches. Falls back to the flag's top-level default if unset.
  • rules — an ordered list. First match wins.

Environments not present in the flag's environments object inherit the top-level default with rules effectively disabled.

Targeting rules

A rule is a JSON Logic expression plus a value to serve when the expression is true:

json
{
  "description": "Enterprise users in US",
  "logic": {
    "and": [
      { "==": [{"var": "user.plan"}, "enterprise"] },
      { "==": [{"var": "account.region"}, "us"] }
    ]
  },
  "value": true
}

Rules live in environments[env].rules and are evaluated in order — the first whose logic evaluates true gets its value returned. If no rule matches, the environment-default is served.

The SDK's fluent Rule builder generates the JSON Logic for you:

python
from smplkit import Op, Rule

flag.add_rule(
    Rule("Enterprise users in US", environment="production")
    .when("user.plan", Op.EQ, "enterprise")
    .when("account.region", Op.EQ, "us")
    .serve(True)
)

Multiple .when() calls are AND-ed.

Evaluation order

For each flag.get():

  1. Look up the flag in the SDK's local cache.
  2. Read the current evaluation context from your registered provider.
  3. Find the configuration for the current environment.
  4. If enabled = false, return the environment-default (or top-level default).
  5. Otherwise iterate rules in order; first match returns its value.
  6. If no rule matches, return the environment-default (or top-level default).

All of this happens in-process. The SDK never makes a network call during evaluation.

Discovered vs managed

Flags auto-discovered via the SDK start as discovered (managed = false) — visible in the console but with no environment configuration, no rules, no per-environment defaults. The SDK's first declaration determines the flag's type and code-level default.

Once an admin edits a discovered flag in the console (any save), it becomes managed (managed = true) and gains the full configuration capability. There's no dedicated "promote" endpoint — it's an implicit transition through the first PUT.

Flag count is not sensitive to managed vs discovered: the flags.items governance check on POST /api/v1/flags counts every flag (managed or not). The check ships -1 (unlimited) on every plan today, so flag count is effectively uncapped, but the gate is in place. Auto-discovery (POST /api/v1/flags/bulk) is exempt from governance entirely. See Discovered vs managed.

Promote to managed

Promote an auto-discovered flag so you can configure environment-specific defaults, kill switches, and targeting rules. Prerequisites: at least MEMBER role.

Flag promotion is implicit

Unlike loggers, there's no dedicated promote endpoint for flags. Any PUT that sets managed: true (typically the first save from the console) makes the flag managed. The SDK's client.flags.boolean_flag(...) declaration always creates as discovered (via POST /api/v1/flags/bulk) — promotion happens later, through the console or an explicit API call.

In the console

  1. Go to Flags in the sidebar.
  2. Find the discovered flag in the list.
  3. Click the flag to open the editor.
  4. Make any change — set a per-environment default, add a rule, even just edit the description.
  5. Save.

The first save promotes the flag to managed. After that, all the environment configuration controls (kill switch, environment-default, rules, value editing) are available.

Via the API

bash
curl -X PUT https://app.smplkit.com/api/v1/flags/checkout-v2 \
  -H "Authorization: Bearer $SMPLKIT_API_KEY" \
  -H "Content-Type: application/vnd.api+json" \
  -d '{
    "data": {
      "type": "flag",
      "id": "checkout-v2",
      "attributes": {
        "managed": true,
        "type": "BOOLEAN",
        "default": false,
        "environments": {
          "production": {
            "enabled": true,
            "default": false,
            "rules": []
          }
        }
      }
    }
  }'

Once promoted, future PUTs preserve the managed = true state unless you explicitly set it to false (which the platform doesn't currently support — see below).

Via the SDK

The flag management SDK creates flags as managed when you call new_*_flag(...) and save() on a fresh flag — these go through POST /api/v1/flags, which defaults managed to true. To promote an existing discovered flag, fetch it first, mutate, and save:

python
from smplkit import AsyncSmplManagementClient, Op, Rule

async with AsyncSmplManagementClient() as manage:
    checkout = await manage.flags.get("checkout-v2")
    checkout.add_rule(
        Rule("Enterprise users", environment="production")
        .when("user.plan", Op.EQ, "enterprise")
        .serve(True)
    )
    await checkout.save()  # promotes to managed

Demoting a flag — not yet supported

The platform does not currently support demoting a managed flag back to discovered. Once a flag is managed, it stays managed for the life of the row. To clear a flag entirely, delete it:

bash
curl -X DELETE https://app.smplkit.com/api/v1/flags/checkout-v2 \
  -H "Authorization: Bearer $SMPLKIT_API_KEY"

The next time the SDK reports it via auto-discovery (POST /api/v1/flags/bulk), it'll come back fresh as a discovered flag.

Verify

bash
curl https://app.smplkit.com/api/v1/flags/checkout-v2 \
  -H "Authorization: Bearer $SMPLKIT_API_KEY"

data.attributes.managed should be true. Per-environment configuration, rules, and kill switches are now editable through the full flag schema.

Flag sources

Each (flag, service, environment) combination is recorded in flag_source. This shows you exactly where a flag is observed — which services declared it, which environments those services run in, what type and default each declaration used.

Drift across services is visible: if payments declared checkout-v2 as a BOOLEAN with default false, and web declared it as a STRING, you'll see two source rows with conflicting metadata. The first declaration wins on the flag itself; subsequent declarations only update source rows.

Limits

Plan-driven (entitlement keys):

  • flags.items — total flags per account, checked on POST /api/v1/flags. Currently -1 (unlimited) on every plan; auto-discovery (POST /api/v1/flags/bulk) is exempt regardless.
  • flags.rules — rules per flag, validated per write. Free: 5; Standard: 25; Pro: unlimited.

Hard caps (platform-wide):

  • 10,000 flag rows per account.
  • 100,000 flag source rows per account.

API surface

  • GET /api/v1/flags — list, filter by type, managed, references_context, references_context_type
  • GET /api/v1/flags/{key} — fetch one
  • POST /api/v1/flags — explicit create (creates as managed = true by default)
  • POST /api/v1/flags/bulk — auto-discovery (creates as managed = false)
  • PUT /api/v1/flags/{key} — full replace
  • DELETE /api/v1/flags/{key} — soft-delete
  • GET /api/v1/flags/{key}/sources — observed-by listing
  • GET /api/v1/flag_sources — cross-flag source listing