cru-flags 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: ddbe75a3b8dd51184b5c884571040346e4f33e96bd0301e7dc845a290b8bb10b
4
+ data.tar.gz: c3bf794d63aaae3a521f9916bd5c00d0c620bdc363b1aa08fd850a3b31a1c136
5
+ SHA512:
6
+ metadata.gz: 9415e6972df83cb2c45d4a7b7de065ad99a47f884f507a057cc7550c96b1c9f4143be31eca001826d34393e0304ebaea4808a39580ec924df6f337a466296f82
7
+ data.tar.gz: f2f12796650212518e932577bc789f1bbf25746fbe2aea574654e7ddad8e37f91badfdb73879f46d9dd871a1a556bc477b364d20aa54b195ba749112224277a3
data/CHANGELOG.md ADDED
@@ -0,0 +1,15 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (2026-08-24)
4
+
5
+
6
+ ### Features
7
+
8
+ * the cru-flags client — sibling contract + Flipper adapter ([#1](https://github.com/CruGlobal/cru-flags-ruby/issues/1)) ([02dfa26](https://github.com/CruGlobal/cru-flags-ruby/commit/02dfa264a68c48ccdce6055893aef5fe50fe8473))
9
+
10
+
11
+ ### Miscellaneous Chores
12
+
13
+ * release 0.1.0 ([2ed0e67](https://github.com/CruGlobal/cru-flags-ruby/commit/2ed0e67e0ce0441d80ba8d1dc6559a9d8995de17))
14
+
15
+ ## Changelog
data/LICENSE ADDED
@@ -0,0 +1,30 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2026, Cru
4
+ All rights reserved.
5
+
6
+ Redistribution and use in source and binary forms, with or without
7
+ modification, are permitted provided that the following conditions are met:
8
+
9
+ 1. Redistributions of source code must retain the above copyright notice,
10
+ this list of conditions and the following disclaimer.
11
+
12
+ 2. Redistributions in binary form must reproduce the above copyright notice,
13
+ this list of conditions and the following disclaimer in the documentation
14
+ and/or other materials provided with the distribution.
15
+
16
+ 3. Neither the name of the copyright holder nor the names of its
17
+ contributors may be used to endorse or promote products derived from this
18
+ software without specific prior written permission.
19
+
20
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
23
+ ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
24
+ LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
25
+ CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
26
+ SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
27
+ INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
28
+ CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
29
+ ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
30
+ POSSIBILITY OF SUCH DAMAGE.
data/README.md ADDED
@@ -0,0 +1,35 @@
1
+ # cru-flags
2
+
3
+ > **Status: AI-generated, not actively maintained by a human team.** This
4
+ > library was authored primarily by an AI assistant against the specification
5
+ > in [docs/design.md](docs/design.md), reviewed and operated by the Cru Rails
6
+ > team. Read the design doc before changing behavior — drift between the
7
+ > design doc and the code is a bug, and the doc wins.
8
+
9
+ The official Ruby client for Cru's pipeline feature-flag service, and the
10
+ third sibling of [cru-flags-node](https://github.com/CruGlobal/cru-flags-node)
11
+ and [cru-flags-python](https://github.com/CruGlobal/cru-flags-python) — plus a
12
+ read-only Flipper adapter so the Rails fleet's existing `Flipper.enabled?`
13
+ call sites work unchanged.
14
+
15
+ ```ruby
16
+ # In a Rails app: add the gem, done. The Railtie wires Flipper for you.
17
+ Flipper.enabled?(:checkout_v2)
18
+
19
+ # Anywhere else:
20
+ require "cru_flags"
21
+ CruFlags.enabled?("checkout_v2")
22
+ ```
23
+
24
+ Configuration is one environment variable: `CRU_FLAGS_URL`, the full document
25
+ URL (`https://deploys.cru.org/flags/<project>/<environment>`). Unset means
26
+ every flag is off and nothing starts — no thread, no socket, no warning.
27
+
28
+ Semantics in one paragraph: the client polls the document in a background
29
+ thread (30s ± 20% jitter, 2s timeouts, conditional GETs via ETag) and answers
30
+ reads from a frozen in-memory snapshot. **Fail-static**: all flags are off
31
+ until the first successful fetch; after that, an outage means flags stop
32
+ changing — never that they turn off. Reads never raise and never do I/O.
33
+ Health transitions (and only transitions) are logged.
34
+
35
+ CRuby >= 3.2 only. See [docs/design.md](docs/design.md) for everything else.
data/docs/design.md ADDED
@@ -0,0 +1,535 @@
1
+ # cru-flags-ruby — Design Document
2
+
3
+ > ADL-35. Drafted 2026-08-21 against cru-flags-python's docs/design.md (the
4
+ > most complete sibling spec) and the recon in adl-35-cru-flags-ruby-recon.md.
5
+ > When the CruGlobal/cru-flags-ruby repo is created this file moves in as
6
+ > `docs/design.md` and inherits the siblings' rule: drift between the design
7
+ > doc and the code is a bug, and the doc wins.
8
+
9
+ ## 1. Purpose & Scope
10
+
11
+ `cru-flags` is the official Ruby client for Cru's pipeline feature-flag
12
+ service, and the third sibling of `@cruglobal/flags` (Node) and `cru-flags`
13
+ (Python). It answers exactly one question, as cheaply and as safely as
14
+ possible:
15
+
16
+ ```ruby
17
+ Flipper.enabled?(:checkout_v2) # the fleet's existing call sites, unchanged
18
+ CruFlags.enabled?("checkout_v2") # the sibling-parity API, same answer
19
+ ```
20
+
21
+ The flag service publishes one JSON document per (project, environment) at a
22
+ public HTTP endpoint. This library polls that document in the background and
23
+ answers reads from an in-memory snapshot. It is a **read-only client**: flags
24
+ are authored elsewhere (cru-cli / the deploys.cru.org dashboard), never by
25
+ this library.
26
+
27
+ The Ruby client differs from its siblings in one deliberate way: it ships a
28
+ **Flipper adapter** that answers Flipper's read API from the same in-memory
29
+ snapshot. The ~20 Rails apps already call `Flipper.enabled?` (ADL-9's
30
+ ratified standard; ararat's hand-rolled `flipper.rb` is the current
31
+ reference), so the gem keeps that API working while replacing everything
32
+ underneath it. What it replaces, and why, is §4.
33
+
34
+ ### In scope for v0.x
35
+
36
+ - Read `CRU_FLAGS_URL` from the environment; poll that URL in a background
37
+ thread; answer `enabled?(name)` from the last document received.
38
+ - Conditional requests (`If-None-Match` / `ETag`) so steady-state polling
39
+ costs one 304 per interval.
40
+ - An opt-in `refresh_mode: "on-demand"` that drops the thread and refreshes
41
+ synchronously on the reading thread, for runtimes that freeze between
42
+ requests (§7.1) — included for contract parity with the siblings.
43
+ - **Fail-static** semantics: a flag lookup never raises, never blocks (in
44
+ background mode), and never changes answer because the network broke.
45
+ - A read-only `CruFlags::FlipperAdapter` over the snapshot, plus a Railtie
46
+ that wires it into Flipper with zero app code.
47
+ - Stdlib-only client core (`net/http`, `json`, `thread`); Ruby >= 3.2.
48
+
49
+ ### Out of scope
50
+
51
+ - **Writing** flags, or any authentication. The flag documents are public,
52
+ read-only URLs.
53
+ - Per-actor / group / percentage targeting, variants, or non-boolean flag
54
+ values. The service models a flag as a boolean with metadata; so does this
55
+ client. The Flipper adapter exposes **only the boolean gate**.
56
+ - Local flag overrides, file-based sources, streaming, or a daemon/sidecar.
57
+ - The service's `/flipper/{project}/{env}/features` protocol endpoints. Stock
58
+ Flipper's HTTP adapter needed them; ours reads the snapshot, so the
59
+ collection/single-feature asymmetries documented in the recon simply never
60
+ come into play.
61
+
62
+ ### Non-goals
63
+
64
+ - We do **not** try to be a general feature-flag SDK. There is one document
65
+ shape, defined by our own service, and this client is allowed to know it.
66
+ - We do **not** raise on misconfiguration. An application missing
67
+ `CRU_FLAGS_URL` must still boot and serve traffic, with every flag off —
68
+ exactly ararat's current Memory-adapter fallback posture.
69
+ - We do **not** cache in `Rails.cache`. The snapshot is process-local memory;
70
+ there is no TTL, no Marshal round-trip, and therefore no TTL cliff and no
71
+ default_proc trap (§4).
72
+
73
+ ---
74
+
75
+ ## 2. Wire contract
76
+
77
+ Identical to the siblings'. The live document (a real response from
78
+ `https://deploys.cru.org/flags/ararat/release-candidate`):
79
+
80
+ ```json
81
+ {
82
+ "Project": "ararat",
83
+ "Environment": "release-candidate",
84
+ "Version": 3,
85
+ "NotifySlack": true,
86
+ "Flags": {
87
+ "pilot_banner": {
88
+ "Enabled": true,
89
+ "Description": "Pilot: flag-gated banner proving the flag service end-to-end (ararat#198)",
90
+ "CreatedAt": "2026-07-31T14:09:01.119Z",
91
+ "UpdatedAt": "2026-07-31T14:09:08.777Z",
92
+ "UpdatedBy": "Omicron7"
93
+ }
94
+ }
95
+ }
96
+ ```
97
+
98
+ Load-bearing properties:
99
+
100
+ | Property | Contract |
101
+ | --- | --- |
102
+ | Top level | JSON object with `Project`, `Environment`, `Version`, `NotifySlack`, `Flags`. PascalCase keys. |
103
+ | `Flags` | Object keyed by flag name. Each value is an object with at least `Enabled: bool`; other keys are metadata. |
104
+ | Unknown keys | **Additive.** New top-level and per-flag keys may appear at any time; the client ignores what it does not understand and never validates beyond "is a JSON object". |
105
+ | `ETag` | Present on `200`, currently the stringified `Version`. Opaque to the client — store and echo it, never parse it. |
106
+ | `304 Not Modified` | Returned when `If-None-Match` matches. Body empty; the previous snapshot stays in force. |
107
+ | `404 Not Found` | A *valid* answer meaning "no document exists yet", not a failure. |
108
+ | `400 Bad Request` | The URL names an environment that does not exist (only `release-candidate` and `production` are real). This *is* a failure: the caller's configuration is wrong and someone should see it. |
109
+
110
+ `enabled?(name)` is defined precisely as:
111
+
112
+ ```ruby
113
+ document.dig("Flags", name, "Enabled") == true
114
+ ```
115
+
116
+ The `== true` identity check (not truthiness) is deliberate: a flag whose
117
+ `Enabled` is the *string* `"true"`, or `1`, or `null` is a malformed
118
+ document, and the safe reading of a malformed document is "off".
119
+
120
+ ---
121
+
122
+ ## 3. Public API
123
+
124
+ ```ruby
125
+ CruFlags.enabled?("checkout_v2") # -> bool, never raises
126
+ CruFlags.ready(timeout: 3.0) # -> bool, never raises
127
+ CruFlags.snapshot # -> Hash, plain JSON-serializable deep copy
128
+ CruFlags.refresh(force: false) # -> bool, refresh now on this thread
129
+ CruFlags.close # -> nil, stop refreshing (mostly for tests)
130
+ CruFlags.flipper_adapter # -> the read-only Flipper adapter (§5)
131
+
132
+ CruFlags::Client.new(
133
+ url: nil, # nil -> read CRU_FLAGS_URL on first use
134
+ poll_seconds: 30.0, # refresh interval, ±20% jitter
135
+ fetch_timeout: 2.0, # per-request open AND read timeout
136
+ on_error: nil, # nil -> log to the "cru_flags" logger (§3.6)
137
+ refresh_mode: nil # nil -> read CRU_FLAGS_REFRESH_MODE, else "background"
138
+ )
139
+ ```
140
+
141
+ ### 3.1 `CruFlags` module methods
142
+
143
+ The module methods delegate to a lazily-constructed process-wide
144
+ `CruFlags::Client` singleton — the 99% path, mirroring the siblings'
145
+ module-level `flags`. Requiring the gem does **nothing**: no environment
146
+ read, no socket, no thread (§7). The singleton is constructed on first use,
147
+ so tests and dotenv can set `CRU_FLAGS_URL` any time before the first lookup.
148
+
149
+ ### 3.2 `enabled?(name) -> bool`
150
+
151
+ Non-blocking read of the current snapshot (in on-demand mode it may refresh
152
+ first; §7.1). **Never raises** — the entire body is rescued so no bug in this
153
+ library, no malformed document, and no shutdown race can take down a caller's
154
+ request path. Anything unexpected answers `false`. Accepts a String or
155
+ Symbol; Symbols are converted with `to_s` (the document's keys are strings;
156
+ Flipper call sites pass symbols).
157
+
158
+ Unknown flag, unset flag, no document yet, no `CRU_FLAGS_URL`, service down
159
+ before the first success — all `false`.
160
+
161
+ ### 3.3 `ready(timeout: nil) -> bool`
162
+
163
+ Blocks until the **first fetch attempt completes** — success *or* failure —
164
+ and returns whether it completed within `timeout`. Returns `true` after a
165
+ failed first attempt (the attempt did complete, and "all off" is the final
166
+ answer until the next poll). Returns `false` immediately, without blocking,
167
+ when the client is inert (no URL) — there is no attempt to wait for and never
168
+ will be. In on-demand mode `ready` *performs* the first attempt like any
169
+ other read, so `timeout:` is unused there — the call is bounded by the fetch
170
+ timeouts (up to ~2s by default) regardless of a smaller `timeout:` value,
171
+ exactly as the python client documents. Never raises.
172
+
173
+ ### 3.4 `snapshot -> Hash`
174
+
175
+ A plain, deep-copied, JSON-serializable Hash of the last document received
176
+ (`{}` before the first success). `JSON.generate(CruFlags.snapshot)`
177
+ round-trips to exactly what the service sent. The copy exists so a caller
178
+ cannot mutate library state; the internally stored snapshot is genuinely
179
+ frozen (§6).
180
+
181
+ ### 3.5 `refresh(force: false) -> bool`
182
+
183
+ Fetches on the *calling* thread and returns whether the snapshot is fresh (an
184
+ attempt has completed and the most recent one succeeded). Without `force` it
185
+ is a no-op while the last attempt is younger than `poll_seconds`. Blocks for
186
+ at most the fetch timeouts. Never raises. This is the refresh primitive in
187
+ on-demand mode, and an out-of-band poke in background mode — where, per the
188
+ Node contract, concurrent `refresh` calls and a concurrent poller tick all
189
+ **share one in-flight request** rather than stacking simultaneous fetches.
190
+
191
+ ### 3.6 `on_error`
192
+
193
+ A callable invoked **only on health transitions**:
194
+
195
+ - ok → failing: called with the exception that broke the poll.
196
+ - failing → ok: called with `nil` (recovery).
197
+
198
+ Never called per-poll while a failure persists, and never for `304` or `404`.
199
+ The default logs both transitions at WARN — to `Rails.logger` when Rails is
200
+ loaded, else to a `Logger` on `$stderr` tagged `cru_flags`. This is the
201
+ structural replacement for ararat's `LoggedFlagFetches` delegator: the two
202
+ 2026-07-31 outages that motivated it were invisible precisely because
203
+ failures were swallowed silently; transition logging makes an outage exactly
204
+ two log lines (one down, one up) instead of zero or thousands. Exceptions
205
+ raised *by* `on_error` are swallowed: a broken error handler must not kill
206
+ the poller.
207
+
208
+ ### 3.7 `close`
209
+
210
+ Stops refreshing, in either mode. Terminal: a closed client never refreshes
211
+ again, and closing before the first lookup leaves the client permanently
212
+ inert. The last snapshot stays readable. Mostly useful in tests.
213
+
214
+ ---
215
+
216
+ ## 4. Why fail-static (and what this replaces)
217
+
218
+ The design constraint is inverted relative to a normal HTTP client: **the
219
+ flag client is allowed to be wrong, but never allowed to be loud, slow, or
220
+ fatal.**
221
+
222
+ 1. **`enabled?` performs no I/O** in the default background mode: one ivar
223
+ read and two Hash lookups. No lock on the read path, so a slow network can
224
+ never become a slow request. It also performs the fork/liveness guard (a
225
+ `Process.pid` check plus `Thread#alive?`) that lazily re-arms a dead or
226
+ post-fork poller, still lock-free and network-free — the guard only takes
227
+ a lock on the (rare) path where it actually needs to respawn.
228
+ 2. **All flags are `false` until the first successful fetch.** A flag guards
229
+ *new* behaviour; the safe answer while ignorant is the old behaviour.
230
+ 3. **Last-known-good persists indefinitely. There is no TTL.** This is the
231
+ failure-mode semantic §6 of the fleet's original flags design doc (the
232
+ Google-Doc-era spec, snapshotted as cru-flags-design-doc.md) promised and
233
+ the current fleet recipe does not deliver: ararat composes `Flipper::Adapters::Failsafe` outside a
234
+ 30-second `ActiveSupportCacheStore`, so 30 seconds into any flag-service
235
+ outage every flag snaps to `false` — a synchronised, fleet-wide behaviour
236
+ change caused by exactly the outage a flag client should ride through.
237
+ Holding the last deliberately-published configuration is the whole reason
238
+ this gem exists. Freshness is a monitoring question (the transition log,
239
+ `ready`, `snapshot["Version"]`), never a silent behaviour flip.
240
+ 4. **404 is data, not an error.** "This project has no flags yet" is the
241
+ normal state of every project on day one.
242
+ 5. **Nothing raises.** `enabled?` and `ready` rescue `StandardError` —
243
+ `Interrupt`, `SystemExit`, and other non-StandardError exceptions still
244
+ propagate.
245
+
246
+ The stock-Flipper traps this architecture retires by construction (all
247
+ verified live in the recon):
248
+
249
+ - Flipper's Rails engine installs a **Memoizer middleware that preloads
250
+ `get_all` on every request** — with a network adapter underneath, Failsafe
251
+ is load-bearing and any gap 500s the whole app. Our adapter's `get_all` is
252
+ a frozen-Hash read; the preload becomes free and Failsafe has nothing to
253
+ guard.
254
+ - `Flipper::Adapters::Http` must be given `max_retries: 0` **explicitly**
255
+ (its internal default never applies) and leaves open/read timeouts at
256
+ Net::HTTP's ~60s unless set. Our HTTP layer sets 2s/2s explicitly and never
257
+ sits on a request path.
258
+ - The `default_proc` that Marshal — and therefore any Marshal-backed
259
+ `Rails.cache` — cannot serialize (ararat's `MarshalableFlipperReads`
260
+ incident) is added by `Flipper::Adapters::Memoizable#get_all`
261
+ (memoizable.rb:103 in 1.4.2), **which wraps ANY adapter the DSL is given,
262
+ including ours** — not by the Http adapter as ararat's comments suggest.
263
+ With no `Rails.cache` layer nothing ever Marshals the result, so the trap
264
+ is gone — but Memoizable's mutation constrains our adapter's return values;
265
+ see the hard rule in §5.
266
+
267
+ ---
268
+
269
+ ## 5. The Flipper adapter
270
+
271
+ `CruFlags::FlipperAdapter` — a read-only adapter over the client's frozen
272
+ snapshot, so every existing `Flipper.enabled?(:name)` call site works
273
+ unchanged.
274
+
275
+ - `features` → the Set of flag names in the current document.
276
+ - `get(feature)` / `get_multi` / `get_all` → gate-value hashes shaped by
277
+ `Flipper::Adapter.default_config` (which in 1.4 includes the `expression`
278
+ gate key alongside boolean/actors/groups/percentages), with **only the
279
+ boolean gate** populated: `true` when the flag's `Enabled == true`, off
280
+ otherwise. The service is boolean-only.
281
+ - **HARD RULE: `get`, `get_multi`, and `get_all` must return freshly-built,
282
+ UNFROZEN top-level Hashes on every call** (their nested gate values may
283
+ reference frozen snapshot data; the top-level containers may not be
284
+ frozen and may not be memoized/shared). Flipper's DSL wraps every adapter
285
+ in `Adapters::Memoizable` by default, and its `get_all` **mutates the
286
+ returned Hash in place** (`response.default_proc = ...`,
287
+ memoizable.rb:103) on every Memoizer-preloaded request — a frozen or
288
+ shared return value raises `FrozenError` on every request in production,
289
+ a stricter recurrence of the very failure mode §4 retires. The suite
290
+ pins this with a test that calls the adapter through
291
+ `Flipper::Adapters::Memoizable` under memoization.
292
+ - `add` / `remove` / `clear` / `enable` / `disable` →
293
+ raise `CruFlags::ReadOnlyError`. The CLI and dashboard are the only
294
+ writers; this preserves the behavior of ararat's
295
+ `Flipper::Adapters::ReadOnly` wrapper (whose own error class is
296
+ `ReadOnly::WriteAttempted`) without another wrapper layer.
297
+ - The adapter holds no state of its own and takes no locks — it reads
298
+ whatever frozen document the client currently publishes, so a Memoizer
299
+ preload, a console `Flipper.enabled?`, and a background job all see the
300
+ same atomically-swapped snapshot.
301
+
302
+ ### 5.1 Railtie
303
+
304
+ `require "cru_flags"` in a Rails app (via the Gemfile) installs a Railtie
305
+ that, in an initializer:
306
+
307
+ - registers the adapter via `Flipper.configure { |c| c.adapter { CruFlags.flipper_adapter } }`
308
+ in an ordinary initializer — safe against ordering because Flipper's engine
309
+ resolves `config.adapter` LAZILY on first `Flipper.instance` access (its
310
+ `flipper.default` block calls it at invocation time, not at boot), and the
311
+ last `AdapterBuilder#store` wins, so an app's own `Flipper.configure` in
312
+ `config/initializers` still overrides ours;
313
+ - quiets strict mode for the no-flag-document case, **but not with a naive
314
+ "unless the app set it" check** — Flipper's engine populates
315
+ `config.flipper.strict` in `config.before_configuration` (`:warn` in
316
+ development, `false` in production), which runs before ANY gem initializer,
317
+ so the value is always already set when we look. Instead the Railtie sets
318
+ `config.flipper.strict = false` only when `ENV["FLIPPER_STRICT"].nil?` AND
319
+ the current value equals Flipper's own computed default for this Rails env —
320
+ i.e. only when we can prove nobody chose it. `FLIPPER_STRICT=warn` (or any
321
+ app-set value) is always honored. Rationale: local dev and brand-new
322
+ projects legitimately have no flag document, and `Flipper.add` is never
323
+ called by app code (flags are born in the service), so dev-mode `:warn`
324
+ would fire on every check.
325
+
326
+ With `CRU_FLAGS_URL` unset the client is inert, the snapshot is empty, and
327
+ every flag reads `false` — byte-for-byte ararat's current Memory-adapter
328
+ fallback, with zero configuration branching. An app that wants different
329
+ wiring (a test adapter, Flipper UI in development) configures Flipper itself;
330
+ the Railtie only supplies the default.
331
+
332
+ Non-Rails Ruby (plain scripts, sidekiq-less workers) skips the Railtie and
333
+ uses `CruFlags.enabled?` directly.
334
+
335
+ ---
336
+
337
+ ## 6. Snapshot storage & atomicity
338
+
339
+ The parsed document is **deep-frozen** before it is published — via
340
+ `JSON.parse(body, freeze: true)` (json >= 2.4, which also interns repeated
341
+ strings) rather than a hand-rolled recursive walk. The frozen tree is then
342
+ assigned to a single instance variable — one atomic reference swap — while
343
+ holding a mutex that only *writers* contend for. Readers never take the lock.
344
+
345
+ The lock-free read depends on CRuby's GVL making a single reference store
346
+ observable atomically; **the gem supports CRuby only** (the entire fleet is
347
+ CRuby; JRuby/TruffleRuby have no GVL and would need a read barrier we have no
348
+ consumer for). The gemspec/README say so.
349
+
350
+ Why frozen rather than "a hash we promise not to mutate": the snapshot is
351
+ shared across every thread in the process, and one accidental
352
+ `snapshot["Flags"]["x"]["Enabled"] = true` in caller code would be an
353
+ un-debuggable, cross-thread, non-reproducible bug. Freezing makes it a
354
+ `FrozenError` at the point of the mistake. `CruFlags.snapshot` hands back a
355
+ thawed deep copy so the public API stays ordinary Ruby.
356
+
357
+ Consistency guarantee: a reader sees either the whole old document or the
358
+ whole new one, never a half-applied update.
359
+
360
+ ---
361
+
362
+ ## 7. Threading model
363
+
364
+ - **One background Thread per active client**, `Thread#name` set to
365
+ `"cru-flags-poller"`, `abort_on_exception` left false, `report_on_exception`
366
+ set false (the loop rescues everything; a teardown race must not spam
367
+ stderr).
368
+ - **Started lazily** on the first `enabled?` / `ready` call, never at
369
+ require. Requiring a gem must not start threads: it breaks preloading
370
+ forking servers (Puma workers, Spring) that require before forking, and
371
+ makes `require "cru_flags"` in a test suite a side-effecting act. **After
372
+ `fork`, the child re-starts its own poller lazily on first read** — the
373
+ client detects a PID change and discards the parent's dead thread state
374
+ (Puma's `fork_worker` and Spring both make this a first-class case in
375
+ Rails, where the siblings could treat forking as exotic).
376
+ - **Never delays exit.** Ruby threads other than the main thread die with
377
+ the process — no daemon flag needed — but the loop also sleeps
378
+ interruptibly (a `Queue#pop` with timeout / `ConditionVariable#wait`), so
379
+ `close` returns promptly instead of waiting out a poll interval.
380
+ - **Jitter**: each sleep is `poll_seconds * rand(0.8..1.2)`. Fleet pods boot
381
+ together; without jitter they synchronise into a thundering herd against
382
+ the flag service. ±20% de-phases them within a couple of intervals.
383
+ - **Inert clients start nothing.** No `CRU_FLAGS_URL` and no explicit `url:`
384
+ means no thread, no socket, no warning — just `false`.
385
+
386
+ ### 7.1 On-demand refresh (`refresh_mode: "on-demand"`)
387
+
388
+ Contract-identical to the siblings' mode (python design §5.1): no poller
389
+ thread at all; every read fetches first when the last **attempt** is
390
+ `poll_seconds` or older, else answers from cache. Concurrent readers coalesce
391
+ on one mutex — a burst of N readers is one request. Staleness anchors on the
392
+ last attempt, not the last success, so a dead service costs one failed
393
+ request per interval, not one per read. No jitter (real traffic de-phases
394
+ itself). The cost is the read-path guarantee: `enabled?` can block up to the
395
+ fetch timeouts, once per interval.
396
+
397
+ Selecting it: `Client.new(refresh_mode: "on-demand")` or
398
+ `CRU_FLAGS_REFRESH_MODE=on-demand`. The constructor argument wins. An
399
+ unrecognised *environment* value warns through `on_error` and falls back to
400
+ background (misconfiguration must never stop boot); an unrecognised
401
+ *constructor* argument raises `ArgumentError` (that is a typo in code).
402
+
403
+ Why it ships at all: the Rails fleet is long-running ECS and will use
404
+ background mode everywhere — but the three clients advertise one contract,
405
+ the mode is ~100 lines plus ported tests, and the first Cloud-Run-shaped
406
+ Ruby consumer gets it for free instead of forking the gem.
407
+
408
+ ### 7.2 Environment resolution
409
+
410
+ `CRU_FLAGS_URL` and `CRU_FLAGS_REFRESH_MODE` are read once, at first use, and
411
+ cached. Empty or whitespace-only counts as unset. Only `http`/`https` URLs
412
+ are accepted; anything else makes the client inert with one warning through
413
+ `on_error`.
414
+
415
+ ---
416
+
417
+ ## 8. Fetch algorithm
418
+
419
+ One tick:
420
+
421
+ 1. Build a `Net::HTTP::Get` with `Accept: application/json`, a `User-Agent`
422
+ of `cru-flags-ruby/<version>`, and `If-None-Match: <etag>` when an ETag is
423
+ stored.
424
+ 2. One-shot `Net::HTTP.start(host, port, use_ssl:, open_timeout: t,
425
+ read_timeout: t, write_timeout: t)` — no keep-alive, no connection
426
+ pooling; the right trade for one request per 30 seconds. **No retries
427
+ within a tick** — the next tick is the retry. Redirects are followed to a
428
+ fixed limit of **3** (Net::HTTP does not follow them itself; the siblings
429
+ inherit auto-follow from `fetch`/`urllib`). Ruby-first hardening the
430
+ siblings may want to adopt: each redirect hop's merged URI is
431
+ re-validated (http/https scheme, non-empty host) before it is followed,
432
+ and the response body is capped at `MAX_BODY_BYTES` (1 MiB) before
433
+ parsing, both failing the tick rather than handing an attacker-controlled
434
+ value to `Net::HTTP` or `JSON.parse`.
435
+ 3. Outcomes:
436
+
437
+ | Outcome | Action | Health |
438
+ | --- | --- | --- |
439
+ | `200` + JSON object | Freeze and publish; **then** store `ETag` | ok |
440
+ | `304` | Keep snapshot and ETag | ok |
441
+ | `404` | Publish empty snapshot; clear ETag | ok |
442
+ | Other status (`400`, `5xx`, …) | Keep snapshot | failing |
443
+ | Timeout / DNS / connection error | Keep snapshot | failing |
444
+ | Body not JSON, or not a JSON object | Keep snapshot | failing |
445
+
446
+ Document-before-ETag ordering is load-bearing (inherited from the Node
447
+ client): a body that fails to parse must never get its ETag stored, or
448
+ the client would pin itself to the bad response via endless 304s.
449
+
450
+ 4. Report the health transition (§3.6), if any.
451
+
452
+ Parsing is lenient exactly like the siblings, with the Node client's §6.1
453
+ rules as the reference: missing/`null` `Flags` becomes `{}`, but `Flags`
454
+ **present with the wrong type** (a string, number, or array) fails the whole
455
+ document (health → failing) — leniency is for absence, not for malformation.
456
+ Individual non-Hash flag *entries* are dropped without failing the document;
457
+ all unknown keys are preserved verbatim. (§2's "never validates beyond is-a-
458
+ JSON-object" is scoped by these rules, same as the siblings' docs.)
459
+
460
+ ---
461
+
462
+ ## 9. Testing strategy
463
+
464
+ Every line of the behavioural contract is a test, in the python client's
465
+ organisation: contract tests grouped by design-doc section, against **a real
466
+ local HTTP server** (a minimal programmable responder on a stdlib
467
+ `TCPServer` at `127.0.0.1:0` — WEBrick is no longer a default gem and the
468
+ responder needs ~60 lines), recording inbound headers so the ETag echo, 304,
469
+ 404, 500, slow-response, and malformed-body paths exercise real `Net::HTTP`
470
+ behaviour rather than a mock's idea of it.
471
+
472
+ Plus, ported from the siblings and ararat:
473
+
474
+ - Subprocess tests: `require "cru_flags"` starts no threads even with
475
+ `CRU_FLAGS_URL` set; a running poller never delays interpreter exit.
476
+ - A fork test: parent warms the client, child forks, child's reads work and
477
+ spawn the child's own poller (§7's PID-change case).
478
+ - Jitter distribution; a concurrency hammer on `enabled?` across snapshot
479
+ swaps; `JSON.generate`/`parse` round-trip of `snapshot`.
480
+ - Flipper conformance: `Flipper.new(CruFlags.flipper_adapter)` answering
481
+ `enabled?` end-to-end against the local server, the Memoizer preload path,
482
+ writes raising `ReadOnlyError`, and — the regression ararat paid for —
483
+ `Marshal.dump(Rails.cache-shaped round trip)` of everything `get_all`
484
+ returns.
485
+ - On-demand mode: its own section (coalescing, one-fetch-per-interval while
486
+ down, no thread).
487
+ - `test/verify_live.rb`, opt-in and networked, against the real public
488
+ endpoint (document parses; second conditional fetch is 304). Not in CI.
489
+
490
+ Framework: minitest (the fleet standard), `filterwarnings`-equivalent
491
+ strictness via `Warning[:deprecated] = true` and a test that the suite emits
492
+ no warnings. CI: one required check running lint (standardrb, the fleet
493
+ direction per AP-7) + the suite on the `.tool-versions` Ruby, plus a matrix
494
+ re-running the suite on 3.2 / 3.3 / 3.4.
495
+
496
+ ---
497
+
498
+ ## 10. Packaging & distribution
499
+
500
+ - Gem `cru-flags`, require path `cru_flags`, module `CruFlags`, repo
501
+ `CruGlobal/cru-flags-ruby`. Version lives in `lib/cru_flags/version.rb`,
502
+ bumped by release-please (`release-type: ruby` **with an explicit
503
+ `"version-file": "lib/cru_flags/version.rb"`** — release-please's default
504
+ path derivation turns the hyphenated name into `lib/cru/flags/version.rb`
505
+ and would silently bump a file that doesn't exist).
506
+ - `required_ruby_version = ">= 3.2"` — the fleet floor.
507
+ - Runtime dependency: **`flipper` (`~> 1.4`) only.** The client core uses
508
+ stdlib exclusively; the Flipper adapter and Railtie are the gem's reason to
509
+ have its one dependency, and every consumer already carries it. (If a
510
+ flipper-less consumer ever appears, splitting a zero-dep core gem out is a
511
+ follow-up, not a v0 concern.)
512
+ - Publishing via **RubyGems Trusted Publishing** (OIDC) from
513
+ `.github/workflows/release.yml` — the PyPI pattern; no API key in the
514
+ repo. First publish uses RubyGems' pending-publisher flow since the gem
515
+ name is unclaimed.
516
+ - Repo onboarding matches the fleet standard from day one: security-only
517
+ `dependabot.yml` (bundler + github-actions lanes per the ADL-32 template),
518
+ a `dependabot-auto-merge` gate is NOT installed (review-tier repo), README
519
+ status banner and CLAUDE.md same-PR-doc rule copied from the siblings.
520
+
521
+ ---
522
+
523
+ ## 11. Rollout (out of scope for the gem, recorded for sequencing)
524
+
525
+ 1. Gem ships and publishes v0.1.0.
526
+ 2. ararat cuts over: Gemfile swap, delete `config/initializers/flipper.rb`
527
+ and its two regression tests (their scenarios live in the gem's suite
528
+ now). ararat is the proving ground exactly as it was for the hand-rolled
529
+ version.
530
+ 3. devops-claude-skills onboarding skill Stage 4 changes from "copy ararat's
531
+ flipper.rb" to "add the cru-flags gem".
532
+ 4. The remaining fleet picks it up as apps onboard to v2 (no mass retrofit —
533
+ only ararat and clm are on v2 today).
534
+
535
+ Each step is its own PR with its own review; none blocks the gem.
@@ -0,0 +1,308 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "logger"
5
+ require "uri"
6
+
7
+ module CruFlags
8
+ # The polling flag client (design doc §3–§8). One instance per process in
9
+ # normal use, via the CruFlags module singleton.
10
+ class Client
11
+ VALID_SCHEMES = %w[http https].freeze
12
+ MODES = %w[background on-demand].freeze
13
+
14
+ def initialize(url: nil, poll_seconds: 30.0, fetch_timeout: 2.0,
15
+ on_error: nil, refresh_mode: nil)
16
+ @poll_seconds = positive(poll_seconds, 30.0)
17
+ @fetch_timeout = positive(fetch_timeout, 2.0)
18
+ @on_error = on_error || default_on_error
19
+ @refresh_mode = resolve_mode(refresh_mode)
20
+ @url = url
21
+ @document = nil
22
+ @etag = nil
23
+ @healthy = true
24
+ @attempted = false
25
+ @last_attempt_at = nil
26
+ @last_attempt_ok = false
27
+ @closed = false
28
+ @write_mutex = Mutex.new
29
+ @fetch_mutex = Mutex.new
30
+ @ready_mutex = Mutex.new
31
+ @ready_cv = ConditionVariable.new
32
+ @wake = Queue.new
33
+ @thread = nil
34
+ @pid = nil
35
+ validate_url!
36
+ end
37
+
38
+ def inert? = @url.nil?
39
+
40
+ def enabled?(name)
41
+ read_path_touch
42
+ doc = @document
43
+ doc&.dig("Flags", name.to_s, "Enabled") == true
44
+ rescue
45
+ false
46
+ end
47
+
48
+ def snapshot
49
+ read_path_touch
50
+ doc = @document
51
+ doc ? JSON.parse(JSON.generate(doc)) : {}
52
+ rescue
53
+ {}
54
+ end
55
+
56
+ # The Flipper adapter's read primitive: the frozen Flags hash (or {}),
57
+ # through the same read path as enabled? (lazy start / on-demand
58
+ # refresh).
59
+ def flags_for_adapter
60
+ read_path_touch
61
+ @document&.fetch("Flags", nil) || {}
62
+ rescue
63
+ {}
64
+ end
65
+
66
+ # Blocks until the first fetch attempt completes (success or failure) and
67
+ # returns whether it did within timeout. Immediately false for inert
68
+ # clients (design doc §3.3, §7).
69
+ def ready(timeout: nil)
70
+ return false if inert? || (@closed && !@attempted)
71
+ ensure_started
72
+ return on_demand_ready if on_demand?
73
+ @ready_mutex.synchronize do
74
+ deadline = timeout && Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
75
+ until @attempted
76
+ ensure_started # self-heals a poller that died mid-wait, rather than waiting on it forever
77
+ remaining = deadline && deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
78
+ return false if remaining && remaining <= 0
79
+ @ready_cv.wait(@ready_mutex, remaining || 1.0)
80
+ return false if @closed && !@attempted
81
+ end
82
+ true
83
+ end
84
+ rescue
85
+ false
86
+ end
87
+
88
+ def refresh(force: false)
89
+ return false if inert? || @closed
90
+ waited_from = Process.clock_gettime(Process::CLOCK_MONOTONIC)
91
+ begin
92
+ @fetch_mutex.synchronize do
93
+ if force
94
+ attempt_fetch unless @last_attempt_at && @last_attempt_at >= waited_from
95
+ elsif stale?
96
+ attempt_fetch
97
+ end
98
+ end
99
+ ensure
100
+ signal_ready
101
+ end
102
+ @attempted && @last_attempt_ok
103
+ rescue
104
+ false
105
+ end
106
+
107
+ def close
108
+ @closed = true
109
+ @wake.push(:stop)
110
+ @ready_mutex.synchronize { @ready_cv.broadcast }
111
+ begin
112
+ @thread&.join(0.1)
113
+ rescue
114
+ nil # close called from the poller thread itself must not raise ThreadError
115
+ end
116
+ nil
117
+ end
118
+
119
+ private
120
+
121
+ # The pre-read hook shared by enabled?/snapshot/flags_for_adapter: lazily
122
+ # arms the background poller (a no-op once running) and, in on-demand
123
+ # mode, performs the coalesced fetch before the read.
124
+ def read_path_touch
125
+ ensure_started
126
+ refresh_on_demand
127
+ end
128
+
129
+ # refresh_mode resolution (design doc §7.1, §7.2): constructor argument
130
+ # wins over ENV["CRU_FLAGS_REFRESH_MODE"], which wins over the
131
+ # "background" default. An unrecognized constructor value is a caller
132
+ # typo -> ArgumentError. An unrecognized ENV value is misconfiguration
133
+ # that must never stop boot -> one on_error warning, then background.
134
+ # @on_error is already assigned by the time this runs, so the warning
135
+ # can be reported inline.
136
+ def resolve_mode(arg)
137
+ if arg
138
+ raise ArgumentError, "refresh_mode must be one of #{MODES.join(", ")}" unless MODES.include?(arg)
139
+ return arg
140
+ end
141
+ env = ENV["CRU_FLAGS_REFRESH_MODE"].to_s.strip
142
+ return "background" if env.empty?
143
+ return env if MODES.include?(env)
144
+ report(FetchError.new("CRU_FLAGS_REFRESH_MODE=#{env.inspect} is not recognized; using background",
145
+ code: :network))
146
+ "background"
147
+ end
148
+
149
+ # On-demand mode has no poller thread at all (design doc §7.1).
150
+ def on_demand?
151
+ @refresh_mode == "on-demand"
152
+ end
153
+
154
+ # The on-demand read-path hook: every enabled?/snapshot/ready call
155
+ # attempts a coalesced, attempt-anchored fetch before reading. A no-op
156
+ # in background mode, for inert clients, and once closed.
157
+ def refresh_on_demand
158
+ return unless on_demand?
159
+ return if inert? || @closed
160
+ begin
161
+ attempt_fetch_coalesced(force: false)
162
+ ensure
163
+ signal_ready # deliberate: kept for symmetry with the background poller's signal, even though on-demand's own ready() never waits on this CV
164
+ end
165
+ end
166
+
167
+ # ready in on-demand mode performs the read-path refresh itself rather
168
+ # than waiting on the ready CV (there is no poller to signal it); the
169
+ # timeout: argument is unused there, bounded instead by the fetch
170
+ # timeouts (design doc §3.3, §7.1).
171
+ def on_demand_ready
172
+ refresh_on_demand
173
+ @attempted
174
+ end
175
+
176
+ # Lazily arms the background poller on the first enabled?/ready/snapshot
177
+ # call. A no-op for inert, closed, or on-demand clients. Self-healing: a
178
+ # dead poller (killed by an unanticipated bug) is detected via
179
+ # Thread#alive? and respawned rather than leaving flags frozen forever.
180
+ # Fork-safe: a PID change discards the dead parent thread's state
181
+ # (including the ready CV, whose wait queue may reference parent
182
+ # threads) so the child re-arms its own poller on its own next read
183
+ # (design doc §7).
184
+ def ensure_started
185
+ return if inert? || @closed || on_demand?
186
+ return if @thread&.alive? && Process.pid == @pid
187
+ @write_mutex.synchronize do
188
+ return if @closed
189
+ if @thread && Process.pid != @pid
190
+ @thread = nil
191
+ @wake = Queue.new
192
+ @ready_mutex = Mutex.new
193
+ @ready_cv = ConditionVariable.new
194
+ end
195
+ spawn_poller unless @thread&.alive?
196
+ end
197
+ end
198
+
199
+ def spawn_poller
200
+ @pid = Process.pid
201
+ @thread = Thread.new do
202
+ Thread.current.name = "cru-flags-poller"
203
+ Thread.current.report_on_exception = false
204
+ until @closed
205
+ begin
206
+ attempt_fetch_coalesced(force: true)
207
+ rescue
208
+ nil # a bug here must not silently kill the poller (design doc §7)
209
+ ensure
210
+ signal_ready
211
+ end
212
+ begin
213
+ @wake.pop(timeout: @poll_seconds * rand(0.8..1.2))
214
+ rescue
215
+ # Non-spinning: a broken wake queue must not turn into a busy
216
+ # loop hammering the flag service.
217
+ sleep(@poll_seconds)
218
+ end
219
+ end
220
+ end
221
+ end
222
+
223
+ def attempt_fetch_coalesced(force:)
224
+ @fetch_mutex.synchronize do
225
+ attempt_fetch if force || stale?
226
+ end
227
+ end
228
+
229
+ def signal_ready
230
+ return unless @attempted
231
+ @ready_mutex.synchronize { @ready_cv.broadcast }
232
+ end
233
+
234
+ def stale?
235
+ @last_attempt_at.nil? ||
236
+ (Process.clock_gettime(Process::CLOCK_MONOTONIC) - @last_attempt_at) >= @poll_seconds
237
+ end
238
+
239
+ def attempt_fetch
240
+ outcome = Fetcher.call(url: @url, etag: @etag, timeout: @fetch_timeout)
241
+ apply(outcome)
242
+ ensure
243
+ @attempted = true
244
+ @last_attempt_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
245
+ end
246
+
247
+ def apply(outcome)
248
+ case outcome.kind
249
+ when :document
250
+ @document = outcome.document # document BEFORE etag (design doc §8)
251
+ @etag = outcome.etag
252
+ became(healthy: true)
253
+ when :not_modified
254
+ became(healthy: true)
255
+ when :missing
256
+ @document = Document::EMPTY
257
+ @etag = nil
258
+ became(healthy: true)
259
+ when :failed
260
+ became(healthy: false, error: outcome.error)
261
+ end
262
+ @last_attempt_ok = outcome.kind != :failed
263
+ end
264
+
265
+ def became(healthy:, error: nil)
266
+ return if healthy == @healthy
267
+ @healthy = healthy
268
+ report(healthy ? nil : error)
269
+ end
270
+
271
+ def report(error)
272
+ @on_error.call(error)
273
+ rescue
274
+ nil # a broken error handler must not kill the poller
275
+ end
276
+
277
+ def default_on_error
278
+ lambda do |error|
279
+ logger = defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger ||
280
+ (@fallback_logger ||= Logger.new($stderr, progname: "cru_flags"))
281
+ if error
282
+ logger.warn("flag fetches failing: #{error.class}: #{error.message}")
283
+ else
284
+ logger.warn("flag fetches recovered")
285
+ end
286
+ end
287
+ end
288
+
289
+ def positive(value, fallback)
290
+ (value.is_a?(Numeric) && value.finite? && value.positive?) ? value.to_f : fallback
291
+ end
292
+
293
+ def validate_url!
294
+ return if @url.nil?
295
+ uri = URI(@url.to_s)
296
+ invalid_scheme = !VALID_SCHEMES.include?(uri.scheme)
297
+ no_host = uri.host.to_s.empty?
298
+ if invalid_scheme || no_host
299
+ reason = invalid_scheme ? "scheme #{uri.scheme.inspect} is not http(s)" : "has no host"
300
+ @url = nil
301
+ report(FetchError.new("CRU_FLAGS_URL #{reason}; client is inert", code: :network))
302
+ end
303
+ rescue URI::InvalidURIError
304
+ @url = nil
305
+ report(FetchError.new("CRU_FLAGS_URL is not a valid URL; client is inert", code: :network))
306
+ end
307
+ end
308
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module CruFlags
6
+ module Document
7
+ EMPTY = {"Flags" => {}.freeze}.freeze
8
+
9
+ module_function
10
+
11
+ # Parse and deep-freeze one flag document. Leniency is for ABSENCE, not
12
+ # malformation: a missing Flags key is an empty document, a wrong-typed
13
+ # one is a broken document (design doc §8).
14
+ def parse(body)
15
+ parsed = JSON.parse(body, freeze: true)
16
+ raise ParseError, "document is not a JSON object" unless parsed.is_a?(Hash)
17
+
18
+ normalize_flags(parsed)
19
+ rescue JSON::ParserError => e
20
+ raise ParseError, "document is not JSON: #{e.message}"
21
+ end
22
+
23
+ def normalize_flags(parsed)
24
+ flags = parsed["Flags"]
25
+ case flags
26
+ when nil
27
+ rebuild(parsed, {}.freeze)
28
+ when Hash
29
+ clean = flags.select { |_name, entry| entry.is_a?(Hash) }
30
+ (clean.size == flags.size) ? parsed : rebuild(parsed, clean.freeze)
31
+ else
32
+ raise ParseError, "Flags is #{flags.class}, expected an object"
33
+ end
34
+ end
35
+
36
+ def rebuild(parsed, flags)
37
+ parsed.except("Flags").merge("Flags" => flags).freeze
38
+ end
39
+ private_class_method :normalize_flags, :rebuild
40
+ end
41
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CruFlags
4
+ class Error < StandardError; end
5
+
6
+ class ParseError < Error; end
7
+
8
+ # Normalized fetch failure. code is :http, :network, :timeout, or :parse;
9
+ # status is set only for :http.
10
+ class FetchError < Error
11
+ attr_reader :code, :status
12
+
13
+ def initialize(message, code:, status: nil)
14
+ super(message)
15
+ @code = code
16
+ @status = status
17
+ end
18
+ end
19
+
20
+ class ReadOnlyError < Error; end
21
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+
6
+ module CruFlags
7
+ # One conditional GET of the flag document. Stateless; the Client owns the
8
+ # etag and the snapshot. Never raises — every failure is an Outcome.
9
+ module Fetcher
10
+ Outcome = Struct.new(:kind, :document, :etag, :error)
11
+
12
+ REDIRECT_LIMIT = 3
13
+ REDIRECT_CODES = %w[301 302 303 307 308].freeze
14
+ VALID_SCHEMES = %w[http https].freeze
15
+ BODY_EXCERPT = 200
16
+ MAX_BODY_BYTES = 1_048_576
17
+
18
+ module_function
19
+
20
+ def call(url:, etag: nil, timeout: 2.0)
21
+ response = get_following_redirects(URI(url), etag:, timeout:)
22
+ case response.code
23
+ when "200"
24
+ body = response.body.to_s
25
+ if body.bytesize > MAX_BODY_BYTES
26
+ failed(FetchError.new("flag fetch body exceeds #{MAX_BODY_BYTES} bytes (#{body.bytesize})",
27
+ code: :parse))
28
+ else
29
+ document = Document.parse(body)
30
+ Outcome.new(kind: :document, document:, etag: response["etag"])
31
+ end
32
+ when "304" then Outcome.new(kind: :not_modified)
33
+ when "404" then Outcome.new(kind: :missing)
34
+ else
35
+ excerpt = response.body.to_s.strip.gsub(/\s+/, " ")[0, BODY_EXCERPT]
36
+ failed(FetchError.new("flag fetch failed with HTTP #{response.code}: #{excerpt}",
37
+ code: :http, status: Integer(response.code)))
38
+ end
39
+ rescue FetchError => e
40
+ failed(e)
41
+ rescue ParseError => e
42
+ failed(FetchError.new(e.message, code: :parse))
43
+ rescue Timeout::Error => e
44
+ failed(FetchError.new("flag fetch timed out: #{e.message}", code: :timeout))
45
+ rescue SystemCallError, SocketError, IOError, OpenSSL::SSL::SSLError,
46
+ URI::Error, Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError => e
47
+ failed(FetchError.new("flag fetch failed: #{e.message}", code: :network))
48
+ rescue => e
49
+ # Floor, not enumeration: "call never raises" must hold by construction.
50
+ # Mirrors the sibling clients' contract (node's toFlagsError) — any
51
+ # exception type we didn't anticipate still normalizes to :network
52
+ # rather than escaping as a raise.
53
+ failed(FetchError.new("flag fetch failed: #{e.message}", code: :network))
54
+ end
55
+
56
+ def get_following_redirects(uri, etag:, timeout:)
57
+ REDIRECT_LIMIT.downto(0) do |hops_left|
58
+ response = get(uri, etag:, timeout:)
59
+ return response unless REDIRECT_CODES.include?(response.code)
60
+ if hops_left.zero?
61
+ raise FetchError.new("flag fetch exceeded #{REDIRECT_LIMIT} redirects",
62
+ code: :http, status: Integer(response.code))
63
+ end
64
+ uri = uri.merge(response["location"].to_s)
65
+ validate_redirect_uri!(uri)
66
+ end
67
+ end
68
+
69
+ # Guards each redirect hop (design doc §8): a Location header pointing at
70
+ # a non-http(s) scheme or a hostless URI must not be handed to Net::HTTP,
71
+ # which would otherwise raise something less legible than a plain :failed
72
+ # :network outcome.
73
+ def validate_redirect_uri!(uri)
74
+ return if VALID_SCHEMES.include?(uri.scheme) && !uri.host.to_s.empty?
75
+ raise FetchError.new("flag fetch redirected to an invalid URI (scheme #{uri.scheme.inspect}, host #{uri.host.inspect})",
76
+ code: :network)
77
+ end
78
+
79
+ def get(uri, etag:, timeout:)
80
+ headers = {"Accept" => "application/json",
81
+ "User-Agent" => "cru-flags-ruby/#{VERSION}"}
82
+ headers["If-None-Match"] = etag if etag
83
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
84
+ open_timeout: timeout, read_timeout: timeout, write_timeout: timeout) do |http|
85
+ http.get(uri.request_uri, headers)
86
+ end
87
+ end
88
+
89
+ def failed(error) = Outcome.new(kind: :failed, error:)
90
+ private_class_method :get_following_redirects, :validate_redirect_uri!, :get, :failed
91
+ end
92
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "flipper"
4
+
5
+ module CruFlags
6
+ # Read-only Flipper adapter over the client's frozen snapshot (design doc
7
+ # §5). HARD RULE: get/get_multi/get_all build fresh, UNFROZEN top-level
8
+ # hashes on every call — Flipper::Adapters::Memoizable mutates the Hash we
9
+ # return (response.default_proc =, memoizable.rb:103), so a frozen or
10
+ # shared return value would raise FrozenError on every memoized request.
11
+ class FlipperAdapter
12
+ include Flipper::Adapter
13
+
14
+ def initialize(client)
15
+ @client = client
16
+ end
17
+
18
+ def name = :cru_flags
19
+
20
+ def read_only? = true
21
+
22
+ def features
23
+ @client.flags_for_adapter.keys.to_set
24
+ end
25
+
26
+ def get(feature)
27
+ gates_for(feature.key)
28
+ end
29
+
30
+ def get_multi(features)
31
+ flags = @client.flags_for_adapter
32
+ features.to_h { |feature| [feature.key, gates_for(feature.key, flags)] }
33
+ end
34
+
35
+ def get_all
36
+ flags = @client.flags_for_adapter
37
+ flags.keys.to_h { |name| [name, gates_for(name, flags)] }
38
+ end
39
+
40
+ def add(_feature) = write_refused
41
+
42
+ def remove(_feature) = write_refused
43
+
44
+ def clear(_feature) = write_refused
45
+
46
+ def enable(_feature, _gate, _thing) = write_refused
47
+
48
+ def disable(_feature, _gate, _thing) = write_refused
49
+
50
+ private
51
+
52
+ def gates_for(name, flags = @client.flags_for_adapter)
53
+ gates = self.class.default_config.dup
54
+ gates[:boolean] = true if flags.dig(name, "Enabled") == true
55
+ gates
56
+ end
57
+
58
+ def write_refused
59
+ raise ReadOnlyError, "flags are written by cru-cli and the deploys.cru.org dashboard, never by the app"
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Loaded at file scope, not inside the initializer: Rails::Application
4
+ # computes its initializer collection ONCE, by walking Rails::Railtie's
5
+ # currently-defined subclasses, before running any of them. If "flipper"
6
+ # were required for the first time from inside our own initializer's body,
7
+ # Flipper::Engine would not exist yet when that walk happens, so its own
8
+ # initializers (Strict wrapping, ActorLimit, the Memoizer middleware) would
9
+ # never join the collection at all — not run late, just silently absent.
10
+ # This file only loads under Rails (see the guarded require in
11
+ # lib/cru_flags.rb), so requiring "flipper" here does not touch the
12
+ # no-bundler / non-Rails path.
13
+ require "flipper"
14
+
15
+ module CruFlags
16
+ # Zero-config Rails wiring (design doc §5.1). Registration is ordering-safe
17
+ # because Flipper's engine resolves config.adapter LAZILY on first
18
+ # Flipper.instance access, and the last-registered adapter block wins — an
19
+ # app's own Flipper.configure in config/initializers still overrides ours.
20
+ class Railtie < Rails::Railtie
21
+ # Flipper's engine assigns config.flipper.strict in before_configuration
22
+ # (:warn in development, false elsewhere) — BEFORE any gem initializer —
23
+ # so "did the app set it" cannot be read off the config value alone. We
24
+ # quiet strict only when the env var is unset AND the value still equals
25
+ # Flipper's own computed default, i.e. only when nobody chose it.
26
+ def self.quiet_strict?(current:, env_var:, rails_env:)
27
+ return false unless env_var.nil?
28
+ flipper_default = (rails_env == "development") ? :warn : false
29
+ current == flipper_default
30
+ end
31
+
32
+ initializer "cru_flags.flipper" do |app|
33
+ Flipper.configure { |config| config.adapter { CruFlags.flipper_adapter } }
34
+
35
+ if app.config.respond_to?(:flipper) &&
36
+ Railtie.quiet_strict?(current: app.config.flipper.strict,
37
+ env_var: ENV["FLIPPER_STRICT"], rails_env: Rails.env.to_s)
38
+ app.config.flipper.strict = false
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CruFlags
4
+ VERSION = "0.1.0"
5
+ end
data/lib/cru_flags.rb ADDED
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "cru_flags/version"
4
+ require_relative "cru_flags/errors"
5
+ require_relative "cru_flags/document"
6
+ require_relative "cru_flags/fetcher"
7
+ require_relative "cru_flags/client"
8
+
9
+ # Module-level singleton — the 99% path (design doc §3.1). Requiring this
10
+ # file does nothing: no env read, no socket, no thread.
11
+ module CruFlags
12
+ ENV_VAR = "CRU_FLAGS_URL"
13
+
14
+ @client_mutex = Mutex.new
15
+ @client = nil
16
+ @flipper_adapter_mutex = Mutex.new
17
+ @flipper_adapter = nil
18
+
19
+ class << self
20
+ def client
21
+ @client || @client_mutex.synchronize { @client ||= Client.new(url: url_from_env) }
22
+ end
23
+
24
+ def enabled?(name) = client.enabled?(name)
25
+
26
+ def ready(timeout: nil) = client.ready(timeout:)
27
+
28
+ def snapshot = client.snapshot
29
+
30
+ def refresh(force: false) = client.refresh(force:)
31
+
32
+ def close = client.close
33
+
34
+ # The read-only Flipper adapter (design doc §5), memoized and bound to
35
+ # the singleton client. `flipper` is required lazily here (not at the
36
+ # top of this file) so requiring "cru_flags" alone stays flipper-free.
37
+ def flipper_adapter
38
+ @flipper_adapter || @flipper_adapter_mutex.synchronize do
39
+ @flipper_adapter ||= begin
40
+ require_relative "cru_flags/flipper_adapter"
41
+ FlipperAdapter.new(client)
42
+ end
43
+ end
44
+ end
45
+
46
+ # Test hook, not public API: closes and discards the singleton so the
47
+ # next call re-reads the environment.
48
+ def reset!
49
+ @client_mutex.synchronize do
50
+ @client&.close
51
+ @client = nil
52
+ end
53
+ @flipper_adapter_mutex.synchronize { @flipper_adapter = nil }
54
+ end
55
+
56
+ private
57
+
58
+ def url_from_env
59
+ value = ENV[ENV_VAR].to_s.strip
60
+ value.empty? ? nil : value
61
+ end
62
+ end
63
+ end
64
+
65
+ require_relative "cru_flags/railtie" if defined?(Rails::Railtie)
metadata ADDED
@@ -0,0 +1,88 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: cru-flags
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Cru
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-08-24 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: flipper
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.4'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.4'
27
+ - !ruby/object:Gem::Dependency
28
+ name: json
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '2.4'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '2.4'
41
+ description: Polls the flag service's per-(project, environment) JSON document, answers
42
+ reads from a frozen in-memory snapshot with fail-static semantics, and ships a read-only
43
+ Flipper adapter for the Rails fleet. CRuby only. See docs/design.md.
44
+ email:
45
+ - apps@cru.org
46
+ executables: []
47
+ extensions: []
48
+ extra_rdoc_files: []
49
+ files:
50
+ - CHANGELOG.md
51
+ - LICENSE
52
+ - README.md
53
+ - docs/design.md
54
+ - lib/cru_flags.rb
55
+ - lib/cru_flags/client.rb
56
+ - lib/cru_flags/document.rb
57
+ - lib/cru_flags/errors.rb
58
+ - lib/cru_flags/fetcher.rb
59
+ - lib/cru_flags/flipper_adapter.rb
60
+ - lib/cru_flags/railtie.rb
61
+ - lib/cru_flags/version.rb
62
+ homepage: https://github.com/CruGlobal/cru-flags-ruby
63
+ licenses:
64
+ - BSD-3-Clause
65
+ metadata:
66
+ rubygems_mfa_required: 'true'
67
+ source_code_uri: https://github.com/CruGlobal/cru-flags-ruby
68
+ changelog_uri: https://github.com/CruGlobal/cru-flags-ruby/blob/main/CHANGELOG.md
69
+ post_install_message:
70
+ rdoc_options: []
71
+ require_paths:
72
+ - lib
73
+ required_ruby_version: !ruby/object:Gem::Requirement
74
+ requirements:
75
+ - - ">="
76
+ - !ruby/object:Gem::Version
77
+ version: '3.2'
78
+ required_rubygems_version: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ requirements: []
84
+ rubygems_version: 3.5.22
85
+ signing_key:
86
+ specification_version: 4
87
+ summary: Official Ruby client for Cru's pipeline feature-flag service
88
+ test_files: []