lucerna

Server-side Lucerna SDK for Ruby: local Gates evaluation (feature flags, experiments, kill switches) with a background runtime poll, and fire-and-forget Identity delivery into People. Pure Ruby, zero runtime dependencies, Ruby >= 3.2. Works in any Ruby process — Rails (a Railtie is included), Sinatra, Sidekiq, plain scripts.

It takes a server key (ck_srv_… or ck_key_…). A publishable browser key (ck_client_…) cannot download gates rules and is rejected at construction. Never put a server key in code that ships to a client.

This is the Ruby sibling of @lucerna-dev/gates-node + @lucerna-dev/identity: same wire protocol, same frozen bucketing contract, same golden vectors.

Install

Not yet published to RubyGems — point Bundler at the monorepo:

gem "lucerna", path: "../lucerna/sdks/ruby"

Quickstart

require "lucerna"

Lucerna.configure do |config|
  config.server_key = ENV["LUCERNA_SERVER_KEY"] # ck_srv_… or ck_key_…, never ck_client_…
end

# One identity per request feeds both products.
identity = Lucerna::Identity.new(
  user_id: "u_42",         # pseudonymous app id — never an email
  email: "sofia@acme.com", # PII: first-class field, only identify transmits it
  traits: { plan: "pro" },
)

Lucerna.gates.wait_until_ready(timeout: 5) # optional — reads are safe before ready

enabled = Lucerna.gates.flag("new_billing", identity)       # => true / false
variant = Lucerna.gates.experiment("checkout_v2", identity) # => "treatment", … or nil
healthy = Lucerna.gates.switch("payments")                  # => false means killed

Lucerna.identity.identify(identity) # fire-and-forget upsert into People

In Rails, put the Lucerna.configure block in config/initializers/lucerna.rb. The Railtie defaults Lucerna.config.logger to Rails.logger.

Documentation

API

Export Signature What it does
Lucerna.configure `{ \ config\ … }` Set config before first use. Raises if a client was already built.
Lucerna.gates Gates::Client Lazy singleton, built from config on first access.
Lucerna.identity Identity::Client Lazy singleton for identify delivery.
Lucerna.close (timeout: 2) Stop polling/delivery, flush queues. Also runs via at_exit.
Lucerna.restart Abandon clients after a fork (on_worker_boot); next access rebuilds.
Lucerna::Identity.new (user_id:, email: nil, name: nil, traits: {}) Immutable identity value; validates strictly.
Gates::Client#flag (key, identity = nil) → bool Flag decision. Unknown key / pre-load: false.
Gates::Client#experiment (key, identity = nil) → String? Variant name or nil. The only exposure-recording read.
Gates::Client#switch (key) → bool Kill switch. false means killed. Unknown: true.
Gates::Client#evaluate (identity = nil) → Hash All decisions (SSR/bootstrap). No exposures.
Gates::Client#inspect_gate (key, identity = nil) → Hash Decision + rule-by-rule trace. No exposures.
Gates::Client#wait_until_ready (timeout: nil) → bool True after first load; raises AuthError on 401/403.
Gates::Client#close (timeout: 2) Stop the poll, flush exposures. Stale reads keep answering.
Identity::Client#identify (identity) or kwargs Validates, enqueues, returns immediately.
Identity::Client#flush (timeout: 2) → bool Block until queued identifies were attempted.

Anywhere an identity is accepted: a Lucerna::Identity, a { user_id:, email:, traits: } hash, or nil (anonymous).

Guarantees & semantics

  • Gate decisions are UX hints, never authorization. Anything entitlement-shaped is re-checked server-side.
  • Reads never raise and fail open: flag → false, experiment → nil, switch → true (not killed). Last-known beats default — stale beats default, default beats crash.
  • Bucketing is frozen: murmur3 (x86 32-bit) over "#{salt}:#{unit_id}", basis points (hash % 10000), stored salts. Renaming a key never reshuffles users. The golden vectors in sdks/core/src/vectors.json are the append-only cross-language contract and run in this gem's test suite.
  • Startup download + 10s ETag poll — kill switches propagate in ≤10s (poll) + ≤10s (server cache). The runtime swap is atomic: readers see the whole old or whole new document, never a torn one.
  • Exposure-on-read: only experiment() records, only when a variant was assigned to a known user. Deduped locally (10 min), batched (≤100/request), delivered on the poll tick, flushed on close. track_exposures: false opts out.
  • Unknown rule elements fail safe: a gate carrying an operator or override kind this version doesn't know answers whole with unsupported_rule — flag off, experiment unassigned.
  • Fork-safe: after Process.fork (Puma cluster, Unicorn, Sidekiq), the first read detects the dead poller and respawns it, starting warm from the inherited runtime; inherited exposure/identify backlogs are dropped, never replayed. Lucerna.restart in on_worker_boot / after_fork is optional and merely avoids the first-read latency blip.
  • PII discipline: user_id is a pseudonymous app id. email / name are first-class fields — never traits — and only identify transmits them. Gates evaluation is local and transmits nothing; exposure events carry only the user_id.

Errors & failure modes

  • wait_until_ready returns true on first successful load, false on timeout, and raises Lucerna::AuthError if the first settle was a 401/403 (bad key — discoverable, and sticky). The poll continues regardless: a key fixed later recovers reads without a restart.
  • Network errors and 5xx retry inside a fetch (3 attempts, jittered backoff), then the poll retries every interval. Any 4xx is a stable answer and fails the attempt immediately.
  • All background failures route to config.on_error (or config.logger at warn). A raising on_error is swallowed — it can never take down a read or a worker thread.
  • identify raises ArgumentError on invalid input (programming error); delivery never raises — one attempt, no retry, because the server upserts idempotently and the next identify is the retry.
  • A runtime document with a newer schemaVersion is rejected (upgrade the gem); the current runtime keeps answering.