Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 146 additions & 0 deletions docs/architecture-decisions/targeting-variant-metadata.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
---
# Valid statuses: draft | proposed | rejected | accepted | superseded
status: draft
author: Parth Suthar
created: 2026-07-30
updated: 2026-07-30
---

# Per-evaluation metadata from targeting rules

Let a targeting rule return `{ "variant": "<key>", "metadata": { ... } }` in place of a plain variant string, so the _branch that fired_ can annotate the evaluation with a rule id or reason.
Existing string returns are unchanged.

## Background

An evaluation today returns `value`, `variant`, `reason`, and `metadata`.
`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_ is to fetch the flag config and re-run the logic against the same context 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 that consumers then have to parse back out.

The plumbing to carry metadata already exists end to end: `AnyValue.Metadata` (`core/pkg/evaluator/ievaluator.go`) is `map[string]interface{}`, threaded through every resolver path and out through gRPC and OFREP as evaluation metadata that OpenFeature SDKs surface as `flagMetadata`.
The only thing missing is a way for a _rule branch_ to contribute to it.

## Proposal

Extend `definitions.primitive` in `schemas/json/targeting.json` with a tagged-object return shape:

```json
{
"type": "object",
"required": ["variant"],
"additionalProperties": false,
"properties": {
"variant": { "type": "string" },
"metadata": {
"$ref": "https://flagd.dev/schema/v0/flags.json#/definitions/metadata"
}
}
}
```

Reusing the existing `metadata` definition keeps values restricted to `string | number | boolean`, matching flag and flag-set metadata.
Comment on lines +29 to +45

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Document the migration and configuration-version contract before accepting this ADR.

When an older provider receives the new object return shape, it may reject the configuration or return PARSE_ERROR. Existing string configurations remain compatible, but the new configuration format is not forward-compatible with older providers.

Document the upgrade order, unsupported-provider behavior, and whether configuration requests carry a schema or feature version. Do not describe this as fully backwards compatible until mixed-version installations have a defined rollout path.

Also applies to: 47-51, 138-140

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/architecture-decisions/targeting-variant-metadata.md` around lines 29 -
45, Update the ADR to document the migration contract for the new tagged-object
return shape, including upgrade order, behavior when an older provider rejects
it or returns PARSE_ERROR, and whether configuration requests include a schema
or feature version. Clarify that existing string configurations remain
compatible but the new format is not forward-compatible, and remove any claim of
full backward compatibility until mixed-version rollout is defined.


In `evaluateVariant` (`core/pkg/evaluator/json.go`), replace the current string-strip of the JsonLogic result with a typed decode:

- string → variant key, as today
- object with `variant` field → use that as the variant key; shallow-merge `metadata` into the metadata already being returned
- anything else → `PARSE_ERROR`, same as an unrecognized return today
Comment on lines +47 to +51

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

echo "== JsonLogic module declaration =="
rg -n 'github.com/diegoholiveira/jsonlogic' . \
  --glob 'go.mod' \
  --glob 'go.sum' || true

echo "== evaluator implementation and tests =="
rg -n -C 8 'evaluateVariant|jsonlogic|PARSE_ERROR' core \
  --glob '*.go'

Repository: open-feature/flagd

Length of output: 24028


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

echo "== current evaluator conversion path =="
sed -n '326,420p' core/pkg/evaluator/json.go

echo "== evaluator test files and relevant test declarations =="
git ls-files 'core/pkg/evaluator/*_test.go'
rg -n -C 5 'evaluateVariant|targeting|ParseErrorCode|PARSE_ERROR|metadata' core/pkg/evaluator --glob '*_test.go' | head -n 260

echo "== module declaration =="
sed -n '1,24p' core/go.mod

echo "== available local jsonlogic source, if any =="
find "${GOMODCACHE:-/nonexistent}" -path '*diegoholiveira*jsonlogic*' -type f 2>/dev/null | head -n 40 || true

Repository: open-feature/flagd

Length of output: 22003


🌐 Web query:

github.com/diegoholiveira/jsonlogic v3.9.1 Apply operator-free object literal behavior

💡 Result:

The version v3.9.1 of the diegoholiveira/jsonlogic Go library, released on April 30, 2026, includes various internal fixes [1], but the specific phrase "operator-free object literal behavior" is not explicitly mentioned in the official release notes [1][2]. However, based on the implementation details within the library, JSON objects are processed according to the following logic: 1. Identification of Operators: The library identifies operators by evaluating the map keys [3][4]. If a map contains exactly one key that matches a registered operator, it executes that operation [3][5]. 2. Operator-Free Objects: In the context of this library, if a JSON object (a map) has more than one key, or if the key does not match any registered operators, it is treated as a primitive (or a raw object literal) and returned as-is rather than being interpreted as a JsonLogic command [3][4]. This behavior allows the library to distinguish between JSON structures intended as logic rules and standard JSON objects that should be passed through as data literals [3]. This mechanism serves as a way to "skip" evaluation for objects that do not conform to the expected { "operator": [...] } rule format [3][4]. { "value": "The v3.9.1 release of the Go implementation of JsonLogic (diegoholiveira/jsonlogic) focuses on small fixes and internal improvements, including those related to operator evaluation and short-circuit logic [1][2]. While the specific phrase 'operator-free object literal behavior' is not explicitly defined in the official documentation, the library handles such structures by checking the number of keys and valid operators in a map. If a map contains multiple keys or an unrecognized operator, it is treated as a literal and returned without further recursive processing [3][4]. This design choice is fundamental to how the library prevents standard data objects from being incorrectly parsed as logic commands [3].", "citations": [3][4][1][2], "confidence": "high"}

Citations:


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

base='https://raw.githubusercontent.com/diegoholiveira/jsonlogic/v3.9.1'

