FlagValue type
pub type FlagValue = BoolV{v: Bool} | StrV{v: Str} | IntV{v: Int} | NumV{v: F64} derive Eq, ShowThe value of one variant.
Package
Feature flags: targeting rules, segments, prerequisites, stable percentage rollouts, OpenFeature-style details, JSON definitions and a SQLite store with audit
0.1.0 MIT db fs
sspur add featureflags@^0.1.0or in sspur.toml:
featureflags = { version = "^0.1.0" }Feature flags for SSPUR services: boolean and multivariate flags (string, integer and number variants), explicit allow and deny lists, targeting rules over context attributes, reusable segments, prerequisites, and stable percentage rollouts and weighted splits. Evaluation is a pure function of a snapshot and a context and returns OpenFeature-style details: the value, the variant, the reason (STATIC, DEFAULT, TARGETING_MATCH, SPLIT, DISABLED, PREREQUISITE_FAILED, ERROR) and an error code (FLAG_NOT_FOUND, TYPE_MISMATCH, TARGETING_KEY_MISSING, GENERAL, ...). Definitions are a JSON format that is validated with errors that name the exact path, and they can be loaded from a file or kept in SQLite with versioned, optimistic-concurrency updates and an audit history.
$ sspur add featureflags
flags.json:
{"flags": [
{"key": "new-checkout",
"variants": [{"name": "on", "boolean": true}, {"name": "off", "boolean": false}],
"off_variant": "off",
"targets": [{"variant": "off", "keys": ["user-13"]}],
"rules": [{"id": "staff", "when": {"segment": "staff"}, "serve": {"variant": "on"}}],
"fallthrough": {"split": [{"variant": "on", "weight": 25}, {"variant": "off", "weight": 75}]}},
{"key": "theme",
"variants": [{"name": "light", "string": "light"}, {"name": "dark", "string": "dark"}],
"off_variant": "light",
"rules": [{"id": "night-owls", "when": {"attr": "hour", "op": "ge", "values": ["20"]}, "serve": {"variant": "dark"}}],
"fallthrough": {"variant": "light"}}
],
"segments": [{"key": "staff", "rules": [{"attr": "email", "op": "ends_with", "values": ["@example.com"]}]}]}
main.ssp:
use featureflags.{context, with_attr, evaluate, is_on, str_value, reason_name}
fn main() -> Unit ! fs, db, log, time, fail[DbErr], fail[featureflags.StoreErr]
= match featureflags.load_file("flags.json")
| err(e) => log("bad flags: {e}")
| ok(s) => do
ada = context("user-1").with_attr("email", "ada@example.com")
d = evaluate(s, "new-checkout", ada)
log("ada: {d.value}, {reason_name(d.reason)}, rule {d.rule}")
log("user-13: {is_on(s, "new-checkout", context("user-13"), true)}")
on = (0..10000).filter(i => is_on(s, "new-checkout", context("user-{i}"), false)).len
log("rollout: {on} of 10000 users")
log("theme at 21h: {str_value(s, "theme", context("u").with_attr("hour", "21"), "light")}")
log("missing flag: {reason_name(evaluate(s, "nope", ada).reason)}")
c = db.open(":memory:")
featureflags.open_store(c)
v = featureflags.import_snapshot(c, s, "deploy", now())
f = featureflags.put_flag(c, featureflags.bool_flag("kill-switch", false), some(0), "ada", now())
log("store version {featureflags.store_version(c)}, kill-switch v{f.version}")
log("history: {featureflags.history(c, "flag", "kill-switch").map(e => "{e.action} by {e.actor}")}")
$ sspur run main.ssp
ada: some(BoolV{v: true}), TARGETING_MATCH, rule some("staff")
user-13: false
rollout: 2521 of 10000 users
theme at 21h: dark
missing flag: ERROR
store version 4, kill-switch v1
history: ["put by ada"]
off_variant (DISABLED).off_variant (PREREQUISITE_FAILED). Prerequisite cycles are a validation error, and an ERROR if a snapshot with one is evaluated anyway.TARGETING_MATCH, rule target:VARIANT). Use them as allow and deny lists.TARGETING_MATCH) or its split (SPLIT).STATIC when the flag has no targets or rules, else DEFAULT, or SPLIT for a split.Typed accessors (is_on, str_value, int_value, num_value and their *_details forms) return the caller's default on any error, with the error in the details, so a missing or mistyped flag never breaks a request.
A split hashes flag key + "." + salt + "." + bucketing key with SHA-256 and maps the first four bytes to a bucket from 0 to 99999; the weights (any non-negative integers, such as percentages or parts of 100000) are laid out in order over the buckets. So a context always gets the same variant in every process and in both tiers, buckets are uniform (the tests check 20000 keys in ten bands, and a 10% and 30% rollout of 5000 keys), and raising a rollout's percentage keeps everyone who already had the new variant. Changing the flag's salt reshuffles it; bucket_by buckets by another attribute (an organization id, say), and a context without that attribute gets TARGETING_KEY_MISSING.
AttrClause{attr, op, values} compares a context attribute (key is the targeting key unless the attributes define key). Operators: equals, not_equals (one value), in, not_in, contains, not_contains, starts_with, ends_with, matches (any of the values; matches takes regular expressions, which the stdlib runs in linear time, so a pattern can't hang a server), lt, le, gt, ge (numbers), before, after (ISO 8601 times), exists, not_exists. A missing attribute fails every operator except not_exists, negated ones included. Combine with all, any and negate; {"segment": "staff"} tests segment membership (excluded keys never, included keys always, else any of its rules).
Context{key, attrs}, context(key), with_attr(c, name, value), attr(c, name).FlagValue (BoolV, StrV, IntV, NumV), type_name(v).Flag, Segment, Rule, Target, Prereq, Serve (Fixed{variant}, Weighted{weights, bucket_by}), Clause (AttrClause, SegmentClause, AllClauses, AnyClause, NotClause, AlwaysClause), Op.bool_flag(key, enabled), string_flag(key, enabled, variants, default, off), add_rule, add_target, add_prereq, with_fallthrough, rule(id, attr, op, values, serve), rollout(on, off, pct).Snapshot{flags, segments, version}, snapshot(flags, segments): an immutable value that request handlers can share.problems(s): every validation problem with its path; empty when valid.evaluate(s, key, ctx) gives Details{key, value, variant, reason, error, message, rule, flag_version}. evaluate_all(s, ctx): every flag, for bootstrapping a client.is_on, str_value, int_value, num_value; bool_details, str_details, int_details, num_details give Resolved[T]{value, details}.Reason, reason_name, ErrorCode, error_name (OpenFeature spellings).clause_holds(s, c, ctx), in_segment(s, seg, ctx), bucket(flag, salt, key), split_variant(weights, bucket).decode(text) (validated snapshot, or an error such as flags[1].rules[0].when.op: unknown operator 'eqals'), encode(s), load_file(path), fingerprint(s) (SHA-256 of the definitions, to tell whether anything changed).decode_flag, encode_flag, decode_segment, encode_segment: one definition.DefinitionsJson, FlagJson, VariantJson (a name and exactly one of boolean, string, integer, number), ClauseJson (exactly one of attr/op/values, segment, all, any, negate, always), ServeJson (variant, or split with optional bucket_by), WeightJson, RuleJson, SegmentJson. op_name, parse_op.open_store(c): create the tables (featureflags_flags, featureflags_segments, featureflags_audit, featureflags_meta).put_flag(c, f, expected, actor, at), delete_flag, put_segment, delete_segment: each a transaction that keeps the whole store valid (a flag can't name a missing segment, and a flag or segment still in use can't be deleted), with optimistic concurrency (expected is the version the caller read; a concurrent change is Conflict) and an audit entry.import_snapshot(c, s, actor, at): replace everything, auditing each change.load_store(c), store_version(c) (one row; compare with Snapshot.version), reload_if_changed(c, have).evaluate_stored(c, key, ctx): evaluate from the store, reading only the flag, its prerequisites and their segments; cheap enough for every request of a handler that shares the Db.history(c, kind, key), changes_since(c, version): AuditEntry{id, kind, key, action, definition, version, actor, at}.StoreErr: Invalid{msg}, Conflict{key, expected, actual}, NotFound{key}; raised from an http.serve handler they answer 400, 409 and 404.JSON-object variants (store JSON text in a string variant), semantic-version operators, streaming updates to remote clients, experiment analytics and exposure events (log Details yourself), and a hosted admin UI. Handlers can't keep a mutable cache between requests, so a service either shares one immutable Snapshot (and restarts, or polls reload_if_changed from a loop outside the handlers) or calls evaluate_stored per request.
MIT
pub type FlagValue = BoolV{v: Bool} | StrV{v: Str} | IntV{v: Int} | NumV{v: F64} derive Eq, ShowThe value of one variant.
fn type_name(v: FlagValue) -> StrThe name of a value's type: boolean, string, integer or number.
pub type Context = {key: Str, attrs: Map[Str, Str]} derive Eq, ShowWho a flag is evaluated for: the targeting key (a user, account or device id; used for explicit targets and as the default bucketing key) and string attributes. A rule's attribute key means the targeting key unless the attributes define key themselves.
fn context(key: Str) -> ContextA context for key with no attributes.
fn with_attr(c: Context, name: Str, value: Str) -> ContextThe context with attribute name set to value.
fn attr(c: Context, name: Str) -> Opt[Str]The value of attribute name in c, or none. key falls back to the targeting key, and an empty targeting key counts as missing.
pub type Op = Equals | NotEquals | In | NotIn | Contains | NotContains | StartsWith | EndsWith | Lt | Le | Gt | Ge | Matches | Before | After | Exists | NotExists derive Eq, ShowA comparison in a rule. Every operator except NotExists is false when the attribute is missing. Equals and NotEquals take one value, In and NotIn one or more, the string operators (Contains, NotContains, StartsWith, EndsWith) and Matches (a regular expression; the stdlib engine runs in linear time, so a pattern can't hang the server) are true when any value fits, Lt Le Gt Ge compare numbers (false if either side isn't one), Before and After compare ISO 8601 times, and Exists and NotExists take no values. Comparisons are exact and case-sensitive.
pub type Clause = AttrClause{attr: Str, op: Op, values: List[Str]} | SegmentClause{segment: Str} | AllClauses{cs: List[Clause]} | AnyClause{cs: List[Clause]} | NotClause{c: Clause} | AlwaysClause derive Eq, ShowA targeting condition: an attribute comparison, membership of a segment, or a combination. AllClauses([]) is true and AnyClause([]) is false.
pub type Serve = Fixed{variant: Str} | Weighted{weights: List[(Str, Int)], bucket_by: Str} derive Eq, ShowWhat a rule (or the fallthrough) serves: one variant, or a weighted split of variants by a stable hash of the bucketing attribute (bucket_by, key for the targeting key). Weights are relative non-negative integers (percentages, or parts of 100000 for finer steps).
pub type Rule = {id: Str, when: Clause, serve: Serve} derive Eq, ShowA targeting rule: when when holds, serve serve. id names it in evaluation details.
pub type Target = {variant: Str, keys: List[Str]} derive Eq, ShowContexts whose targeting key is in keys get variant, before any rule: allow and deny lists.
pub type Prereq = {flag: Str, variants: List[Str]} derive Eq, ShowThe flag flag must evaluate to one of variants for this flag to be on.
pub type Flag = {key: Str, enabled: Bool, variants: List[(Str, FlagValue)], off_variant: Str, fallthrough: Serve, targets: List[Target], rules: List[Rule], prerequisites: List[Prereq], salt: Str, version: Int} derive Eq, ShowA flag. When enabled is false it serves off_variant. Otherwise its prerequisites must hold (else off_variant), then explicit targets, then the first matching rule, then fallthrough decide. salt changes every bucket of the flag at once (leave it "" to bucket by the key alone); version counts changes.
pub type Segment = {key: Str, included: List[Str], excluded: List[Str], rules: List[Clause]} derive Eq, ShowA reusable set of contexts: excluded keys never belong, included keys always do, and otherwise a context belongs when any of rules holds. Segment rules may not refer to segments.
pub type Snapshot = {flags: Map[Str, Flag], segments: Map[Str, Segment], version: Int} derive Eq, ShowA set of flags and segments, with the version of the store they came from (0 for a file). Share it between request handlers as any immutable value.
fn bool_flag(key: Str, enabled: Bool) -> FlagA boolean flag: on (true) and off (false), off when disabled, on for everyone when enabled unless rules say otherwise.
fn string_flag(key: Str, enabled: Bool, variants: List[(Str, Str)], default_variant: Str, off_variant: Str) -> FlagA flag with string variants, serving default_variant to everyone when enabled and off_variant when disabled.
fn add_rule(f: Flag, rule: Rule) -> FlagThe flag with rule added after its other rules.
fn add_target(f: Flag, variant: Str, keys: List[Str]) -> FlagThe flag with an explicit target: keys get variant.
fn add_prereq(f: Flag, flag: Str, variants: List[Str]) -> FlagThe flag with a prerequisite.
fn with_fallthrough(f: Flag, s: Serve) -> FlagThe flag serving s when no target or rule decides.
fn rollout(on: Str, off: Str, pct: F64 where _ >= 0.0 and _ <= 100.0) -> ServeA percentage rollout: pct percent (0 to 100, in steps of 0.001) of contexts, by targeting key, get on, the rest off. Raising the percentage later keeps everyone who already had on.
fn rule(id: Str, attr: Str, op: Op, values: List[Str], serve: Serve) -> RuleA rule matching one attribute.
fn snapshot(flags: List[Flag], segments: List[Segment]) -> SnapshotA snapshot of these flags and segments (later ones replace earlier ones with the same key).
fn bucket(flag_key: Str, salt: Str, bucket_key: Str) -> IntThe bucket of bucket_key for a flag, from 0 to 99999: the first four bytes of SHA-256(flag_key "." salt "." bucket_key), as an unsigned big-endian integer, scaled to
uniform to within 0.003%.
fn split_variant(weights: List[(Str, Int)], b: Int where _ >= 0 and _ < 100000) -> Opt[Str]The variant a split gives to bucket b (0 to 99999): weights are laid out in order over the buckets, in proportion. None when every weight is 0.
pub type Reason = Static | Default | TargetingMatch | Split | Disabled | PrerequisiteFailed | ErrorReason derive Eq, ShowWhy a flag has the value it has, as in OpenFeature: Static (the flag has no targets or rules), Default (nothing matched; the fallthrough variant), TargetingMatch (a target or rule matched), Split (a weighted split chose), Disabled (the flag is off), PrerequisiteFailed (a prerequisite did not hold; the off variant), or ErrorReason (see the error code; the caller's default is used).
fn reason_name(r: Reason) -> StrThe OpenFeature spelling of a reason: STATIC, DEFAULT, TARGETING_MATCH, SPLIT, DISABLED, PREREQUISITE_FAILED or ERROR.
pub type ErrorCode = FlagNotFound | TypeMismatch | ParseError | TargetingKeyMissing | InvalidContext | General derive Eq, ShowWhat went wrong, as in OpenFeature: no such flag, a typed accessor for another type, a split without its bucketing attribute, or a definition that refers to something missing or forms a prerequisite cycle (General, with a message). ParseError and InvalidContext complete the OpenFeature set for code that wraps this package (definitions that failed to load, a context it rejects); evaluation itself never produces them.
fn error_name(e: ErrorCode) -> StrThe OpenFeature spelling of an error code: FLAG_NOT_FOUND, TYPE_MISMATCH, PARSE_ERROR, TARGETING_KEY_MISSING, INVALID_CONTEXT or GENERAL.
pub type Details = {key: Str, value: Opt[FlagValue], variant: Opt[Str], reason: Reason, error: Opt[ErrorCode], message: Str, rule: Opt[Str], flag_version: Int} derive Eq, ShowThe result of evaluating a flag: the value and variant (none on an error), why, the error code and a message on an error, the id of the rule that matched (target:VARIANT for an explicit target), and the flag's version.
fn clause_holds(s: Snapshot, c: Clause, ctx: Context) -> BoolWhether c holds for ctx, with the snapshot's segments. A clause naming a missing segment is false (validation reports it).
fn in_segment(s: Snapshot, seg: Segment, ctx: Context) -> BoolWhether ctx belongs to segment seg: not excluded, and included or matched by a rule. A segment rule that names a segment is false (validation reports it).
fn evaluate(s: Snapshot, key: Str, ctx: Context) -> DetailsEvaluates flag key for ctx. Pure: the same snapshot and context always give the same details.
pub type Resolved[T] = {value: T, details: Details} derive Eq, ShowA typed result: the value (the caller's default on any error) and the details.
fn bool_details(s: Snapshot, key: Str, ctx: Context, default: Bool) -> Resolved[Bool]A boolean flag's value and details; default on any error, including TypeMismatch.
fn str_details(s: Snapshot, key: Str, ctx: Context, default: Str) -> Resolved[Str]A string flag's value and details; default on any error.
fn int_details(s: Snapshot, key: Str, ctx: Context, default: Int) -> Resolved[Int]An integer flag's value and details; default on any error.
fn num_details(s: Snapshot, key: Str, ctx: Context, default: F64) -> Resolved[F64]A number flag's value and details (integer variants are accepted as numbers); default on any error.
fn is_on(s: Snapshot, key: Str, ctx: Context, default: Bool) -> BoolWhether boolean flag key is on for ctx; default on any error.
fn str_value(s: Snapshot, key: Str, ctx: Context, default: Str) -> StrA string flag's value for ctx; default on any error.
fn int_value(s: Snapshot, key: Str, ctx: Context, default: Int) -> IntAn integer flag's value for ctx; default on any error.
fn num_value(s: Snapshot, key: Str, ctx: Context, default: F64) -> F64A number flag's value for ctx; default on any error.
fn evaluate_all(s: Snapshot, ctx: Context) -> List[Details]Every flag of the snapshot evaluated for ctx, in key order: what a client-side SDK bootstrap needs.
fn problems(s: Snapshot) -> List[Str]Every problem in a snapshot, each with its path (flags.KEY.rules[0].when.values: ...): unknown variants, segments and prerequisite flags, prerequisite cycles, operator arities, numbers and times that don't parse, bad regular expressions, splits without positive weights, variants of mixed types, duplicate names and keys that aren't allowed. Empty when the snapshot is valid.
pub type VariantJson = {name: Str, boolean: Opt[Bool], string: Opt[Str], integer: Opt[Int], number: Opt[F64]} derive Eq, ShowA variant on the wire: its name and exactly one of boolean, string, integer, number.
pub type ClauseJson = {attr: Opt[Str], op: Opt[Str], values: Opt[List[Str]], segment: Opt[Str], all: Opt[List[ClauseJson]], any: Opt[List[ClauseJson]], negate: Opt[ClauseJson], always: Opt[Bool]} derive Eq, ShowA clause on the wire: exactly one of an attribute comparison (attr, op, values), segment, all, any or negate; {} with always: true always holds.
pub type WeightJson = {variant: Str, weight: Int} derive Eq, ShowA weighted variant of a split on the wire.
pub type ServeJson = {variant: Opt[Str], split: Opt[List[WeightJson]], bucket_by: Opt[Str]} derive Eq, ShowA serve on the wire: variant, or split (with an optional bucket_by, default key).
pub type RuleJson = {id: Str, when: ClauseJson, serve: ServeJson} derive Eq, ShowA rule on the wire.
pub type FlagJson = {key: Str, enabled: Opt[Bool], variants: List[VariantJson], off_variant: Str, fallthrough: ServeJson, targets: Opt[List[Target]], rules: Opt[List[RuleJson]], prerequisites: Opt[List[Prereq]], salt: Opt[Str], version: Opt[Int]} derive Eq, ShowA flag on the wire. Missing targets, rules, prerequisites, salt and version are empty (0); a missing enabled is true.
pub type SegmentJson = {key: Str, included: Opt[List[Str]], excluded: Opt[List[Str]], rules: Opt[List[ClauseJson]]} derive Eq, ShowA segment on the wire.
pub type DefinitionsJson = {flags: List[FlagJson], segments: Opt[List[SegmentJson]]} derive Eq, ShowA definitions document: {"flags": [...], "segments": [...]}.
fn op_name(op: Op) -> StrThe name of an operator in the JSON format (equals, not_in, starts_with, lt, ...).
fn parse_op(name: Str) -> Opt[Op]The operator with this JSON name.
fn decode(text: Str) -> Res[Snapshot, Str]The snapshot a definitions document describes, validated (see problems). Errors name the path: flags[1].rules[0].when.op: unknown operator 'eqals'. Flags and segments must have unique keys.
fn encode(s: Snapshot) -> StrThe definitions document of a snapshot; decode reads it back to the same flags and segments.
fn encode_flag(f: Flag) -> StrOne flag as JSON in the definitions format.
fn encode_segment(s: Segment) -> StrOne segment as JSON in the definitions format.
fn decode_flag(text: Str) -> Res[Flag, Str]A flag from its JSON (one element of flags), checked on its own: references to other flags and segments are checked when it joins a snapshot.
fn decode_segment(text: Str) -> Res[Segment, Str]A segment from its JSON (one element of segments).
fn load_file(path: Str) -> Res[Snapshot, Str] ! fsThe definitions in a JSON file, validated.
fn fingerprint(s: Snapshot) -> StrA fingerprint of a snapshot's definitions (the SHA-256 of their JSON, in hex): equal snapshots have equal fingerprints, so a poller can tell whether anything changed.
pub type StoreErr = Invalid{msg: Str} | Conflict{key: Str, expected: Int, actual: Int} | NotFound{key: Str} derive Eq, ShowWhy a store change was refused: the change would make the definitions invalid, the flag or segment changed since the version the caller read (expected), or it does not exist. The variant names answer 400, 409 and 404 when an http.serve handler raises them.
pub type AuditEntry = {id: Int, kind: Str, key: Str, action: Str, definition: Opt[Str], version: Int, actor: Str, at: Time} derive Eq, ShowOne entry of the audit history: what changed (flag or segment, its key), the action (put or delete), the definition after a put, the store version it produced, who did it and when.
fn open_store(c: Db) -> Unit ! db, fail[DbErr]Creates the store's tables if they are missing (featureflags_flags, featureflags_segments, featureflags_audit, featureflags_meta). Safe to call on every start.
fn store_version(c: Db) -> Int ! db, fail[DbErr]The store's version: it increases by one with every change, so comparing it with a snapshot's version is the cheap way to tell whether to reload.
fn load_store(c: Db) -> Snapshot ! db, fail[DbErr], fail[StoreErr]Every flag and segment in the store, as a snapshot with the store's version. Read in one transaction, so it is consistent.
fn reload_if_changed(c: Db, have: Int) -> Opt[Snapshot] ! db, fail[DbErr], fail[StoreErr]The store's snapshot if its version differs from have, else none: a poll costs one indexed read when nothing changed.
fn put_flag(c: Db, f: Flag, expected: Opt[Int], actor: Str, at: Time) -> Flag ! db, fail[DbErr], fail[StoreErr]Creates or replaces flag f, in a transaction: the whole store must stay valid with it. With expected (the flag's version the caller read, 0 for a new flag) a concurrent change is a Conflict instead of being overwritten. Returns the flag as stored, with its new version.
fn delete_flag(c: Db, key: Str, expected: Opt[Int], actor: Str, at: Time) -> Unit ! db, fail[DbErr], fail[StoreErr]Deletes flag key; refused while another flag has it as a prerequisite.
fn put_segment(c: Db, g: Segment, expected: Opt[Int], actor: Str, at: Time) -> Int ! db, fail[DbErr], fail[StoreErr]Creates or replaces segment g; the store must stay valid with it.
fn delete_segment(c: Db, key: Str, expected: Opt[Int], actor: Str, at: Time) -> Unit ! db, fail[DbErr], fail[StoreErr]Deletes segment key; refused while a flag uses it.
fn import_snapshot(c: Db, s: Snapshot, actor: Str, at: Time) -> Int ! db, fail[DbErr], fail[StoreErr]Replaces everything in the store with the snapshot's flags and segments (keeping each flag's version counting), in one transaction, with one audit entry per change. Returns the new store version.
fn history(c: Db, kind: Str, key: Str) -> List[AuditEntry] ! db, fail[DbErr]The audit history of one flag or segment (kind is flag or segment), oldest first.
fn changes_since(c: Db, since: Int) -> List[AuditEntry] ! db, fail[DbErr]The audit entries after store version since, oldest first: what changed since a snapshot.
fn evaluate_stored(c: Db, key: Str, ctx: Context) -> Details ! db, fail[DbErr], fail[StoreErr]Evaluates flag key straight from the store, reading only that flag, its prerequisites and the segments they use: cheap enough for every request of a handler that shares the Db.
| Version | Published | Commit | Source | Effects |
|---|---|---|---|---|
| 0.1.0 | 2026-10-16 | a4cd55744e | snapshot | db fs |