Skip to content

Commit 053573d

Browse files
fix(migrate-eppo): align REST reference and fixtures with real Eppo OpenAPI schema
The prior REST reference and fake-server fixtures were modeled from Eppo's high-level docs and were structurally close but field-naming wrong almost everywhere. Diffed against the real OpenAPI 3.0 spec (publicly served at https://eppo.cloud/api/docs/swagger-ui-init.js, no auth required) and corrected: * snake_case throughout: variation_type, is_archived, targeting_rules, variation_weight, percent_exposure, is_default, environment_id, etc. * Numeric IDs (Eppo Object IDs) instead of slug strings * variation_weight is an array of {variation_id, weight}, not a map keyed by variant_key * Condition values are always arrays, even for single-value operators * Default variation lives on the allocation with is_default: true, not on the flag itself * Environment status uses active + is_production, not enabled * List pagination is offset + limit, not page + per_page * List response is a bare array, not a {flags, has_more, total} wrapper * Added include_archived, include_detailed_allocations query params Surfaced two new BLOCKED cases the spec made visible: * IS_NULL operator -> Confidence has no native null-check rule, so any allocation using it is BLOCKED * SWITCHBACK allocation type -> Eppo time-windowed experiments are not modeled in Confidence; the whole flag is BLOCKED Plus an audiences[]-references BLOCKED path (allocations that reference reusable Eppo audience definitions via the IS_IN / IS_NOT_IN type require fetching /audiences/{id} and inlining, which is non-trivial). Test-fixture coverage updated to exercise every operator and allocation type in the spec: 10 fixture flags covering MATCHES suffix, waterfall + multivariant, NOT_ONE_OF + GTE + AND, special id attribute, inactive-in-env, SemVer BLOCKED, regex BLOCKED, IS_NULL BLOCKED, SWITCHBACK BLOCKED, and audience-reference BLOCKED. Validated end-to-end against the OpenAPI required-fields and enums via an automated schema-check script: zero violations across 10 flags and 3 envs.
1 parent 60a7512 commit 053573d

3 files changed

Lines changed: 860 additions & 397 deletions

File tree

skills/migrate-eppo/SKILL.md

Lines changed: 181 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ communication rules.)
133133

134134
```bash
135135
curl -sS -H "X-Eppo-Token: $EPPO_API_KEY" \
136-
"https://eppo.cloud/api/v1/feature-flags?page=1&per_page=1" \
136+
"https://eppo.cloud/api/v1/feature-flags?offset=0&limit=1" \
137137
| head -c 200
138138
```
139139

@@ -156,27 +156,63 @@ short version is `python3 server.py`, then point this skill at
156156
The migration uses these endpoints. All require `-H "X-Eppo-Token: $EPPO_API_KEY"`.
157157
Base URL defaults to `https://eppo.cloud/api/v1`.
158158

