Skip to content

Dimensions and Scopes

Dimensions belong to the dial

Different dials vary along different axes. Bazario's fee varies by market; its delivery threshold varies by market and platform; its banner copy varies by locale; its kill switch must never vary at all. So dimensions are declared per dial:

ruby
dial :checkout_fee_bps, default: 250, type: :integer,
     dimensions: { market: { enum: %w[KE NG BD] } }

dial :free_delivery_threshold, default: 5_000, type: :integer,
     dimensions: { market: { enum: %w[KE NG BD] },
                 platform: { enum: %w[ios android web] } }

dial :welcome_banner, default: { "headline" => "Welcome" }, type: :json,
     dimensions: { locale: {} }        # open dimension: any non-empty string

dial :signups_enabled, default: true, type: :boolean   # no dimensions: global-only

A dimension with enum: validates every scope value against the list (an Array or a callable resolved on first use — e.g. -> { ISO3166::Country.codes }). An open dimension accepts any non-empty string. Dimension values are compared as strings: market: :KE and market: "KE" are the same scope.

One name is reserved: a dimension cannot be called actor. On the generated adjust_/clear_ methods scope travels as bare keywords next to actor:, which must always mean attribution.

Declaring dimensions IS the arming gate

A dial with no dimensions: cannot hold a scoped override — the write is rejected, not ignored. The API enforces a process rule:

Add the dimensions: declaration in the same PR as the code that reads the varied value.

Without the gate, an operator can create a "BD = 24" row that nothing consumes. It silently does nothing today — and months later, when a reader ships, the stale forgotten row suddenly takes effect in production. With the gate, a scope cannot be configured before something consumes it, and the diff that arms a dial is visible in review. The demo app pins its armed dials in a registry-integrity spec (spec/dials/registry_spec.rb) so arming one more dial fails a test until the pin is deliberately updated.

The same rule protects global-only dials: a kill switch declared without dimensions: cannot be half-off in one market, even by a determined operator with production console access to the API.

The exact-scope rule (v1)

Both reads and writes must name every declared dimension:

ruby
Dials.free_delivery_threshold(market: "KE", platform: "ios")   # ✓
Dials.free_delivery_threshold(market: "KE")                    # InvalidScope
Dials.free_delivery_threshold                                  # InvalidScope
Dials.signups_enabled(market: "KE")                            # InvalidScope

One sentence to remember: a scoped override matches exactly, or you get the global. No precedence table, no "which partial wins" question, nothing to misremember at 2am.

Canonical scopes

Stored scopes are canonicalized — keys sorted, values stringified — so { platform: :ios, "market" => "KE" } and { market: "KE", platform: "ios" } are one scope, enforced by a unique index on (dial_id, scope). A scope written once can never be re-stored under a cosmetically different spelling.

The path to partial scopes (if ever needed)

Sometimes you will want { market: "KE" } to cover every platform without writing three rows. That is a partial scope, and the gem is built so adding it later is a write-side relaxation, not a redesign:

  • The resolver already implements the general rule: a stored scope matches a request when every pair it names appears in the request; the most specific match (most dimensions named) wins; ties break by the dial's declared dimension order (declare market before platform and the market partial outranks the platform partial).
  • The storage model (canonical scope strings) represents partial scopes today.
  • Only Scope.validate!(..., exact: true) on the write path stands between v1 and partials — and the resolver's partial-scope behavior is already pinned by the gem's tests.

v1 ships exact-only because partial precedence is a concept operators must hold in their heads, and the right time to charge that cost is when a real need arrives — not before. Whether it ever ships is tracked in Possible Enhancements.

Constants you can turn without a deploy.