Skip to content

Build a Write Surface

The gem ships no admin GUI — deliberately. Every team's admin stack (ActiveAdmin, Avo, hand-rolled, an internal SPA) already has authentication, authorization, and styling that a bundled GUI would fight. What the gem provides is everything a thin surface needs: the registry to render, typed validated writes, attribution, and history.

The demo app's Admin::DialsController is the reference implementation. The essentials:

One note on API choice: this surface uses the key-taking primitives (Dials.get / Dials.set / Dials.clear) rather than the generated per-dial methods, because the dial key arrives as a request param — this is exactly the dynamic-access case the primitives exist for. Application code with the dial in hand uses Dials.checkout_fee_bps(...) and friends.

Render from one overview

ruby
def index
  overview = Dials.overview
  render json: {
    version: overview.version,   # informational "rendered as of" stamp
    absent_version: Dials::ABSENT_VERSION,
    dials: overview.dials.map { |state|
      definition = state.definition
      {
        key: definition.key,
        label: definition.label,
        type: definition.type,
        unit: definition.unit,
        description: definition.description,
        default: definition.default,
        schema: state.json_schema,
        dimensions: definition.dimensions.map { |d| { name: d.name, enum: d.enum } },
        global_override: state.global_override?,
        global_value: state.global_value,
        global_version: state.global_version,   # stale-write token for the global
        scoped_overrides: state.scoped_overrides.map { |scope, value|
          { scope: scope, value: value, version: state.scoped_override_versions[scope] }
        }
      }
    }
  }
end

Dials.overview reads everything from ONE snapshot, so the page is a coherent picture stamped with a single version. It tells your UI everything: what dials exist, what inputs to render (type), what to validate client-side (schema is a real JSON Schema fragment — feed it to any validator; the server re-checks; render enum values as selects), which cells are inherited versus overridden (global_override is an explicit boolean — a kill switch overridden to false must never render as "no override"), and which scopes hold overrides. There is no "dial CRUD" — dials are created in code, so the surface only edits values. For the resolved value in one specific context, Dials.get(key, **scope); for one dial's stored scoped overrides, Dials.scoped_overrides(key).

Writes: pass the authenticated admin as actor

ruby
def update
  Dials.set(dial_key, value_param, scope: scope_param, actor: current_admin,
                                   expected_version: params[:expected_version].presence)
  head :no_content
end

def destroy   # "clear" — return a cell to inheritance
  Dials.clear(dial_key, scope: scope_param, actor: current_admin,
              expected_version: params[:expected_version].presence)
  head :no_content
end

This is where your auth lives. The gem never guesses the actor; the controller — which knows who is logged in and has checked authorization — supplies it explicitly.

Stale-write protection

Echo each override's own version token back as expected_version when writing it (the payload's absent_version for cells rendered as inherited) and no operator can ever overwrite a change they didn't see: the gem refuses the write with Dials::StaleWrite (atomically — nothing applied, nothing logged) when that override has changed since the page rendered. Unrelated dials changing never conflict. Map it to 409 and re-render:

ruby
rescue_from Dials::StaleWrite do |error|
  render json: { error: error.message }, status: :conflict
end

On a 409 the client fetches a fresh overview, shows the operator what changed, and lets them decide again — never auto-retry, which would defeat the point. A successful CAS write returns the override's new token, so sequential edits to the same override can chain without a re-fetch.

Two hard-won details

Do not use params.require(:value). Rails' require rejects blank values, and false is blank — it would make a boolean kill switch impossible to turn off over HTTP. Use key-presence instead:

ruby
def value_param
  value = params.fetch(:value)                                 # key-presence, false-safe
  value.respond_to?(:to_unsafe_h) ? value.to_unsafe_h : value  # unwrap json-type hashes
end

Map the typed errors, don't swallow them. The gem's error messages are written for operators; pass them through:

ruby
rescue_from Dials::UnknownDial, with: -> { head :not_found }
rescue_from Dials::InvalidValue, Dials::InvalidScope do |error|
  render json: { error: error.message }, status: :unprocessable_content
end

History

ruby
def changes
  render json: Dials.changes(key: dial_key).map(&:to_h)
end

Render it as a timeline next to the dial, and consider surfacing old_value → new_value with the actor label in a confirm step before writes — an operator about to change a money-shaped number should see what they're replacing.

If you use ActiveAdmin / Avo

Register a page (not a resource — there's no model to CRUD) that renders from Dials.registry and posts to the two write actions above. Do not register the gem's internal model (Dials::ActiveRecord::Entry) as editable resources: direct model writes bypass validation, attribution, and cache busting — all three of which are the point.

Constants you can turn without a deploy.