159+
> **Source of truth.** Field names and shapes here are taken directly from
160+
> Eppo's OpenAPI 3.0 spec, embedded at
161+
> <https://eppo.cloud/api/docs/swagger-ui-init.js> (public, no auth). Refer
162+
> back to it if you encounter a field that isn't documented below.
163+
159164
| Purpose | Endpoint |
160165
|---------|----------|
161166
| List environments | `GET /environments` |
162-
| List feature flags | `GET /feature-flags?page=<n>&per_page=<n>` |
167+
| List feature flags | `GET /feature-flags?offset=<n>&limit=<n>` |
163168
| Get a single flag (full definition: variations, allocations, rules) | `GET /feature-flags/{id}` |
164-
| Get environment-specific flag state (enabled + per-env allocations) | `GET /feature-flags/{id}/environments/{environmentId}` |
165-
166-
The flag object includes:
167-
- `key` — used in code (subject of `get_*_assignment` calls)
168-
- `name`, `description`
169-
- `variationType``STRING` / `BOOLEAN` / `NUMERIC` / `INTEGER` / `JSON`
170-
- `variations[]` — each has `key`, `name`, `value`
169+
| Get environment-specific flag state (active + per-env allocations) | `GET /feature-flags/{id}/environments/{environmentId}` |
170+
171+
**Convention.** All field names are `snake_case`. All IDs are integers
172+
(numeric Eppo Object IDs). All condition `values` are arrays even when
173+
the operator only consumes a single value.
174+
175+
The flag object (`PublicApiFeatureFlag`) includes:
176+
- `id` (number), `key` (string used in code as the first arg to
177+
`get_*_assignment`), `name`, `description`
178+
- `is_archived` (boolean)
179+
- `variation_type``BOOLEAN` / `INTEGER` / `JSON` / `NUMERIC` / `STRING`
180+
- `variations[]` — each has `id` (number), `name`, `variant_key`
171181
- `allocations[]` — ordered waterfall (top wins). Each allocation has:
172-
- `name`, `allocationType` (`FEATURE_GATE` / `EXPERIMENT` / `AUDIENCE`)
173-
- `targetingRules[]` — each rule is `{ conditions: [{ attribute, operator, value }] }`
174-
- `variationWeightsByKey` or `variationWeights[]` — split among variations
175-
- `trafficExposure` (0–1) — fraction of matched subjects that enter the allocation
176-
- `environments[]` — per-environment state (enabled flag, env-specific allocations)
182+
- `id`, `key`, `name`
183+
- `type``FEATURE_GATE` / `EXPERIMENT` / `SWITCHBACK`
184+
- `targeting_rules[]` — each rule is `{ conditions: [{ operator, attribute, values: [...] }] }`
185+
- `variation_weight[]` — array of `{ variation_id, weight }` referencing variations by numeric `id`
186+
- `audiences[]` — array of `{ audience_id, type }` where `type` is `IS_IN` or `IS_NOT_IN`
187+
- `percent_exposure` (0–100) — fraction of matched subjects that enter the allocation
188+
- `is_default` (boolean) — the default allocation sits at the bottom of the waterfall and supplies the "no match" variation
189+
- `experiment` — the linked Eppo experiment object, or `null` for non-experiment allocations
190+
- `environment_id` — only set on the env-scoped endpoint
191+
- `environments[]` — per-environment state (`PublicApiFeatureFlagEnvironment`: `id`, `name`, `active`, `is_production`); allocations are NOT included here, only env status
192+
193+
The env-scoped endpoint (`GET /feature-flags/{id}/environments/{environmentId}`)
194+
returns a `PublicApiFeatureFlagEnvironmentWithAllocation`: the env status
195+
fields above PLUS `allocations[]` for that environment. This is the
196+
canonical place to read the per-env waterfall.
197+
198+
**Default value lives on the allocation marked `is_default: true`**, not
199+
on the flag. The default allocation has empty `targeting_rules[]` and
200+
`audiences[]` and matches everyone; its `variation_weight[]` decides what
201+
unmatched subjects see.
202+
203+
**Pagination.** Eppo uses `offset` + `limit` (both numbers), not cursors
204+
and not page numbers. Loop:
205+
206+
```
207+
offset = 0
208+
LOOP:
209+
items = GET /feature-flags?offset=<offset>&limit=50
210+
process items
211+
if len(items) < 50 OR items is empty → STOP
212+
offset += 50 → continue LOOP
213+
```
177214

178-
**Always paginate** until the response returns fewer items than `per_page` or
179-
an empty page. Eppo's API uses page-based pagination, not cursors.
215+
The list endpoint returns a **bare JSON array**, no wrapper object.
180216

181217
---
182218

@@ -258,55 +294,61 @@ Set the step to `⏸ awaiting user` and wait for an explicit pick.
258294
**Step 1b — list all flags. CRITICAL: paginate until exhausted.**
259295

260296
```
261-
page = 1
297+
offset = 0
262298
LOOP:
263-
response = curl GET /feature-flags?page=<page>&per_page=50
264-
process response items
265-
if response items < 50 OR response is empty → STOP
266-
page += 1 → continue LOOP
267-
```
268-
269-
```bash
270-
curl -sS -H "X-Eppo-Token: $EPPO_API_KEY" \
271-
"https://eppo.cloud/api/v1/feature-flags?page=1&per_page=50"
299+
items = curl GET /feature-flags?offset=<offset>&limit=50
300+
process items (bare array, no wrapper)
301+
if len(items) < 50 OR items is empty → STOP
302+
offset += 50 → continue LOOP
272303
```
273304