echo "== upstream evaluator implementation =="
curl -fsSL "$base/jsonlogic.go" | nl -ba | sed -n '1,260p'

echo "== upstream validator and operation handling =="
for file in validator.go operation.go; do
  echo "--- $file ---"
  curl -fsSL "$base/$file" | nl -ba | sed -n '1,240p'
done

echo "== upstream tests mentioning object literals, if, or Apply =="
for file in jsonlogic_test.go evaluator_test.go validator_test.go; do
  body="$(curl -fsSL "$base/$file" 2>/dev/null || true)"
  if [ -n "$body" ]; then
    echo "--- $file ---"
    printf '%s\n' "$body" | rg -n -C 4 'object|literal|Apply|if|map|variant' | head -n 220 || true
  fi
done

Repository: open-feature/flagd

Length of output: 309


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

base='https://raw.githubusercontent.com/diegoholiveira/jsonlogic/v3.9.1'

echo "== upstream evaluator implementation =="
curl -fsSL "$base/jsonlogic.go" | sed -n '1,280p'

echo "== upstream validator and operation handling =="
for file in validator.go operation.go; do
  echo "--- $file ---"
  curl -fsSL "$base/$file" | sed -n '1,260p'
done

echo "== upstream repository file list =="
curl -fsSL 'https://api.github.com/repos/diegoholiveira/jsonlogic/git/trees/v3.9.1?recursive=1' |
  grep -o '"path": "[^"]*"' |
  grep -E 'test|jsonlogic.go|validator.go|operation.go' |
  head -n 100

Repository: open-feature/flagd

Length of output: 11512


Add compatibility tests for tagged objects.

jsonlogic/v3.9.1 treats only maps with more than one key as literals. A {"variant":"blue"} result is treated as operator variant, so jsonlogic.Apply returns an error and evaluateVariant returns PARSE_ERROR. Add integration coverage through evaluateVariant for string returns, tagged objects with and without metadata, nested if branches, and invalid shapes. Adjust the evaluator or dependency if single-key tagged objects are supported.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/architecture-decisions/targeting-variant-metadata.md` around lines 47 -
51, Extend integration tests around evaluateVariant to cover string results,
tagged objects with and without metadata, nested if branches, and invalid result
shapes. Ensure single-key {"variant":...} objects are supported despite
jsonlogic/v3.9.1 treating them as operators by adjusting evaluateVariant or the
dependency, while preserving PARSE_ERROR for unsupported shapes and validating
metadata merging.


Merge precedence, lowest → highest, so more specific wins:
flag-set metadata → flag metadata → **rule-returned metadata**.

Example — chained `if` (adapted from the `chainable-conditions` playground scenario). Each branch now 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",
"metadata": { "rule": "customer-A-allowlist" }
},
{ "in": [{ "var": "customerId" }, ["customer-B1", "customer-B2"]] },
{
"variant": "beta",
"metadata": { "rule": "beta-cohort" }
},
{
"variant": "ga",
"metadata": { "rule": "ga-default" }
}
]
}
}
}
```

Three callers, three variants, three different `metadata.rule` values in the evaluation result — no config lookup needed.

Nested rules work the same way — the branch that produces the return value is the one whose metadata rides through (adapted from `enable-by-locale` + `gradual-rollout` combined):

```json
{
"targeting": {
"if": [
{ "==": [{ "var": "locale" }, "en-US"] },
{
"if": [
{ "fractional": [{ "var": "targetingKey" }, ["on", 10], ["off", 90]] },
{
"variant": "on",
"metadata": { "rule": "us-10pct-rollout" }
},
{
"variant": "off",
"metadata": { "rule": "us-holdback" }
}
]
},
{
"variant": "off",
"metadata": { "rule": "non-us-off" }
}
]
}
}
```

A US caller who buckets into the 10% cohort sees `metadata.rule=us-10pct-rollout`; the same variant `on` reached via any other path would carry a different `rule` value. The 90% US holdback and the non-US off case both serve `off` but are distinguishable in telemetry.

Note the `fractional` bucket itself still resolves to a plain variant string internally — see the fractional caveat in Open Questions.

### Telemetry win

`flagMetadata` is already emitted on OpenFeature evaluation events and picked up by hooks (OpenTelemetry, logging, custom exporters).
Once rule metadata rides that channel, every downstream tool gets rule-level attribution _for free_:

- OTel spans / evaluation events carry a `rule` (or whatever key the config author picked) attribute — filter and group by rule id in traces and logs without touching application code.
- A/B and experimentation dashboards can bucket by rule id emitted by flagd, instead of re-computing which rule fired from raw context.
- Debug logs on the caller side get an immediate "why" alongside the "what" — variant plus rule id in the same log line, no config lookup.

This turns targeting attribution from a config-diving exercise into a queryable dimension anywhere `flagMetadata` already flows.

## Consequences

- **Good** — direct answer to "which branch fired" without overloading the variant key; no wire-format changes; every SDK already surfaces `flagMetadata`.
- **Good** — fully backwards compatible; string returns are unchanged byte-for-byte.
- **Bad** — in-process implementations (Java, JS, Kotlin, Python, .NET) each need the same object-return handling to stay conformant. Covered the usual way — a new suite in [flagd-testbed](https://github.com/open-feature/flagd-testbed).

## Open questions

- **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.
- **JsonLogic literal-object semantics.** We rely on the current Go engine (`github.com/diegoholiveira/jsonlogic`) treating an object with no operator keys as a data literal at return position. Pin with an integration test so a future engine swap doesn't regress it.
- **Metadata size.** No metadata field is capped today; consistency says leave uncapped, but a soft warning at config load is cheap if reviewers want it.
Loading