API Reference
Declaration
Dials.define(&block)
Runs the block against the registry. Blocks accumulate; a duplicate key raises Dials::DuplicateDial at boot.
dial(key, default:, type:, label: nil, unit: nil, description: nil, dimensions: nil, validate: nil, **constraints)
Declares one dial (inside a define block). Raises Dials::InvalidDefinition at boot when the declaration is malformed, a constraint keyword doesn't apply to the type, the default fails its own schema, or a generated method name is already taken.
Each declaration generates the dial's three methods on Dials: the bare <key> reader, adjust_<key>, and clear_<key> (see below). They are defined at declaration time — real methods, not method_missing. Because the reader is the bare name, a dial cannot share a name with a Dials method (:store, :cache, :changes, ...) — that raises at boot.
| Argument | Type | Required | Notes |
|---|---|---|---|
key | Symbol/String | yes | unique across the app; the only positional argument |
default: | value | yes | the code default; validated like any stored value |
type: | Symbol | yes | :boolean :integer :float :string :json |
label: | String | no | defaults to the humanized key |
unit: | String | no | display metadata ("bps", "cents", "hours") |
description: | String | no | shown on admin surfaces; write one |
dimensions: | Hash or Array | no | dimensions; see below |
validate: | callable | no | escape hatch for rules a schema cannot express; returns truthy for storable. Not serializable — prefer the schema keywords |
| constraints | keywords | no | value constraints in JSON Schema's vocabulary; see below |
Constraints
Constraints are JSON Schema keywords, snake_cased for Ruby, passed directly on dial. Each keyword is checked against the dial's type at boot — a pattern: on an :integer dial raises InvalidDefinition, not nothing.
| Keyword | Applies to | Meaning |
|---|---|---|
enum: | any type | non-empty Array of allowed values |
minimum: / maximum: | :integer :float | inclusive bounds |
exclusive_minimum: / exclusive_maximum: | :integer :float | exclusive bounds |
multiple_of: | :integer :float | must divide the value exactly |
min_length: / max_length: | :string | length in characters |
pattern: | :string | Regexp (or String compiled to one); must match |
properties: | :json | Hash of key => nested schema; see below |
required: | :json | Array of keys that must be present |
dial :checkout_fee_bps, default: 250, type: :integer, minimum: 1, maximum: 10_000
dial :tier, default: "low", type: :string, enum: %w[low medium high]
dial :support_email, default: "support@x.co", type: :string,
pattern: URI::MailTo::EMAIL_REGEXP, max_length: 254
dial :welcome_banner, default: { "headline" => "Hi", "cta" => "Go" }, type: :json,
properties: { "headline" => { type: :string, min_length: 1 },
"cta" => { type: :string } },
required: %w[headline cta]Nested schemas (inside properties:, and items: for arrays) must declare a type: — one of :boolean :integer :number :string :object:array (JSON Schema's own type names) — plus that type's keywords. Declaring properties:/required: pins a :json dial's values to JSON objects; keys not named in properties: are allowed.
dimensions: shapes, all equivalent where applicable:
dimensions: { market: { enum: %w[KE NG BD] } } # canonical
dimensions: { market: %w[KE NG BD] } # shorthand: enum array
dimensions: { market: -> { Market.pluck(:code) } } # callable, resolved lazily
dimensions: { locale: {} } # open: any non-empty string
dimensions: [:market, :platform] # names only, all openactor is a reserved dimension name — on the generated adjust_/clear_ methods it always means attribution, never scope.
Reading
Dials.<key>(**scope) → value
The generated reader:
Dials.signups_enabled # global-only dial
Dials.checkout_fee_bps(market: "KE") # varied dialResolves scoped override → global override → code default. Scope must name every declared dimension exactly (values compared as strings). Raises Dials::InvalidScope. Returned :json values are deep-frozen; hash keys are strings.
Dials.get(key, **scope) → value
The key-taking primitive under the bare <key> reader, for code that receives the key at runtime (an admin surface, a console). Same semantics; also raises Dials::UnknownDial for an undeclared key.
Dials.scoped_overrides(key) → { scope => value }
One dial's stored scoped overrides, keyed by parsed scope hashes (never canonical scope strings) — "which markets have an override for this dial":
Dials.scoped_overrides(:checkout_fee_bps) # => { { market: "BD" } => 120,
# { market: "NG" } => 180 }{} when nothing scoped is stored (or the dial declares no dimensions); raises Dials::UnknownDial for undeclared keys. Reads through the same snapshot path as every other read (in-transaction rule included); the result is deep-frozen.
Dials.overview → Overview
Every registered dial's full state — the Definition (with json_schema), whether a global override exists and its value, and its scoped overrides — read from ONE snapshot in one call, so an admin page renders a coherent picture stamped with a single version:
overview = Dials.overview
overview.version # store write-clock token (informational)
overview.dials.each do |state|
state.key # :checkout_fee_bps
state.definition # the Definition
state.json_schema # JSON Schema fragment for this dial
state.global_override? # explicitly present-or-absent...
state.global_value # ...because false ≠ "no override"
state.global_version # the global's stale-write token
# (Dials::ABSENT_VERSION when not stored)
state.scoped_overrides # { parsed scope => value }
state.scoped_override_versions # { parsed scope => stale-write token }
endAll returned structures are frozen.
Dials.changes(key: nil, limit: 50) → [ChangeRecord]
Newest-first history. ChangeRecord is a Data class: key, scope, action ("set"/"clear"), old_value, new_value, actor_type, actor_id, actor_label, created_at, plus #global?.
Dials.registry
Enumerable of Dials::Definition. Useful members for building UIs:
Dials.registry.keys # [:checkout_fee_bps, ...]
Dials.registry.fetch(:key) # Definition (raises UnknownDial)
Dials.registry.defined?(:key) # true/false
definition.key .default .type .label .unit .description
definition.dimensions? # any dimensions?
definition.dimension_names # [:market, :platform]
definition.dimensions # [Dimension(name, enum), ...]
definition.problems_for(value) # [] when storable, else messages
definition.to_json_schema # JSON Schema fragment; see belowDefinition#to_json_schema → Hash
The declaration as a JSON Schema fragment — camelCase keywords, pattern as its regexp source, title/description/default included — ready for a client-side validator or an agent reading the dial catalog:
Dials.registry.fetch(:checkout_fee_bps).to_json_schema
# => { "type" => "integer", "title" => "Checkout fee bps",
# "minimum" => 1, "maximum" => 10_000, "default" => 250, ... }A validate: callable is not representable and is simply absent from the output; the server-side check still runs on every write.
Writing
Dials.adjust_<key>(value, actor:, **scope) → value
The generated writer:
Dials.adjust_checkout_fee_bps(300, actor: current_admin) # global
Dials.adjust_checkout_fee_bps(120, actor: current_admin, market: "BD") # scopedStores an override — global with no scope keywords, scoped with them. Validates type, schema, and scope; requires actor: (which is why actor is a reserved dimension name). Appends to the change log and busts the local cache. Raises Dials::InvalidValue, Dials::InvalidScope, Dials::MissingActor.
expected_version: — stale-write protection
Every write path (generated and primitive) accepts expected_version:, which makes the write compare-and-swap against the override it targets (the global when there are no scope keywords, the named scoped override otherwise): pass that override's token from Dials.overview — or Dials::ABSENT_VERSION when the page showed no override stored — and the write is refused with Dials::StaleWrite, unapplied and with nothing appended to the change log, if the override has changed since. The comparison is atomic with the write via the database's own row primitives (guarded UPDATE/DELETE, the unique index for inserts): of two concurrent writes carrying the same token, exactly one commits, and an unconditional write interleaving has the same effect — nothing needs to opt in for the guarantee to hold. Writes to other overrides never conflict.
state = Dials.overview.dials.find { |s| s.key == :checkout_fee_bps }
# ... operator looks at the page, decides ...
token = Dials.adjust_checkout_fee_bps(300, actor: admin,
expected_version: state.global_version)
# a CAS write returns the override's NEW token — chain the next write:
Dials.clear_checkout_fee_bps(actor: admin, expected_version: token)
# a CAS clear returns Dials::ABSENT_VERSION: the override is goneTokens are opaque: obtain them from overview or a CAS write's return value and echo them back — never construct or parse one (Dials::ABSENT_VERSION is the one well-known constant, and it strictly means "never written": cleared overrides keep a tombstone token, so an old "absent" assertion goes stale the moment any set/clear touches the stream — no ABA). A CAS clear returns the tombstone's token, which chains into a later set. A token minted inside a database transaction that rolls back is void — it describes a write that never happened. expected_version is a reserved dimension name, like actor. Passing nothing keeps unconditional last-write-wins, and unconditional writes keep their usual return values (the value for set, the boolean for clear). The staleness check runs even when a clear would be a no-op — a page showing an override that no longer exists is stale.
On StaleWrite, re-render from a fresh Dials.overview and let the operator decide again; retrying automatically would defeat the mechanism (the stores deliberately never auto-retry it).
Dials.clear_<key>(actor:, **scope) → true/false
The generated remover. Removes an override; resolution falls to the next layer down. Returns whether an override existed; clearing nothing is a silent no-op (no log entry).
Dials.set(key, value, actor:, scope: nil) / Dials.clear(key, actor:, scope: nil)
The key-taking primitives under adjust_<key> / clear_<key>, for dynamic access. Scope travels as an explicit hash (scope: { market: "BD" }); both also raise Dials::UnknownDial for an undeclared key.
Configuration
Dials.configure do |config|
config.store = :active_record # or :memory, or any store instance
config.cache_ttl = 5.0 # seconds; 0 = probe every read; nil = never
config.actor_label = ->(actor) { } # change-log label builder
config.default_actor = nil # fallback attribution; see below
config.table_name_prefix = nil # "zar_" names the table zar_dials; see below
endconfig.table_name_prefix
Prefix for the gem-owned table, when dials collides with an existing table. Used verbatim — include the trailing underscore, as with Rails' table_name_prefix:
config.table_name_prefix = "zar_" # the table is zar_dialsThe migration must create the matching table; pass the same prefix to the install generator so both stay in step:
bin/rails generate dials:install --table-name-prefix=zar_nil (the default) keeps dials.
config.default_actor
Fallback attribution for writes that pass no actor: — for apps without user identity (no User model, single-operator tools, scripts). A string/object, or a callable evaluated per write:
config.default_actor = "anonymous" # log, anonymously
config.default_actor = -> { ENV.fetch("USER", "console") } # log the OS usernil (the default) keeps actor: required on every write. An explicit actor: always wins over the default. This is a declared app-level fallback, not discovery — the gem still never guesses (no Current.user magic).
Dials.reload!
Discard this process's snapshot; the next read rebuilds from the store. Needed after writes that bypass the gem, and in test suites (see Testing).
Testing
Dials::Testing.with_overrides(hash, &block)
Thread-local, validated, nestable value pinning for the block's duration. Applies to every scope of each pinned dial; never touches store, cache, or log.
Stores
A store is any object implementing the interface documented in Dials::Stores::Memory (state, version, override_version, set_override, clear_override, changes — the global override is the one at Scope::GLOBAL, the canonical empty scope; the write methods return [result, stamp] so tokens come from the write itself, never a second read). Shipped: Stores::Memory (default) and Stores::ActiveRecordStore (via require "dials/active_record").
Generator
bin/rails generate dials:installCreates the three-table migration and config/initializers/dials.rb.
Errors
All inherit Dials::Error:
| Error | Raised when |
|---|---|
UnknownDial | read/write of an undeclared key |
DuplicateDial | a key declared twice |
InvalidDefinition | malformed declaration (boot-time) |
InvalidValue | wrong type, schema violation, or nil on write/pin |
InvalidScope | wrong/missing/unknown dimensions or values |
MissingActor | write without actor: and no config.default_actor declared |
StaleWrite | expected_version: no longer matches the targeted override — unapplied, unlogged |
WriteConflict | concurrent unconditional writes to one override outran the store's retries (effectively never) |