274-
**Step 1c — fetch each flag's full definition (in batches of 5).**
275-
276305
```bash
277306
curl -sS -H "X-Eppo-Token: $EPPO_API_KEY" \
278-
"https://eppo.cloud/api/v1/feature-flags/<id>"
307+
"https://eppo.cloud/api/v1/feature-flags?offset=0&limit=50"
279308
```
280309

281-
And the environment-specific state for the chosen environment:
310+
**Step 1c — fetch each flag's environment-scoped definition (in batches of 5).**
282311

283312
```bash
284313
curl -sS -H "X-Eppo-Token: $EPPO_API_KEY" \
285314
"https://eppo.cloud/api/v1/feature-flags/<id>/environments/<environmentId>"
286315
```
287316

317+
This is the env-scoped endpoint — it returns the flag's per-env
318+
`active` state AND the full `allocations[]` for that environment in
319+
one shot, which is everything Step 4 needs. You don't also need
320+
`GET /feature-flags/{id}` unless you need cross-environment data.
321+
288322
**After each batch of 5**, write the flag data to the plan file —
289323
append the flag sections to Section 4. This way if the session closes
290324
mid-scan, the flags fetched so far are saved.
291325

292-
Skip flags that are **archived** in Eppo unless the user opts in (ask
293-
once up-front: "Include archived flags too? Default: no").
326+
Skip flags that are **archived** in Eppo unless the user opts in. Ask
327+
once up-front: "Include archived flags too? Default: no". The list
328+
endpoint defaults to excluding archived; pass `include_archived=true`
329+
in the query string if the user opted in.
294330

295331
Extract from each flag:
296332

297-
- `key` and `name`
298-
- `description` (if Eppo provides one, include it; otherwise leave blank)
299-
- `variationType` and the list of `variations` (key + value)
300-
- For the chosen environment:
301-
- `enabled` state — flags that are disabled in the chosen environment
302-
still migrate, but with rollout 0% so they don't activate
303-
accidentally; surface this clearly in the plan
304-
- Ordered list of `allocations` with:
305-
- `allocationType` (Feature Gate, Experiment, or Audience)
306-
- `trafficExposure` (0–1) → maps to Confidence rule `rolloutPercentage`
307-
- `targetingRules[]` (`conditions: [{ attribute, operator, value }]`)
308-
- `variationWeightsByKey` — the split among variations
309-
- The default variation (what subjects see when no allocation matches)
333+
- `key`, `name`, `description` (if Eppo provides a description, include
334+
it; otherwise leave blank)
335+
- `variation_type` and `variations[]` (each: `id`, `name`, `variant_key`)
336+
- For the chosen environment (from the env-scoped endpoint):
337+
- `active` — flags inactive in the chosen environment still migrate,
338+
but with rollout 0% so they don't activate accidentally; surface
339+
this clearly in the plan
340+
- Ordered list of `allocations[]`. For each:
341+
- `type` (`FEATURE_GATE`, `EXPERIMENT`, or `SWITCHBACK`)
342+
- `percent_exposure` (0–100) → maps to Confidence rule `rolloutPercentage`
343+
- `targeting_rules[]` (`conditions: [{ operator, attribute, values: [...] }]`)
344+
- `variation_weight[]` — array of `{ variation_id, weight }`. Look up
345+
each `variation_id` against the flag's `variations[]` to recover
346+
`variant_key`
347+
- `audiences[]` — if non-empty, this allocation references reusable
348+
audience definitions; see the BLOCKED rules under Operator Mapping
349+
- `is_default` — the default allocation supplies the "no match"
350+
variation; treat its `variation_weight[]` as the default value and
351+
do NOT emit it as a Confidence targeting rule
310352

311353
**Randomization unit.** Eppo always uses `subjectKey`. Unlike PostHog
312354
there's no per-group bucketing concept built into the flag — group-level
@@ -382,40 +424,79 @@ half.
382424

