-
Notifications
You must be signed in to change notification settings - Fork 133
docs: add adr for targeting metadata #2013
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
suthar26
wants to merge
1
commit into
open-feature:main
Choose a base branch
from
suthar26:docs-adr-targeting-metadata
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
255 changes: 255 additions & 0 deletions
255
docs/architecture-decisions/targeting-variant-metadata.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,255 @@ | ||
| --- | ||
| # Valid statuses: draft | proposed | rejected | accepted | superseded | ||
| status: proposed | ||
| author: Parth Suthar | ||
| created: 2026-07-30 | ||
| updated: 2026-09-02 | ||
| --- | ||
|
|
||
| # Per-evaluation metadata from targeting rules | ||
|
|
||
| Let a targeting rule return `{ "variant": "<key>", "reason": "<string>", "metadata": { ... } }` in place of a plain variant string, so the _branch that fired_ can annotate the evaluation with extra metadata, custom reasons, and other auxiliary information. Existing string returns are unchanged. | ||
|
|
||
| ## Background | ||
|
|
||
| An evaluation today returns `value`, `variant`, `reason`, and `metadata`. The `reason` is a coarse enum (`TARGETING_MATCH`, `DEFAULT`, `STATIC`, …); `metadata` carries only the static blocks defined at flag-set and flag level. Neither answers the debugging question we hit most often: **which branch of the targeting expression fired?** | ||
|
|
||
| For a nested `if` / `and` / `or` tree the resolver just returns the winning variant key. Two rules landing on the same variant are indistinguishable in the response — the only way to know _why_ today is to fetch the flag config and re-run the logic by hand. Encoding the branch identity into the variant key (`"clubs-eu-rollout-a"`) is the workaround, and it pollutes the variant space with debug info consumers then have to parse back out. | ||
|
|
||
| The plumbing to carry metadata already exists on most paths: `AnyValue.Metadata` (`core/pkg/evaluator/ievaluator.go`) is threaded through the evaluator and surfaced via OFREP (single and bulk) and single-resolution gRPC responses as `flagMetadata`. Two paths don't carry it yet — bulk gRPC `ResolveAll` and `RecordEvaluation` telemetry — but wiring those up is separate follow-up work. | ||
|
|
||
| ## Proposal | ||
|
|
||
| In `definitions.primitive` in `schemas/json/targeting.json`, the `string` entry (the variant-key return) becomes a choice of the plain string or the tagged object — the other `primitive` entries (`null`, `boolean`, `number`, `array`) are untouched: | ||
|
|
||
| ```json | ||
| { | ||
| "description": "When returned from rules, strings are used as keys to retrieve the associated value from the \"variants\" object. Be sure that the returned string is present as a key in the variants! As of <version>, an object of the form { \"variant\": ..., \"reason\": ..., \"metadata\": ... } may be used instead, to attach a custom reason and arbitrary metadata to the branch that fired.", | ||
| "oneOf": [ | ||
| { "type": "string" }, | ||
| { | ||
| "type": "object", | ||
| "required": ["variant"], | ||
| "additionalProperties": false, | ||
| "properties": { | ||
| "variant": { "type": "string" }, | ||
| "reason": { "type": "string" }, | ||
| "metadata": { | ||
| "allOf": [ | ||
| { | ||
| "$ref": "https://flagd.dev/schema/v0/flags.json#/definitions/metadata" | ||
| }, | ||
| { | ||
| "properties": { | ||
| "details": { | ||
| "type": "string", | ||
| "description": "Free-text explanation of why this branch fired." | ||
| } | ||
| } | ||
| } | ||
| ] | ||
| } | ||
| } | ||
| } | ||
| ] | ||
| } | ||
| ``` | ||
|
|
||
| ### Field types | ||
|
|
||
| - **`variant`** — string. The only **required** property, and the exact equivalent of the plain-string return: `"red"` and `{ "variant": "red" }` are interchangeable terminals selecting the variant `red`. | ||
| - **`reason`** — string, optional. Free-form, operator-defined; overrides the top-level evaluation `reason` for this branch. The schema does not constrain it to an enum (see [Narrowing the coarse `reason`](#narrowing-the-coarse-reason)). | ||
| - **`metadata`** — object, optional, shallow. Reuses the existing `metadata` definition, so its values stay restricted to `string | number | boolean`, matching flag and flag-set metadata. Nested objects and arrays are not permitted. | ||
| - **`metadata.details`** — string, optional. A well-known key, declared in the schema so editors autocomplete and validate it, but carried inside `metadata` rather than promoted to a peer of `variant`. | ||
|
|
||
| ### Evaluator changes | ||
|
|
||
| In `evaluateVariant` (`core/pkg/evaluator/json.go`), the current unconditional quote-strip of the JsonLogic result gains an object case ahead of it: | ||
|
|
||
| - object with a `variant` field → use it as the variant key; shallow-merge the rule's `metadata` object into the returned metadata; if `reason` is present, use it in place of `TARGETING_MATCH` | ||
| - everything else → unchanged from today: `null` exits targeting to `defaultVariant`; strings, booleans, and numbers are quote-stripped into a variant key (so a boolean `true` still resolves the `"true"` variant, as `schemas/json/targeting.json` documents and the existing tests cover); a key absent from `variants` errors exactly as it does now | ||
|
|
||
| Metadata merge precedence, lowest → highest, so more specific wins: flag-set metadata → flag metadata → **rule-returned metadata**. | ||
|
|
||
| !!! warning "Breaking change to the config schema" | ||
|
|
||
| Existing configs keep working on a new binary, but the reverse does not hold: a config using the tagged-object return, evaluated by an older flagd binary, fails — the old resolver quote-strips the JsonLogic result and gets a non-existent variant key (or panics on the single-key `variant` map, which it has no operator registered for). | ||
|
|
||
| There's no schema/feature version negotiation today, so the config schema version has to gate it: operators on an older binary must stay on the older schema version, and flagd has to be upgraded fleet-wide before any config starts emitting the new shape. This needs a schema version bump and a migration note in the release. | ||
|
|
||
| Gating on the schema version is sufficient on its own — providers and flagd embed a snapshot of the schema in each version, so an older release rejects the new shape at validation time rather than misbehaving at evaluation time. | ||
|
|
||
| **Hold the hosted schema back.** `https://flagd.dev/schema/v0/flags.json` is what editors and IDE tooling resolve against. Do not publish the new shape there until it has landed in flagd and a few providers — otherwise editors will happily autocomplete a construct most of the fleet cannot evaluate. | ||
|
|
||
| One wrinkle: the Go JsonLogic engine (`github.com/diegoholiveira/jsonlogic`) treats a returned map as a data literal only when it has **more than one key** (`apply()` in `jsonlogic.go`); a single-key map is looked up as an operator and panics if unregistered. Register `variant` as a passthrough JsonLogic operator — same way `fractional`, `starts_with`, `ends_with`, and `semver` are already registered — so all shapes reach the typed decode instead of panicking. | ||
|
|
||
| ### Example — a full flag config | ||
|
|
||
| ```jsonc | ||
| { | ||
| "$schema": "https://flagd.dev/schema/v0/flags.json", | ||
| "flags": { | ||
| "enable-mainframe-access": { | ||
| "state": "ENABLED", | ||
| "defaultVariant": "off", | ||
| "variants": { | ||
| "on": true, | ||
| "off": false, | ||
| }, | ||
| "targeting": { | ||
| "if": [ | ||
| { | ||
| "ends_with": [{ "var": "email" }, "@ingen.com"], | ||
| }, | ||
| // new terminus — this node used to be the plain string "on" | ||
| { | ||
| "variant": "on", // required | ||
| "reason": "my_super_cool_custom_reason", // optional | ||
| "metadata": {}, // custom metadata, optionally including "details" | ||
| }, | ||
| ], | ||
| }, | ||
| }, | ||
| }, | ||
| } | ||
| ``` | ||
|
|
||
| ### Example — chained `if` | ||
|
|
||
| Each branch tags the evaluation with the rule that fired: | ||
|
|
||
| ```json | ||
| { | ||
| "acceptable-feature-stability": { | ||
| "state": "ENABLED", | ||
| "defaultVariant": "ga", | ||
| "variants": { "alpha": "alpha", "beta": "beta", "ga": "ga" }, | ||
| "targeting": { | ||
| "if": [ | ||
| { "===": [{ "var": "customerId" }, "customer-A"] }, | ||
| { | ||
| "variant": "alpha", | ||
| "reason": "TARGETING_MATCH", | ||
| "metadata": { "details": "explicit allowlist for enterprise pilot" } | ||
| }, | ||
| { "in": [{ "var": "customerId" }, ["customer-B1", "customer-B2"]] }, | ||
| { "variant": "beta", "reason": "TARGETING_MATCH" }, | ||
| { "variant": "ga", "reason": "FALLTHROUGH" } | ||
| ] | ||
| } | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| ### Example — nested `if` with a fractional split | ||
|
|
||
| `fractional` returns a plain string (`"on"` or `"off"`), and both are truthy under JsonLogic — so it can't be used directly as an `if` condition; wrapping it in `==` against a target bucket label makes it a real boolean: | ||
|
|
||
| ```json | ||
| { | ||
| "targeting": { | ||
| "if": [ | ||
| { "==": [{ "var": "locale" }, "en-US"] }, | ||
| { | ||
| "if": [ | ||
| { | ||
| "==": [ | ||
| { | ||
| "fractional": [ | ||
| { "var": "targetingKey" }, | ||
| ["on", 10], | ||
| ["off", 90] | ||
| ] | ||
| }, | ||
| "on" | ||
| ] | ||
| }, | ||
| { | ||
| "variant": "on", | ||
| "reason": "SPLIT_RANDOM", | ||
| "metadata": { "details": "us-10pct-rollout" } | ||
| }, | ||
| { | ||
| "variant": "off", | ||
| "reason": "SPLIT_FALLTHROUGH", | ||
| "metadata": { "details": "us-holdback" } | ||
| } | ||
| ] | ||
| }, | ||
| { | ||
| "variant": "off", | ||
| "reason": "FALLTHROUGH", | ||
| "metadata": { "details": "non-us-off" } | ||
| } | ||
| ] | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| The 90% US holdback and the non-US off case both serve `off` but carry different `reason` and `details` values, so they stay distinguishable in telemetry. | ||
|
|
||
| ### Narrowing the coarse `reason` | ||
|
|
||
| flagd's top-level `reason` today (`TARGETING_MATCH`, `DEFAULT`, `STATIC`, `DISABLED`, `ERROR`, `FALLBACK`) says _what class_ of evaluation happened but not _which specific path_ inside it — every rule-driven result collapses to `TARGETING_MATCH`. A rule-scoped `reason` plus `metadata.details` sub-classifies that. The schema deliberately does not fix an enum: `reason` is an arbitrary string the operator defines, so each fleet picks whatever taxonomy fits it. | ||
|
|
||
| Note that the OpenFeature spec ([evaluation details, requirement 6.1](https://openfeature.dev/specification/sections/evaluation-context)) types `reason` as a free-form string — the values in the spec (`TARGETING_MATCH`, `SPLIT`, `DEFAULT`, …) are _recommended_, not exhaustive, and providers are explicitly allowed to emit their own. | ||
| That means letting a branch-scoped `reason` override the top-level `reason` is spec-legal without any SDK contract change; SDK hooks and telemetry sinks already treat the field as opaque. | ||
|
|
||
| The user-facing reason does not match what the flagd engine actually evaluated for different variants of `SPLIT`. Today `SPLIT` covers both fractional and gradual rollout. Letting the rule override the reason is an easy win here, and the same applies to the several distinct kinds of `OVERRIDE`. | ||
|
|
||
| ```json | ||
| { "variant": "on", "reason": "SPLIT_GRADUAL", "metadata": { "details": "5%→50% ramp, week 3" } } | ||
| { "variant": "on", "reason": "SPLIT_STEPPED", "metadata": { "details": "stage 2 of 4, cohort=eu-west" } } | ||
| { "variant": "on", "reason": "SPLIT_RANDOM", "metadata": { "details": "50/50 A/B, salt=exp-4231" } } | ||
| ``` | ||
|
|
||
| Seeing these reasons instead of a bare `SPLIT` allows for much richer telemetry signals. | ||
|
|
||
| Other patterns the free-form field unlocks inside a single `TARGETING_MATCH`: | ||
|
|
||
| - **Split vs. allowlist under the same top-level reason** — two paths reach the same variant, telemetry can tell them apart: | ||
|
|
||
| ```json | ||
| { "variant": "on", "reason": "SPLIT", "metadata": { "details": "10% cohort bucketed by email" } } | ||
| { "variant": "on", "reason": "ALLOWLIST", "metadata": { "details": "customerId in enterprise-pilot" } } | ||
| ``` | ||
|
|
||
| - **Kill switch / override tagged distinctly** — a branch short-circuiting an incident stays visible in traces: | ||
|
|
||
| ```json | ||
| { | ||
| "variant": "off", | ||
| "reason": "OVERRIDE", | ||
| "metadata": { "details": "incident-4231 kill switch" } | ||
| } | ||
| ``` | ||
|
|
||
| - **Audience-segment attribution** — which specific condition inside a large predicate fired: | ||
|
|
||
| ```json | ||
| { | ||
| "variant": "beta", | ||
| "reason": "AUDIENCE_MATCH", | ||
| "metadata": { "details": "country=US AND app_version>=2.0" } | ||
| } | ||
| ``` | ||
|
|
||
| ### Testing | ||
|
|
||
| Beyond Go unit tests in `core/pkg/evaluator`, the flagd gherkin suite gets extended to cover the new terminus end-to-end: testbed flag configs using the tagged-object return, and scenarios in the shared feature files asserting the resolved `variant`, the overridden `reason`, and the merged `flagMetadata` (including `details`). | ||
|
|
||
| Those scenarios run across the gRPC, OFREP, and in-process paths that `test/integration` already exercises, which keeps every provider honest about the new shape rather than testing it only inside flagd. | ||
|
|
||
| ## Consequences | ||
|
|
||
| - **Good** — direct answer to "which branch fired" without overloading the variant key; no wire-format changes. `flagMetadata` is already emitted on OpenFeature evaluation events and picked up by SDK hooks (OpenTelemetry, logging, custom exporters), so anything reading it — OTel spans, A/B dashboards, debug logs — picks up rule-level attribution automatically. | ||
| - **Good** — backwards compatible for existing configs: string, boolean, number, and null returns are byte-for-byte unchanged on an upgraded flagd binary. | ||
| - **Good** — no OpenFeature spec change required. `reason` is already a first-class spec field, and `details` rides `flagMetadata`, so no SDK gains a new resolution field. | ||
| - **Bad** — not forward compatible: a config using the new object-return shape, evaluated by an older flagd binary, fails (the returned object doesn't quote-strip into a valid variant key). No schema/feature version negotiation exists today, so rollouts must upgrade flagd before configs start using the new shape. Needs an explicit migration note. | ||
| - **Bad** — automatic telemetry attribution requires the separate `RecordEvaluation` work called out in Background; without it, rule ids ride `flagMetadata` for SDK-side hooks but don't reach flagd's own OTel metrics. | ||
|
|
||
| ## Open questions | ||
|
|
||
| - **Config version.** Proposal: bump the targeting/flags schema to `v0.1` and gate the new terminus on it, leaving `v0` untouched for existing fleets. Sequencing of the hosted schema at `flagd.dev` is covered in the migration warning above. | ||
| - **Fractional buckets.** `fractional` returns a variant string directly, so it can't tag the picked bucket with metadata without a separate operator extension (e.g. an optional third element per weight tuple). Proposal: defer — ship the base object-return shape first. Nesting metadata into fractional is meaningfully more complicated and shouldn't block this. | ||
| - **JsonLogic operator registration for `variant`.** Registering `variant` as a passthrough operator is a workaround for a dependency quirk, not a documented contract of `github.com/diegoholiveira/jsonlogic`. Pin the behavior with an integration test so a future engine swap or upgrade doesn't silently regress single-key tagged-object returns. | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.