383425
Within a single Eppo rule, all `conditions` are ANDed. Across multiple
384426
rules in the same allocation, conditions are ORed (any rule satisfying
385-
means the allocation matches). Across allocations, each Eppo allocation
386-
becomes a **separate Confidence targeting rule** — see the waterfall
387-
ordering note in Step 4 above.
427+
means the allocation matches). Across allocations, each non-default
428+
Eppo allocation becomes a **separate Confidence targeting rule** — see
429+
the waterfall ordering note in Step 4 above. The `is_default`
430+
allocation does NOT emit a rule; its `variation_weight[]` is set as
431+
the flag's default value at `createFlag` time.
388432

389-
| Eppo operator (`GT`, `LT`, `GTE`, `LTE`, `MATCHES`, `ONE_OF`, `NOT_ONE_OF`) | Confidence payload strategy |
433+
Eppo's operator enum (`ERuleConditionOperator`) is `LT`, `LTE`, `GT`,
434+
`GTE`, `MATCHES`, `ONE_OF`, `NOT_ONE_OF`, `IS_NULL`. Conditions always
435+
use array `values`, even when there's only one value.
436+
437+
| Eppo condition | Confidence payload strategy |
390438
|---|---|
391-
| `GT` / `>` | One criterion with `rangeRule.startExclusive`, expression: `ref` |
392-
| `GTE` / `>=` | One criterion with `rangeRule.startInclusive`, expression: `ref` |
393-
| `LT` / `<` | One criterion with `rangeRule.endExclusive`, expression: `ref` |
394-
| `LTE` / `<=` | One criterion with `rangeRule.endInclusive`, expression: `ref` |
395-
| `ONE_OF ["A"]` (single value) | One criterion with `eqRule`, expression: `ref` |
396-
| `ONE_OF ["A","B",...]` | One criterion per value with `eqRule`, expression: `or` of `ref`s |
397-
| `NOT_ONE_OF ["A"]` (single value) | One criterion with `eqRule`, expression: `not` wrapping `ref` |
398-
| `NOT_ONE_OF ["A","B",...]` | One criterion per value with `eqRule`, expression: `and` of `not`-wrapped `ref`s |
399-
| `MATCHES "^prefix.*"` | One criterion with `startsWithRule { value: "prefix" }`, expression: `ref` |
400-
| `MATCHES ".*suffix$"` | One criterion with `endsWithRule { value: "suffix" }`, expression: `ref` |
439+
| `{operator: GT, values: ["N"]}` | One criterion with `rangeRule.startExclusive: N`, expression: `ref` |
440+
| `{operator: GTE, values: ["N"]}` | One criterion with `rangeRule.startInclusive: N`, expression: `ref` |
441+
| `{operator: LT, values: ["N"]}` | One criterion with `rangeRule.endExclusive: N`, expression: `ref` |
442+
| `{operator: LTE, values: ["N"]}` | One criterion with `rangeRule.endInclusive: N`, expression: `ref` |
443+
| `{operator: ONE_OF, values: ["A"]}` (singleton) | One criterion with `eqRule`, expression: `ref` |
444+
| `{operator: ONE_OF, values: ["A","B",...]}` | One criterion per value with `eqRule`, expression: `or` of `ref`s |
445+
| `{operator: NOT_ONE_OF, values: ["A"]}` (singleton) | One criterion with `eqRule`, expression: `not` wrapping `ref` |
446+
| `{operator: NOT_ONE_OF, values: ["A","B",...]}` | One criterion per value with `eqRule`, expression: `and` of `not`-wrapped `ref`s |
447+
| `{operator: MATCHES, values: ["^prefix.*"]}` | One criterion with `startsWithRule { value: "prefix" }`, expression: `ref` |
448+
| `{operator: MATCHES, values: [".*suffix$"]}` | One criterion with `endsWithRule { value: "suffix" }`, expression: `ref` |
401449

402450
**Blocked (manual review):**
403451

404-
- **`MATCHES` regex that is not a simple prefix/suffix anchor.** Confidence
405-
has no general regex rule. Surface the flag in Section 4 with an
406-
explicit `BLOCKED` marker and a brief explanation; the user must
407-
either rewrite the rule using set membership / starts-with / ends-with
408-
or migrate manually.
409-
- **SemVer comparisons.** Eppo can compare SemVer strings numerically.
410-
Confidence's `rangeRule` is purely numeric. If the attribute type is
411-
SemVer, mark the rule `BLOCKED` and ask the user whether to convert
412-
the comparison to a numeric `appVersionMajor` / `appVersionMinor`
413-
context field, or migrate manually.
414-
415-
**Eppo subject `id` targeting** (`id` ONE_OF [...]): rewrite the
416-
`attributeName` from `id` to the chosen entity field name from Step 3
417-
(e.g. `user_id`). Lists up to ~50 values are fine; Eppo caps them at 50
418-
but Confidence handles larger sets.
452+
- **`{operator: IS_NULL}`** — Confidence has no native "attribute is
453+
null" rule. Mark the allocation `BLOCKED` in Section 4 with the
454+
reason `Uses IS_NULL on '<attribute>'; Confidence has no null-check
455+
rule.` The user must either change the Eppo rule to use explicit
456+
values, or migrate manually with a default-value strategy.
457+
- **`{operator: MATCHES, values: ["<non-anchor regex>"]}`** — anything
458+
not a clean `^prefix.*` or `.*suffix$`. Confidence has no general
459+
regex rule. Surface in Section 4 with the BLOCKED marker.
460+
- **SemVer-looking numeric comparisons.** Eppo's spec has no value-type
461+
field — numeric operators (`GT/GTE/LT/LTE`) take strings, and Eppo's
462+
SDK decides at evaluation time whether to compare numerically or as
463+
SemVer based on whether the value parses as SemVer (`X.Y.Z` or
464+
`X.Y.Z-suffix`). Confidence's `rangeRule` is purely numeric. If any
465+
`values[0]` for a numeric operator matches the regex
466+
`^\d+\.\d+\.\d+([.-].+)?$`, mark the allocation `BLOCKED` with the
467+
reason `SemVer comparison on '<attribute>'; Confidence rangeRule is
468+
numeric only.` Offer to convert to a numeric `appVersionMajor` /
469+
`appVersionMinor` context field.
470+
471+
**Eppo allocation `type` handling:**
472+
473+
- `FEATURE_GATE` and `EXPERIMENT` → migrate normally as one Confidence
474+
targeting rule each.
475+
- `SWITCHBACK` → Eppo switchback allocations rotate variations over
476+
time windows for experiments on temporally-correlated outcomes
477+
(surge pricing, dispatch routing, etc.). Confidence does not model
478+
time-bucketed exposure. Mark the entire **flag** `BLOCKED` in
479+
Section 4 with the reason `Contains SWITCHBACK allocation; not
480+
supported in Confidence.`
481+
482+
**Eppo allocation `audiences[]` handling:**
483+
484+
- Empty `audiences[]` → no action.
485+
- Non-empty `audiences[]` → mark the allocation `BLOCKED` with the
486+
reason `References Eppo audience(s) <ids>; resolve audience
487+
definitions to inline conditions, or migrate manually.` Audiences
488+
are reusable targeting definitions stored in a separate Eppo
489+
resource and would need to be fetched via `GET /audiences/{id}`
490+
and inlined; we don't do that automatically because the inversion
491+
semantics (`IS_IN` vs `IS_NOT_IN`) and combination with
492+
`targeting_rules[]` are non-trivial.
493+
494+
**Eppo subject `id` targeting** (`{operator: ONE_OF, attribute: "id",
495+
values: [...]}`): the special `id` attribute targets the subject key
496+
directly. Rewrite `attribute` from `id` to the chosen Confidence
497+
entity field name from Step 3 (e.g. `user_id`). Lists up to ~50
498+
values are fine; Eppo caps them at 50 but Confidence handles larger
499+
sets.
419500

420501
### Worked example (waterfall)
421502

@@ -522,19 +603,25 @@ by `execute` — no implicit defaults.
522603
### Flag: `<flag-key>`
523604

524605
**Description:** <from Eppo if available, otherwise empty>
525-
**Variation type:** <STRING / BOOLEAN / NUMERIC / INTEGER / JSON>
526-
**Variations:** <variant key — value list, e.g. "control = false, treatment = true">
527-
**Enabled in `<env>`:** <yes / no — if no, all rules will be added at 0% rollout and flag created in the OFF state>
606+
**Variation type:** <BOOLEAN / INTEGER / JSON / NUMERIC / STRING>
607+
**Variations:** <variant_key — value list, e.g. "control = false, treatment = true">
608+
**Active in `<env>`:** <yes / no — if no, all rules will be added at 0% rollout and flag created in the OFF state>
528609
**Allocations (Eppo, in order):**
529610
1. `<allocation name>` (`<FEATURE_GATE | EXPERIMENT>`) — <plain-English rule>, exposure <X>%, splits <variant=X%, ...>
530611
2. ...
531-
**Default value (no allocation matches):** <variation key>
612+
**Default allocation:** `<allocation name>` (is_default: true) → variation `<variant_key>`
532613
**Confidence entity:** <mapped entity field from Step 3>
533-
**Confidence rules:** one targeting rule per allocation, in the same order
614+
**Confidence rules:** one targeting rule per non-default allocation, in the same order
534615
**Action:** [ ] Migrate [ ] Skip
535616

617+
If any allocation or the whole flag is BLOCKED, replace the **Action**
618+
line with:
619+
620+
**Status:** BLOCKED — <one-line reason from the BLOCKED rules above>
621+
**Action:** [ ] Skip (no migrate option available until the block is resolved)
622+
536623
**MCP Commands:**
537-
<createFlag, addFlagToClient, addTargetingRule (ONE per allocation, in order, with variant assignments and their split), resolveFlag with full parameters — positive AND negative case>
624+
<createFlag (default value = is_default allocation's variation), addFlagToClient, addTargetingRule (ONE per non-default allocation, in order, with variant assignments and their split), resolveFlag with full parameters — positive AND negative case>
538625

539626
---
540627

@@ -552,17 +639,23 @@ by `execute` — no implicit defaults.
552639
(The core file defines the execute flow and the Flag Setup Sequence.
553640
This section adds Eppo-specific guidance.)
554641

555-
**Disabled-in-environment handling.** If a flag is off in the source
556-
Eppo environment, surface that during execute:
642+
**Inactive-in-environment handling.** If a flag's `active` flag is
643+
false in the source Eppo environment, surface that during execute:
557644

558-
> This flag is OFF in Eppo (<env>). I'll create it in Confidence but
559-
> keep the rules at 0% rollout so it stays inactive until you turn it
560-
> on intentionally. Continue?
645+
> This flag is INACTIVE in Eppo (<env>). I'll create it in Confidence
646+
> but keep the rules at 0% rollout so it stays off until you turn it on
647+
> intentionally. Continue?
561648
562-
**Variation type → Confidence schema.** Use the Eppo `variationType`
563-
(`STRING` / `BOOLEAN` / `NUMERIC` / `INTEGER` / `JSON`) as the
649+
**Variation type → Confidence schema.** Use the Eppo `variation_type`
650+
(`BOOLEAN` / `INTEGER` / `JSON` / `NUMERIC` / `STRING`) as the
564651
Confidence schema type when calling `createFlag`. Include all Eppo
565-
variations as Confidence variants.
652+
variations (`variant_key``value`) as Confidence variants.
653+
654+
**Default value.** Take the variation referenced by the allocation
655+
with `is_default: true` (its `variation_weight[0].variation_id`,
656+
resolved against `variations[]`) and pass that variant's value as
657+
`createFlag`'s default. Do NOT emit a targeting rule for the default
658+
allocation.
566659

567660
**Waterfall verification.** Because Eppo flags often have multiple
568661
allocations, the core file's Flag Setup Sequence Step 4 requires you to

0 commit comments

Comments
 (0)