From 0dc499fcce811f5eaf8028c7341994bd3c53f837 Mon Sep 17 00:00:00 2001 From: Parth Suthar Date: Thu, 2 Apr 2026 15:23:05 -0400 Subject: [PATCH 1/7] docs: add disabled flag behaviour Signed-off-by: Parth Suthar --- .../disabled-flag-evaluation.md | 284 ++++++++++++++++++ 1 file changed, 284 insertions(+) create mode 100644 docs/architecture-decisions/disabled-flag-evaluation.md diff --git a/docs/architecture-decisions/disabled-flag-evaluation.md b/docs/architecture-decisions/disabled-flag-evaluation.md new file mode 100644 index 000000000..63f634c2f --- /dev/null +++ b/docs/architecture-decisions/disabled-flag-evaluation.md @@ -0,0 +1,284 @@ +--- +# Valid statuses: draft | proposed | rejected | accepted | superseded +status: draft +author: Parth Suthar (@suthar26) +created: 2026-04-01 +--- +# Treat Disabled Flag Evaluation as Successful with Reason DISABLED + +This ADR proposes changing flagd's handling of disabled flags from returning an error (`reason=ERROR`, `errorCode=FLAG_DISABLED`) to returning a successful evaluation with `reason=DISABLED` and the flag's `defaultVariant` value. A flag that does not exist in a flag set should remain a `FLAG_NOT_FOUND` error. +This aligns flagd with the [OpenFeature specification's resolution reasons](https://openfeature.dev/specification/types/#resolution-reason), which defines `DISABLED` as a valid resolution reason for successful evaluations. + +## Background + +flagd currently treats the evaluation of a disabled flag as an error. When a flag exists in the store but has `state: DISABLED`, the evaluator returns `reason=ERROR` with `errorCode=FLAG_DISABLED`. This error propagates through every surface — gRPC, OFREP, and in-process providers — resulting in the caller receiving an error response rather than a resolved value. + +This is problematic for several reasons: + +1. **Spec misalignment**: The [OpenFeature specification](https://openfeature.dev/specification/types/#resolution-reason) explicitly defines `DISABLED` as a resolution reason with the description: *"The resolved value was the result of the flag being disabled in the management system."* This implies a successful evaluation that communicates the flag's disabled state, not an error. +2. **OFREP masks the disabled state**: The OFREP response handler in `core/pkg/service/ofrep/models.go` rewrites `FLAG_DISABLED` to `FLAG_NOT_FOUND` in the structured error response (while only preserving the "is disabled" distinction in the free-text `errorDetails` string). This means OFREP clients cannot programmatically distinguish between a flag that doesn't exist and one that was intentionally disabled. +3. **Conflation of "missing" and "disabled"**: gRPC v1 maps both `FLAG_NOT_FOUND` and `FLAG_DISABLED` to `connect.CodeNotFound`. These are semantically different situations: a missing flag is a configuration or deployment error, while a disabled flag is an intentional operational decision (incident remediation, environment-specific rollout, not-yet-ready feature). +4. **Loss of observability**: When disabled flags are treated as errors, they pollute error metrics and alerting. Operators who disable a flag for legitimate reasons (ongoing incident remediation, feature not ready for an environment) see false error signals. Conversely, if they suppress these errors, they lose visibility into flag state entirely. A successful evaluation with `reason=DISABLED` would give operators a clean signal without noise. +5. **Flag set use cases**: In multi-flag-set deployments, a flag may exist in a shared definition but be disabled in certain flag sets (e.g., disabled for `staging` but enabled for `production`). Treating this as an error forces the application into error-handling paths when the flag is simply not active — a normal operational state, not an exceptional one. + +Related context: + +- [OpenFeature Specification - Resolution Reasons](https://openfeature.dev/specification/types/#resolution-reason) +- [flagd Flag Definitions Reference](https://flagd.dev/reference/flag-definitions/) +- [ADR: Support Explicit Code Default Values](./support-code-default.md) — establishes the pattern of returning `defaultVariant` with appropriate reason codes + +## Requirements + +- Evaluating a disabled flag must return a successful response with `reason=DISABLED` across all surfaces (gRPC v1, gRPC v2, OFREP, in-process) +- The resolved value for a disabled flag must be the flag's configured `defaultVariant` +- If the flag's `defaultVariant` is `null` (per the code-default ADR), the disabled response must defer to code defaults using the same field-omission pattern, but with `reason=DISABLED` instead of `reason=DEFAULT` +- Evaluating a flag key that does not exist in the store or flag set must remain a `FLAG_NOT_FOUND` error +- `reason=DISABLED` must be distinct from all other reasons (`STATIC`, `DEFAULT`, `SPLIT`, `TARGETING_MATCH`, `ERROR`) and must not trigger error-handling paths in providers or SDKs +- Bulk evaluation (`ResolveAll`) must include disabled flags in the response with `reason=DISABLED`, rather than silently omitting them +- Telemetry and metrics must record disabled flag evaluations as successful (non-error) with the `DISABLED` reason +- Existing flag configurations must continue to work without modification (backward compatible at the configuration level) + +## Considered Options + +- **Option 1: Successful evaluation with `reason=DISABLED` returning `defaultVariant`** — Disabled flags evaluate successfully, returning the `defaultVariant` value and `reason=DISABLED` +- **Option 2: Return a successful evaluation with** `reason=DEFAULT` — Treat disabled flags as if they had no targeting, collapsing the disabled state into the default reason +- **Option 3: Status quo** — Keep the current error behavior and document it as intentional divergence from the OpenFeature spec + +## Proposal + +We propose **Option 1: Successful evaluation with `reason=DISABLED` returning `defaultVariant`**. + +When a flag exists in the store but has `state: DISABLED`, the evaluator should return a successful evaluation with the following properties: + +- **value**: The flag's `defaultVariant` value (from the flag configuration). If `defaultVariant` is `null` or absent, the value field is omitted to signal code-default deferral (see [Interaction with code defaults](#interaction-with-code-defaults-defaultvariant-null)). +- **variant**: The flag's `defaultVariant` key. If `defaultVariant` is `null` or absent, the variant field is omitted. +- **reason**: `DISABLED` +- **error**: `nil` (no error) +- **metadata**: The merged flag set + flag metadata (consistent with current behavior for successful evaluations) + +This aligns with the OpenFeature specification's definition of `DISABLED` as a resolution reason and leverages the existing but unused `DisabledReason` constant already defined in flagd. + +### Interaction with other resolution reasons + +The [OpenFeature specification](https://openfeature.dev/specification/types/#resolution-reason) defines several resolution reasons. Here is how `DISABLED` interacts with each: + +| Reason | Current flagd usage | Interaction with DISABLED | +| ----------------- | ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `STATIC` | Returned when a flag has no targeting rules and resolves to `defaultVariant` | When disabled, `DISABLED` takes precedence. The flag's `defaultVariant` is still returned as the value, but the reason is `DISABLED`, not `STATIC`. This distinction matters: `STATIC` tells caching providers the value is safe to cache indefinitely, while `DISABLED` signals the value may change when the flag is re-enabled. | +| `DEFAULT` | Returned when targeting rules exist but evaluate to the `defaultVariant` | When disabled, `DISABLED` takes precedence. Targeting rules are not evaluated at all for disabled flags, so `DEFAULT` (which implies targeting was attempted) is not appropriate. | +| `SPLIT` | Defined in flagd but used for pseudorandom assignment (fractional targeting) | When disabled, `DISABLED` takes precedence. Fractional targeting rules are not evaluated for disabled flags. | +| `TARGETING_MATCH` | Returned when targeting rules match and select a specific variant | Not applicable. Targeting rules are never evaluated for disabled flags. | +| `ERROR` | Currently returned for disabled flags (this is what we are changing) | `DISABLED` replaces `ERROR` for this case. `ERROR` remains the reason for genuine errors (parse errors, type mismatches, etc.). | + +**Key principle**: `DISABLED` is a terminal reason. When a flag is disabled, no targeting evaluation occurs, so reasons that describe targeting outcomes (`STATIC`, `DEFAULT`, `SPLIT`, `TARGETING_MATCH`) never apply. The evaluation short-circuits to `reason=DISABLED` with the `defaultVariant` value. + +### Interaction with code defaults (`defaultVariant: null`) + +Per the [code-default ADR](./support-code-default.md), when `defaultVariant` is `null`, the server omits the value and variant fields to signal code-default deferral. This pattern applies to disabled flags as well: + +- `defaultVariant` is a string → return the variant value with `reason=DISABLED` +- `defaultVariant` is `null` → omit value/variant fields, return `reason=DISABLED` + +The only difference from a normal code-default response is the reason field: `DISABLED` instead of `DEFAULT`. + +### API changes + +**Evaluator core — `evaluateVariant`** (`core/pkg/evaluator/json.go`): + +The `evaluateVariant` function changes from returning an error to returning a successful result. Since `flag.DefaultVariant` is already `""` when no default variant is configured, no additional branch is needed: + +```go +// Before +if flag.State == Disabled { + return "", flag.Variants, model.ErrorReason, metadata, errors.New(model.FlagDisabledErrorCode) +} + +// After +if flag.State == Disabled { + return flag.DefaultVariant, flag.Variants, model.DisabledReason, metadata, nil +} +``` + +**Evaluator core — `resolve[T]`** (`core/pkg/evaluator/json.go`): + +The generic `resolve[T]` function currently only short-circuits for `FallbackReason` when the variant is empty. For any other reason it attempts a variant map lookup, which would produce a `TYPE_MISMATCH` error for disabled flags that defer to code defaults (`defaultVariant: null`). `resolve[T]` must treat `DisabledReason` the same way when the variant is empty: + +```go +// Before +if reason == model.FallbackReason { + var zero T + return zero, variant, model.FallbackReason, metadata, nil +} + +// After +if reason == model.FallbackReason || reason == model.DisabledReason { + if variant == "" { + var zero T + return zero, variant, reason, metadata, nil + } +} +``` + +**Bulk evaluation — `ResolveAllValues`** (`core/pkg/evaluator/json.go`): + +Disabled flags are no longer skipped. They are evaluated and included in the response. Additionally, when `defaultVariant` is `null`, the type switch on `flag.Variants[flag.DefaultVariant]` hits the `default` case. For gRPC v1 requests (where `ProtoVersionKey` is set), the current `default` branch skips unknown types via `continue`. This must be adjusted so disabled flags are always included regardless of proto version: + +```go +// Before +if flag.State == Disabled { + continue +} + +// After: remove the skip — disabled flags flow through normal evaluation +// and will be returned with reason=DISABLED +``` + +```go +// Before (default branch of type switch) +default: + if ctx.Value(ProtoVersionKey) == nil { + value, variant, reason, metadata, err = resolve[interface{}](...) + } else { + continue + } + +// After: disabled flags must not be skipped even for old proto versions +default: + if ctx.Value(ProtoVersionKey) == nil { + value, variant, reason, metadata, err = resolve[interface{}](...) + } else if flag.State == Disabled { + value, variant, reason, metadata, err = resolve[interface{}](...) + } else { + continue + } +``` + +**OFREP success mapping** (`core/pkg/service/ofrep/models.go`): + +The `SuccessResponseFrom` function currently rewrites `FallbackReason` to `DefaultReason` and omits value/variant fields to signal code-default deferral. When a disabled flag defers to code defaults, the same field-omission pattern must apply, but the reason must remain `DISABLED` (not be rewritten to `DEFAULT`): + +```go +// Before +if result.Reason == model.FallbackReason { + return EvaluationSuccess{ + Value: nil, + Key: result.FlagKey, + Reason: model.DefaultReason, + Variant: "", + Metadata: result.Metadata, + } +} + +// After: handle disabled flags deferring to code defaults +if result.Reason == model.FallbackReason { + return EvaluationSuccess{ + Value: nil, + Key: result.FlagKey, + Reason: model.DefaultReason, + Variant: "", + Metadata: result.Metadata, + } +} +if result.Reason == model.DisabledReason && result.Variant == "" { + return EvaluationSuccess{ + Value: nil, + Key: result.FlagKey, + Reason: model.DisabledReason, + Variant: "", + Metadata: result.Metadata, + } +} +``` + +**gRPC response** (single flag evaluation): + +```json +{ + "value": false, + "variant": "off", + "reason": "DISABLED", + "metadata": { + "flagSetId": "my-app", + "scope": "production" + } +} +``` + +**OFREP response** (single flag evaluation): + +```json +{ + "key": "my-feature", + "value": false, + "variant": "off", + "reason": "DISABLED", + "metadata": { + "flagSetId": "my-app" + } +} +``` + +**OFREP bulk response** (disabled flag now included): + +```json +{ + "flags": [ + { + "key": "my-feature", + "value": false, + "variant": "off", + "reason": "DISABLED", + "metadata": {} + }, + { + "key": "active-feature", + "value": true, + "variant": "on", + "reason": "STATIC", + "metadata": {} + } + ] +} +``` + +### Consequences + +- Good, because it aligns flagd with the OpenFeature specification's definition of `DISABLED` as a resolution reason +- Good, because it eliminates the OFREP bug where `FLAG_DISABLED` is silently masked as `FLAG_NOT_FOUND` +- Good, because operators get clean observability: disabled flags appear as successful evaluations with a distinct reason, not polluting error metrics +- Good, because it enables flag-set-based workflows where disabling a flag in one environment is a normal operational state +- Good, because the existing `DisabledReason` constant is finally used as designed +- Good, because it provides visibility into disabled flags in bulk evaluation responses, rather than silently omitting them +- Good, because applications can distinguish between "flag doesn't exist" (a real problem) and "flag is disabled" (an intentional state) +- Bad, because it is a breaking change for clients that rely on `FLAG_DISABLED` error responses for control flow, alerting, or metrics +- Bad, because including disabled flags in `ResolveAll` responses increases payload size for flag sets with many disabled flags +- Bad, because it requires coordinated updates across flagd core, all gRPC/OFREP surfaces, providers, and the testbed +- Neutral, because the `FlagDisabledErrorCode` constant and related error-handling code can be removed (code simplification) + +### Timeline + +1. Update `evaluateVariant` in `core/pkg/evaluator/json.go` to return `reason=DISABLED` with `defaultVariant` instead of an error +2. Update `resolve[T]` in `core/pkg/evaluator/json.go` to handle `DisabledReason` with empty variants (avoids `TYPE_MISMATCH` when disabled flags defer to code defaults) +3. Remove the disabled-flag skip in `ResolveAllValues` and update the `default` branch of the type switch to include disabled flags for gRPC v1 requests +4. Update `SuccessResponseFrom` in `core/pkg/service/ofrep/models.go` to preserve `reason=DISABLED` (with field omission) when a disabled flag defers to code defaults +5. Remove `FlagDisabledErrorCode` handling from `errFormat`, `errFormatV2`, and `EvaluationErrorResponseFrom` +6. Update gRPC v1 service layer (`flag_evaluator_v1.go`) to handle nil values in `ResolveAll` responses for disabled flags deferring to code defaults +7. Update flagd-testbed with test cases for disabled flag evaluation across all surfaces +8. Update OpenFeature providers to recognize `DISABLED` as a non-error, non-cacheable reason +9. Update provider documentation and migration guides + +### Open questions + +- Should bulk evaluation include an option to exclude disabled flags for clients that prefer the current behavior (smaller payloads)? +- How should existing dashboards and alerts that key on `FLAG_DISABLED` errors be migrated? Should we provide a deprecation period where both behaviors are available? +- Does this change require a new flagd major version, or can it be introduced in a minor version with appropriate documentation given the spec alignment argument? +- Should the `FlagDisabledErrorCode` constant be retained (but unused) for a deprecation period, or removed immediately? +- How should in-process providers handle the transition? They evaluate locally and would need to be updated to return `DISABLED` reason instead of throwing an error. + +## More Information + +- [OpenFeature Specification - Resolution Reasons](https://openfeature.dev/specification/types/#resolution-reason) +- [OpenFeature Specification - Error Codes](https://openfeature.dev/specification/types/#error-code) — notably, `FLAG_DISABLED` is not in the spec's error code list +- [flagd Flag Definitions Reference](https://flagd.dev/reference/flag-definitions/) +- [ADR: Support Explicit Code Default Values](./support-code-default.md) +- [flagd Testbed](https://github.com/open-feature/flagd-testbed) From cb227d37b34c8ac89a586742f802c0abe2fa6540 Mon Sep 17 00:00:00 2001 From: Parth Suthar Date: Wed, 15 Apr 2026 14:50:46 -0400 Subject: [PATCH 2/7] docs: update the disabled flows to use code defaults Signed-off-by: Parth Suthar --- .../disabled-flag-evaluation.md | 124 ++++++++---------- 1 file changed, 55 insertions(+), 69 deletions(-) diff --git a/docs/architecture-decisions/disabled-flag-evaluation.md b/docs/architecture-decisions/disabled-flag-evaluation.md index 63f634c2f..0097defa0 100644 --- a/docs/architecture-decisions/disabled-flag-evaluation.md +++ b/docs/architecture-decisions/disabled-flag-evaluation.md @@ -3,10 +3,11 @@ status: draft author: Parth Suthar (@suthar26) created: 2026-04-01 +updated: 2026-04-15 --- # Treat Disabled Flag Evaluation as Successful with Reason DISABLED -This ADR proposes changing flagd's handling of disabled flags from returning an error (`reason=ERROR`, `errorCode=FLAG_DISABLED`) to returning a successful evaluation with `reason=DISABLED` and the flag's `defaultVariant` value. A flag that does not exist in a flag set should remain a `FLAG_NOT_FOUND` error. +This ADR proposes changing flagd's handling of disabled flags from returning an error (`reason=ERROR`, `errorCode=FLAG_DISABLED`) to returning a successful evaluation with `reason=DISABLED` that defers to code defaults. A disabled flag means the flag management system has nothing to say — the application should use its code-defined default value. A flag that does not exist in a flag set should remain a `FLAG_NOT_FOUND` error. This aligns flagd with the [OpenFeature specification's resolution reasons](https://openfeature.dev/specification/types/#resolution-reason), which defines `DISABLED` as a valid resolution reason for successful evaluations. ## Background @@ -18,20 +19,21 @@ This is problematic for several reasons: 1. **Spec misalignment**: The [OpenFeature specification](https://openfeature.dev/specification/types/#resolution-reason) explicitly defines `DISABLED` as a resolution reason with the description: *"The resolved value was the result of the flag being disabled in the management system."* This implies a successful evaluation that communicates the flag's disabled state, not an error. 2. **OFREP masks the disabled state**: The OFREP response handler in `core/pkg/service/ofrep/models.go` rewrites `FLAG_DISABLED` to `FLAG_NOT_FOUND` in the structured error response (while only preserving the "is disabled" distinction in the free-text `errorDetails` string). This means OFREP clients cannot programmatically distinguish between a flag that doesn't exist and one that was intentionally disabled. 3. **Conflation of "missing" and "disabled"**: gRPC v1 maps both `FLAG_NOT_FOUND` and `FLAG_DISABLED` to `connect.CodeNotFound`. These are semantically different situations: a missing flag is a configuration or deployment error, while a disabled flag is an intentional operational decision (incident remediation, environment-specific rollout, not-yet-ready feature). -4. **Loss of observability**: When disabled flags are treated as errors, they pollute error metrics and alerting. Operators who disable a flag for legitimate reasons (ongoing incident remediation, feature not ready for an environment) see false error signals. Conversely, if they suppress these errors, they lose visibility into flag state entirely. A successful evaluation with `reason=DISABLED` would give operators a clean signal without noise. -5. **Flag set use cases**: In multi-flag-set deployments, a flag may exist in a shared definition but be disabled in certain flag sets (e.g., disabled for `staging` but enabled for `production`). Treating this as an error forces the application into error-handling paths when the flag is simply not active — a normal operational state, not an exceptional one. +4. **Non-standard error code**: `FLAG_DISABLED` is not in the [OpenFeature specification's error code list](https://openfeature.dev/specification/types/#error-code) (`PROVIDER_NOT_READY`, `FLAG_NOT_FOUND`, `PARSE_ERROR`, `TYPE_MISMATCH`, `TARGETING_KEY_MISSING`, `INVALID_CONTEXT`, `PROVIDER_FATAL`, `GENERAL`), nor is it a valid `errorCode` in the [OFREP `evaluationFailure` schema](https://github.com/open-feature/protocol/blob/main/service/openapi.yaml) +(which only allows `PARSE_ERROR`, `TARGETING_KEY_MISSING`, `INVALID_CONTEXT`, `GENERAL`). Conversely, `DISABLED` *is* already a valid `reason` in OFREP's [`evaluationSuccess` schema](https://github.com/open-feature/protocol/blob/main/service/openapi.yaml). flagd's current treatment of disabled flags as errors is a spec violation on both the OpenFeature and OFREP sides. +5. **Loss of observability**: When disabled flags are treated as errors, they pollute error metrics and alerting. Operators who disable a flag for legitimate reasons (ongoing incident remediation, feature not ready for an environment) see false error signals. Conversely, if they suppress these errors, they lose visibility into flag state entirely. A successful evaluation with `reason=DISABLED` would give operators a clean signal without noise. +6. **Flag set use cases**: In multi-flag-set deployments, a flag may exist in a shared definition but be disabled in certain flag sets (e.g., disabled for `staging` but enabled for `production`). Treating this as an error forces the application into error-handling paths when the flag is simply not active — a normal operational state, not an exceptional one. Related context: - [OpenFeature Specification - Resolution Reasons](https://openfeature.dev/specification/types/#resolution-reason) - [flagd Flag Definitions Reference](https://flagd.dev/reference/flag-definitions/) -- [ADR: Support Explicit Code Default Values](./support-code-default.md) — establishes the pattern of returning `defaultVariant` with appropriate reason codes +- [ADR: Support Explicit Code Default Values](./support-code-default.md) — establishes the field-omission pattern for code-default deferral ## Requirements - Evaluating a disabled flag must return a successful response with `reason=DISABLED` across all surfaces (gRPC v1, gRPC v2, OFREP, in-process) -- The resolved value for a disabled flag must be the flag's configured `defaultVariant` -- If the flag's `defaultVariant` is `null` (per the code-default ADR), the disabled response must defer to code defaults using the same field-omission pattern, but with `reason=DISABLED` instead of `reason=DEFAULT` +- The resolved value for a disabled flag must always defer to the application's code default — the server omits value and variant fields using the same field-omission pattern established in the [code-default ADR](./support-code-default.md), but with `reason=DISABLED` instead of `reason=DEFAULT` - Evaluating a flag key that does not exist in the store or flag set must remain a `FLAG_NOT_FOUND` error - `reason=DISABLED` must be distinct from all other reasons (`STATIC`, `DEFAULT`, `SPLIT`, `TARGETING_MATCH`, `ERROR`) and must not trigger error-handling paths in providers or SDKs - Bulk evaluation (`ResolveAll`) must include disabled flags in the response with `reason=DISABLED`, rather than silently omitting them @@ -40,52 +42,46 @@ Related context: ## Considered Options -- **Option 1: Successful evaluation with `reason=DISABLED` returning `defaultVariant`** — Disabled flags evaluate successfully, returning the `defaultVariant` value and `reason=DISABLED` -- **Option 2: Return a successful evaluation with** `reason=DEFAULT` — Treat disabled flags as if they had no targeting, collapsing the disabled state into the default reason -- **Option 3: Status quo** — Keep the current error behavior and document it as intentional divergence from the OpenFeature spec +- **Option 1: Successful evaluation with `reason=DISABLED` deferring to code defaults** — Disabled flags evaluate successfully, always deferring to the application's code-defined default value with `reason=DISABLED` +- **Option 2: Successful evaluation with `reason=DISABLED` returning `defaultVariant`** — Disabled flags evaluate successfully, returning the flag configuration's `defaultVariant` value +- **Option 3: Return a successful evaluation with** `reason=DEFAULT` — Treat disabled flags as if they had no targeting, collapsing the disabled state into the default reason +- **Option 4: Status quo** — Keep the current error behavior and document it as intentional divergence from the OpenFeature spec ## Proposal -We propose **Option 1: Successful evaluation with `reason=DISABLED` returning `defaultVariant`**. +We propose **Option 1: Successful evaluation with `reason=DISABLED` deferring to code defaults**. -When a flag exists in the store but has `state: DISABLED`, the evaluator should return a successful evaluation with the following properties: +When a flag exists in the store but has `state: DISABLED`, the evaluator should return a successful evaluation that always defers to the application's code-defined default value. A disabled flag means the flag management system is explicitly stepping aside — it has nothing to say about what the value should be. This is semantically different from returning a configured `defaultVariant`, which would still delegate the decision to the flag management system and contradict the meaning of `DISABLED`. -- **value**: The flag's `defaultVariant` value (from the flag configuration). If `defaultVariant` is `null` or absent, the value field is omitted to signal code-default deferral (see [Interaction with code defaults](#interaction-with-code-defaults-defaultvariant-null)). -- **variant**: The flag's `defaultVariant` key. If `defaultVariant` is `null` or absent, the variant field is omitted. +The response has the following properties: + +- **value**: Omitted (signals code-default deferral). The SDK/provider uses the application's code-defined default value. +- **variant**: Omitted (empty string). - **reason**: `DISABLED` - **error**: `nil` (no error) - **metadata**: The merged flag set + flag metadata (consistent with current behavior for successful evaluations) -This aligns with the OpenFeature specification's definition of `DISABLED` as a resolution reason and leverages the existing but unused `DisabledReason` constant already defined in flagd. +This aligns with the OpenFeature specification's definition of `DISABLED` as a resolution reason and leverages the existing but unused `DisabledReason` constant already defined in flagd. The field-omission pattern reuses the mechanism established in the [code-default ADR](./support-code-default.md). ### Interaction with other resolution reasons The [OpenFeature specification](https://openfeature.dev/specification/types/#resolution-reason) defines several resolution reasons. Here is how `DISABLED` interacts with each: -| Reason | Current flagd usage | Interaction with DISABLED | -| ----------------- | ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `STATIC` | Returned when a flag has no targeting rules and resolves to `defaultVariant` | When disabled, `DISABLED` takes precedence. The flag's `defaultVariant` is still returned as the value, but the reason is `DISABLED`, not `STATIC`. This distinction matters: `STATIC` tells caching providers the value is safe to cache indefinitely, while `DISABLED` signals the value may change when the flag is re-enabled. | -| `DEFAULT` | Returned when targeting rules exist but evaluate to the `defaultVariant` | When disabled, `DISABLED` takes precedence. Targeting rules are not evaluated at all for disabled flags, so `DEFAULT` (which implies targeting was attempted) is not appropriate. | -| `SPLIT` | Defined in flagd but used for pseudorandom assignment (fractional targeting) | When disabled, `DISABLED` takes precedence. Fractional targeting rules are not evaluated for disabled flags. | -| `TARGETING_MATCH` | Returned when targeting rules match and select a specific variant | Not applicable. Targeting rules are never evaluated for disabled flags. | -| `ERROR` | Currently returned for disabled flags (this is what we are changing) | `DISABLED` replaces `ERROR` for this case. `ERROR` remains the reason for genuine errors (parse errors, type mismatches, etc.). | - -**Key principle**: `DISABLED` is a terminal reason. When a flag is disabled, no targeting evaluation occurs, so reasons that describe targeting outcomes (`STATIC`, `DEFAULT`, `SPLIT`, `TARGETING_MATCH`) never apply. The evaluation short-circuits to `reason=DISABLED` with the `defaultVariant` value. - -### Interaction with code defaults (`defaultVariant: null`) - -Per the [code-default ADR](./support-code-default.md), when `defaultVariant` is `null`, the server omits the value and variant fields to signal code-default deferral. This pattern applies to disabled flags as well: - -- `defaultVariant` is a string → return the variant value with `reason=DISABLED` -- `defaultVariant` is `null` → omit value/variant fields, return `reason=DISABLED` +| Reason | Current flagd usage | Interaction with DISABLED | +| ----------------- | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `STATIC` | Returned when a flag has no targeting rules and resolves to `defaultVariant` | When disabled, `DISABLED` takes precedence. No variant is returned — the application uses its code default. `STATIC` tells caching providers the value is safe to cache indefinitely, while `DISABLED` signals the flag management system is stepping aside entirely. | +| `DEFAULT` | Returned when targeting rules exist but evaluate to the `defaultVariant` | When disabled, `DISABLED` takes precedence. Targeting rules are not evaluated at all for disabled flags, so `DEFAULT` (which implies targeting was attempted) is not appropriate. | +| `SPLIT` | Defined in flagd but used for pseudorandom assignment (fractional targeting) | When disabled, `DISABLED` takes precedence. Fractional targeting rules are not evaluated for disabled flags. | +| `TARGETING_MATCH` | Returned when targeting rules match and select a specific variant | Not applicable. Targeting rules are never evaluated for disabled flags. | +| `ERROR` | Currently returned for disabled flags (this is what we are changing) | `DISABLED` replaces `ERROR` for this case. `ERROR` remains the reason for genuine errors (parse errors, type mismatches, etc.). | -The only difference from a normal code-default response is the reason field: `DISABLED` instead of `DEFAULT`. +**Key principle**: `DISABLED` is a terminal reason. When a flag is disabled, no targeting evaluation occurs and the flag management system defers entirely to the application's code default. Reasons that describe targeting outcomes (`STATIC`, `DEFAULT`, `SPLIT`, `TARGETING_MATCH`) never apply. The evaluation short-circuits to `reason=DISABLED` with value and variant omitted. ### API changes **Evaluator core — `evaluateVariant`** (`core/pkg/evaluator/json.go`): -The `evaluateVariant` function changes from returning an error to returning a successful result. Since `flag.DefaultVariant` is already `""` when no default variant is configured, no additional branch is needed: +The `evaluateVariant` function changes from returning an error to returning a successful result with an empty variant, which signals code-default deferral: ```go // Before @@ -95,13 +91,13 @@ if flag.State == Disabled { // After if flag.State == Disabled { - return flag.DefaultVariant, flag.Variants, model.DisabledReason, metadata, nil + return "", flag.Variants, model.DisabledReason, metadata, nil } ``` **Evaluator core — `resolve[T]`** (`core/pkg/evaluator/json.go`): -The generic `resolve[T]` function currently only short-circuits for `FallbackReason` when the variant is empty. For any other reason it attempts a variant map lookup, which would produce a `TYPE_MISMATCH` error for disabled flags that defer to code defaults (`defaultVariant: null`). `resolve[T]` must treat `DisabledReason` the same way when the variant is empty: +The generic `resolve[T]` function currently only short-circuits for `FallbackReason` when the variant is empty. For any other reason it attempts a variant map lookup, which would produce a `TYPE_MISMATCH` error when the variant is empty. `resolve[T]` must treat `DisabledReason` the same way: ```go // Before @@ -112,16 +108,14 @@ if reason == model.FallbackReason { // After if reason == model.FallbackReason || reason == model.DisabledReason { - if variant == "" { - var zero T - return zero, variant, reason, metadata, nil - } + var zero T + return zero, variant, reason, metadata, nil } ``` **Bulk evaluation — `ResolveAllValues`** (`core/pkg/evaluator/json.go`): -Disabled flags are no longer skipped. They are evaluated and included in the response. Additionally, when `defaultVariant` is `null`, the type switch on `flag.Variants[flag.DefaultVariant]` hits the `default` case. For gRPC v1 requests (where `ProtoVersionKey` is set), the current `default` branch skips unknown types via `continue`. This must be adjusted so disabled flags are always included regardless of proto version: +Disabled flags are no longer skipped. They are evaluated and included in the response. Since disabled flags always return an empty variant, the type switch on the variant value hits the `default` case. For gRPC v1 requests (where `ProtoVersionKey` is set), the current `default` branch skips unknown types via `continue`. This must be adjusted so disabled flags are always included regardless of proto version: ```go // Before @@ -155,7 +149,7 @@ default: **OFREP success mapping** (`core/pkg/service/ofrep/models.go`): -The `SuccessResponseFrom` function currently rewrites `FallbackReason` to `DefaultReason` and omits value/variant fields to signal code-default deferral. When a disabled flag defers to code defaults, the same field-omission pattern must apply, but the reason must remain `DISABLED` (not be rewritten to `DEFAULT`): +The `SuccessResponseFrom` function currently rewrites `FallbackReason` to `DefaultReason` and omits value/variant fields to signal code-default deferral. Disabled flags use the same field-omission pattern, but the reason must remain `DISABLED` (not be rewritten to `DEFAULT`): ```go // Before @@ -169,33 +163,22 @@ if result.Reason == model.FallbackReason { } } -// After: handle disabled flags deferring to code defaults -if result.Reason == model.FallbackReason { - return EvaluationSuccess{ - Value: nil, - Key: result.FlagKey, - Reason: model.DefaultReason, - Variant: "", - Metadata: result.Metadata, - } -} -if result.Reason == model.DisabledReason && result.Variant == "" { +// After: handle both fallback and disabled code-default deferral +if result.Reason == model.FallbackReason || result.Reason == model.DisabledReason { return EvaluationSuccess{ Value: nil, Key: result.FlagKey, - Reason: model.DisabledReason, + Reason: lo.Ternary(result.Reason == model.FallbackReason, model.DefaultReason, model.DisabledReason), Variant: "", Metadata: result.Metadata, } } ``` -**gRPC response** (single flag evaluation): +**gRPC response** (single flag evaluation — value and variant omitted, SDK uses code default): ```json { - "value": false, - "variant": "off", "reason": "DISABLED", "metadata": { "flagSetId": "my-app", @@ -204,13 +187,11 @@ if result.Reason == model.DisabledReason && result.Variant == "" { } ``` -**OFREP response** (single flag evaluation): +**OFREP response** (single flag evaluation, HTTP 200 — previously HTTP 404): ```json { "key": "my-feature", - "value": false, - "variant": "off", "reason": "DISABLED", "metadata": { "flagSetId": "my-app" @@ -218,15 +199,13 @@ if result.Reason == model.DisabledReason && result.Variant == "" { } ``` -**OFREP bulk response** (disabled flag now included): +**OFREP bulk response** (disabled flag now included, value/variant omitted): ```json { "flags": [ { "key": "my-feature", - "value": false, - "variant": "off", "reason": "DISABLED", "metadata": {} }, @@ -241,6 +220,11 @@ if result.Reason == model.DisabledReason && result.Variant == "" { } ``` +### In-process providers + +The sync.proto payload already includes disabled flags in the `flag_configuration` JSON with `"state": "DISABLED"`. No wire-format changes are needed. Each language SDK's in-process evaluator must be updated to return `reason=DISABLED` with code-default deferral (omitted value/variant) instead of raising an error when encountering a disabled flag. +This is functionally the same change as the flagd core evaluator, replicated in each SDK. This is a coordinated rollout across all SDK in-process providers and should be tracked alongside the flagd core changes. + ### Consequences - Good, because it aligns flagd with the OpenFeature specification's definition of `DISABLED` as a resolution reason @@ -250,22 +234,25 @@ if result.Reason == model.DisabledReason && result.Variant == "" { - Good, because the existing `DisabledReason` constant is finally used as designed - Good, because it provides visibility into disabled flags in bulk evaluation responses, rather than silently omitting them - Good, because applications can distinguish between "flag doesn't exist" (a real problem) and "flag is disabled" (an intentional state) +- Good, because always deferring to code defaults gives a clear semantic: disabled means the flag management system has nothing to say - Bad, because it is a breaking change for clients that rely on `FLAG_DISABLED` error responses for control flow, alerting, or metrics +- Bad, because OFREP single-flag evaluation changes from HTTP 404 to HTTP 200, which is a breaking change for HTTP clients that branch on status codes - Bad, because including disabled flags in `ResolveAll` responses increases payload size for flag sets with many disabled flags -- Bad, because it requires coordinated updates across flagd core, all gRPC/OFREP surfaces, providers, and the testbed +- Bad, because it requires coordinated updates across flagd core, all gRPC/OFREP surfaces, in-process providers in each language SDK, and the testbed - Neutral, because the `FlagDisabledErrorCode` constant and related error-handling code can be removed (code simplification) ### Timeline -1. Update `evaluateVariant` in `core/pkg/evaluator/json.go` to return `reason=DISABLED` with `defaultVariant` instead of an error -2. Update `resolve[T]` in `core/pkg/evaluator/json.go` to handle `DisabledReason` with empty variants (avoids `TYPE_MISMATCH` when disabled flags defer to code defaults) +1. Update `evaluateVariant` in `core/pkg/evaluator/json.go` to return `reason=DISABLED` with an empty variant (code-default deferral) instead of an error +2. Update `resolve[T]` in `core/pkg/evaluator/json.go` to handle `DisabledReason` with empty variants (avoids `TYPE_MISMATCH`) 3. Remove the disabled-flag skip in `ResolveAllValues` and update the `default` branch of the type switch to include disabled flags for gRPC v1 requests -4. Update `SuccessResponseFrom` in `core/pkg/service/ofrep/models.go` to preserve `reason=DISABLED` (with field omission) when a disabled flag defers to code defaults +4. Update `SuccessResponseFrom` in `core/pkg/service/ofrep/models.go` to preserve `reason=DISABLED` (with field omission) for disabled flags deferring to code defaults 5. Remove `FlagDisabledErrorCode` handling from `errFormat`, `errFormatV2`, and `EvaluationErrorResponseFrom` 6. Update gRPC v1 service layer (`flag_evaluator_v1.go`) to handle nil values in `ResolveAll` responses for disabled flags deferring to code defaults -7. Update flagd-testbed with test cases for disabled flag evaluation across all surfaces -8. Update OpenFeature providers to recognize `DISABLED` as a non-error, non-cacheable reason -9. Update provider documentation and migration guides +7. Update each language SDK's in-process provider evaluator to return `reason=DISABLED` with code-default deferral instead of raising an error when encountering a disabled flag +8. Update flagd-testbed with test cases for disabled flag evaluation across all surfaces +9. Update OpenFeature providers to recognize `DISABLED` as a non-error, non-cacheable reason +10. Update provider documentation and migration guides ### Open questions @@ -273,7 +260,6 @@ if result.Reason == model.DisabledReason && result.Variant == "" { - How should existing dashboards and alerts that key on `FLAG_DISABLED` errors be migrated? Should we provide a deprecation period where both behaviors are available? - Does this change require a new flagd major version, or can it be introduced in a minor version with appropriate documentation given the spec alignment argument? - Should the `FlagDisabledErrorCode` constant be retained (but unused) for a deprecation period, or removed immediately? -- How should in-process providers handle the transition? They evaluate locally and would need to be updated to return `DISABLED` reason instead of throwing an error. ## More Information From 97f60c03c1e0bcd3f89d26bddfffdf4f646f2d47 Mon Sep 17 00:00:00 2001 From: Parth Suthar Date: Mon, 27 Apr 2026 12:13:57 -0400 Subject: [PATCH 3/7] add implementation steps and address comments Signed-off-by: Parth Suthar --- .../disabled-flag-evaluation.md | 48 ++++++++++++------- 1 file changed, 31 insertions(+), 17 deletions(-) diff --git a/docs/architecture-decisions/disabled-flag-evaluation.md b/docs/architecture-decisions/disabled-flag-evaluation.md index 0097defa0..275b81ff5 100644 --- a/docs/architecture-decisions/disabled-flag-evaluation.md +++ b/docs/architecture-decisions/disabled-flag-evaluation.md @@ -55,8 +55,8 @@ When a flag exists in the store but has `state: DISABLED`, the evaluator should The response has the following properties: -- **value**: Omitted (signals code-default deferral). The SDK/provider uses the application's code-defined default value. -- **variant**: Omitted (empty string). +- **value**: Omitted from the response. The SDK/provider uses the application's code-defined default value. In Go this surfaces as the type's zero value; on the wire (protobuf/JSON) the field is left unset; SDKs in languages with optional types should expose it as absent (`None`/`null`/`undefined`). +- **variant**: Omitted from the response, using the same omission semantics as `value` above. - **reason**: `DISABLED` - **error**: `nil` (no error) - **metadata**: The merged flag set + flag metadata (consistent with current behavior for successful evaluations) @@ -89,7 +89,8 @@ if flag.State == Disabled { return "", flag.Variants, model.ErrorReason, metadata, errors.New(model.FlagDisabledErrorCode) } -// After +// After — the empty string return is Go's zero value and is interpreted +// downstream as "variant omitted"; it is not a literal empty-string variant. if flag.State == Disabled { return "", flag.Variants, model.DisabledReason, metadata, nil } @@ -241,25 +242,38 @@ This is functionally the same change as the flagd core evaluator, replicated in - Bad, because it requires coordinated updates across flagd core, all gRPC/OFREP surfaces, in-process providers in each language SDK, and the testbed - Neutral, because the `FlagDisabledErrorCode` constant and related error-handling code can be removed (code simplification) -### Timeline +### Versioning and migration + +- This is a behavior-breaking change. Because flagd is pre-1.0, it is shipped as a minor-version bump and called out as breaking in the release notes — there is no dual-mode or deprecation period. +- Operators relying on `FLAG_DISABLED` error signals (in dashboards, alerts, log filters, or HTTP 404 branches) must migrate to keying on successful evaluations with `reason=DISABLED`. Migration guidance is communicated through the release notes rather than a runtime compatibility flag. +- The `FlagDisabledErrorCode` constant and its handling in `errFormat`, `errFormatV2`, and `EvaluationErrorResponseFrom` are removed outright in the same release. Retaining them serves no purpose once the evaluator no longer produces the error path, and there is no straightforward way to keep the old behavior reachable without re-introducing the spec-violating code path. + +### Implementation steps + +The work breaks down into three groups that must land together for a coherent release, but can be developed in parallel. + +**flagd core (single release, behavior-breaking minor bump):** + +1. Update `evaluateVariant` in `core/pkg/evaluator/json.go` to return `reason=DISABLED` with an omitted variant (code-default deferral) instead of an error. +2. Update `resolve[T]` in `core/pkg/evaluator/json.go` to handle `DisabledReason` with omitted variants (avoids `TYPE_MISMATCH`). +3. Remove the disabled-flag skip in `ResolveAllValues` and update the `default` branch of the type switch to include disabled flags for gRPC v1 requests. +4. Update `SuccessResponseFrom` in `core/pkg/service/ofrep/models.go` to preserve `reason=DISABLED` (with field omission) for disabled flags deferring to code defaults. +5. Update the gRPC v1 service layer (`flag_evaluator_v1.go`) to handle nil values in `ResolveAll` responses for disabled flags deferring to code defaults. +6. Remove `FlagDisabledErrorCode` handling from `errFormat`, `errFormatV2`, and `EvaluationErrorResponseFrom`. + +**Ecosystem (rolled out alongside or shortly after the flagd core release):** + +7. Update each language SDK's in-process provider evaluator to return `reason=DISABLED` with code-default deferral instead of raising an error when encountering a disabled flag. +8. Update OpenFeature providers (RPC and in-process) to recognize `DISABLED` as a non-error, non-cacheable reason. + +**Validation and documentation:** -1. Update `evaluateVariant` in `core/pkg/evaluator/json.go` to return `reason=DISABLED` with an empty variant (code-default deferral) instead of an error -2. Update `resolve[T]` in `core/pkg/evaluator/json.go` to handle `DisabledReason` with empty variants (avoids `TYPE_MISMATCH`) -3. Remove the disabled-flag skip in `ResolveAllValues` and update the `default` branch of the type switch to include disabled flags for gRPC v1 requests -4. Update `SuccessResponseFrom` in `core/pkg/service/ofrep/models.go` to preserve `reason=DISABLED` (with field omission) for disabled flags deferring to code defaults -5. Remove `FlagDisabledErrorCode` handling from `errFormat`, `errFormatV2`, and `EvaluationErrorResponseFrom` -6. Update gRPC v1 service layer (`flag_evaluator_v1.go`) to handle nil values in `ResolveAll` responses for disabled flags deferring to code defaults -7. Update each language SDK's in-process provider evaluator to return `reason=DISABLED` with code-default deferral instead of raising an error when encountering a disabled flag -8. Update flagd-testbed with test cases for disabled flag evaluation across all surfaces -9. Update OpenFeature providers to recognize `DISABLED` as a non-error, non-cacheable reason -10. Update provider documentation and migration guides +9. Update flagd-testbed with test cases for disabled flag evaluation across all surfaces (gRPC v1, gRPC v2, OFREP single, OFREP bulk, in-process). +10. Update provider documentation and call out the behavior change prominently in the flagd release notes. ### Open questions - Should bulk evaluation include an option to exclude disabled flags for clients that prefer the current behavior (smaller payloads)? -- How should existing dashboards and alerts that key on `FLAG_DISABLED` errors be migrated? Should we provide a deprecation period where both behaviors are available? -- Does this change require a new flagd major version, or can it be introduced in a minor version with appropriate documentation given the spec alignment argument? -- Should the `FlagDisabledErrorCode` constant be retained (but unused) for a deprecation period, or removed immediately? ## More Information From b07ff2966b9c13d168e06b87bb8f4feae1bf81de Mon Sep 17 00:00:00 2001 From: Parth Suthar Date: Mon, 27 Apr 2026 12:15:47 -0400 Subject: [PATCH 4/7] docs: address review feedback on disabled flag ADR - Clarify variant/value omission semantics across languages - Rename Timeline to Implementation steps grouped by phase - Add Versioning and migration section - Resolve open questions on dashboards, versioning, and FlagDisabledErrorCode removal - Fix MD029 ordered list numbering Signed-off-by: Parth Suthar --- docs/architecture-decisions/disabled-flag-evaluation.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/architecture-decisions/disabled-flag-evaluation.md b/docs/architecture-decisions/disabled-flag-evaluation.md index 275b81ff5..c3cf5aa2b 100644 --- a/docs/architecture-decisions/disabled-flag-evaluation.md +++ b/docs/architecture-decisions/disabled-flag-evaluation.md @@ -263,13 +263,13 @@ The work breaks down into three groups that must land together for a coherent re **Ecosystem (rolled out alongside or shortly after the flagd core release):** -7. Update each language SDK's in-process provider evaluator to return `reason=DISABLED` with code-default deferral instead of raising an error when encountering a disabled flag. -8. Update OpenFeature providers (RPC and in-process) to recognize `DISABLED` as a non-error, non-cacheable reason. +1. Update each language SDK's in-process provider evaluator to return `reason=DISABLED` with code-default deferral instead of raising an error when encountering a disabled flag. +2. Update OpenFeature providers (RPC and in-process) to recognize `DISABLED` as a non-error, non-cacheable reason. **Validation and documentation:** -9. Update flagd-testbed with test cases for disabled flag evaluation across all surfaces (gRPC v1, gRPC v2, OFREP single, OFREP bulk, in-process). -10. Update provider documentation and call out the behavior change prominently in the flagd release notes. +1. Update flagd-testbed with test cases for disabled flag evaluation across all surfaces (gRPC v1, gRPC v2, OFREP single, OFREP bulk, in-process). +2. Update provider documentation and call out the behavior change prominently in the flagd release notes. ### Open questions From 6ca4658eed6620d168be21c7c1a1de5937b8461d Mon Sep 17 00:00:00 2001 From: Parth Suthar Date: Tue, 28 Apr 2026 12:13:40 -0500 Subject: [PATCH 5/7] condense the ADR and remove implementation details Signed-off-by: Parth Suthar --- .../disabled-flag-evaluation.md | 279 +++--------------- 1 file changed, 42 insertions(+), 237 deletions(-) diff --git a/docs/architecture-decisions/disabled-flag-evaluation.md b/docs/architecture-decisions/disabled-flag-evaluation.md index c3cf5aa2b..4b688dc16 100644 --- a/docs/architecture-decisions/disabled-flag-evaluation.md +++ b/docs/architecture-decisions/disabled-flag-evaluation.md @@ -3,282 +3,87 @@ status: draft author: Parth Suthar (@suthar26) created: 2026-04-01 -updated: 2026-04-15 +updated: 2026-04-28 --- # Treat Disabled Flag Evaluation as Successful with Reason DISABLED -This ADR proposes changing flagd's handling of disabled flags from returning an error (`reason=ERROR`, `errorCode=FLAG_DISABLED`) to returning a successful evaluation with `reason=DISABLED` that defers to code defaults. A disabled flag means the flag management system has nothing to say — the application should use its code-defined default value. A flag that does not exist in a flag set should remain a `FLAG_NOT_FOUND` error. -This aligns flagd with the [OpenFeature specification's resolution reasons](https://openfeature.dev/specification/types/#resolution-reason), which defines `DISABLED` as a valid resolution reason for successful evaluations. +Today, evaluating a disabled flag in flagd produces an error (`reason=ERROR`, `errorCode=FLAG_DISABLED`). We propose returning a successful evaluation with `reason=DISABLED` and no value, so the calling SDK falls back to the application's code default. A flag that does not exist still produces `FLAG_NOT_FOUND`. This matches how the [OpenFeature specification](https://openfeature.dev/specification/types/#resolution-reason) defines `DISABLED`: a successful evaluation, not a failure. ## Background -flagd currently treats the evaluation of a disabled flag as an error. When a flag exists in the store but has `state: DISABLED`, the evaluator returns `reason=ERROR` with `errorCode=FLAG_DISABLED`. This error propagates through every surface — gRPC, OFREP, and in-process providers — resulting in the caller receiving an error response rather than a resolved value. +flagd's current behavior treats `state: DISABLED` as an error and surfaces that error through gRPC, OFREP, and in-process providers. Several issues follow from this. -This is problematic for several reasons: +The OpenFeature specification lists `DISABLED` as a resolution reason and describes it as *"the resolved value was the result of the flag being disabled in the management system."* Errors are described separately. Treating disabled as an error therefore conflicts with the spec. -1. **Spec misalignment**: The [OpenFeature specification](https://openfeature.dev/specification/types/#resolution-reason) explicitly defines `DISABLED` as a resolution reason with the description: *"The resolved value was the result of the flag being disabled in the management system."* This implies a successful evaluation that communicates the flag's disabled state, not an error. -2. **OFREP masks the disabled state**: The OFREP response handler in `core/pkg/service/ofrep/models.go` rewrites `FLAG_DISABLED` to `FLAG_NOT_FOUND` in the structured error response (while only preserving the "is disabled" distinction in the free-text `errorDetails` string). This means OFREP clients cannot programmatically distinguish between a flag that doesn't exist and one that was intentionally disabled. -3. **Conflation of "missing" and "disabled"**: gRPC v1 maps both `FLAG_NOT_FOUND` and `FLAG_DISABLED` to `connect.CodeNotFound`. These are semantically different situations: a missing flag is a configuration or deployment error, while a disabled flag is an intentional operational decision (incident remediation, environment-specific rollout, not-yet-ready feature). -4. **Non-standard error code**: `FLAG_DISABLED` is not in the [OpenFeature specification's error code list](https://openfeature.dev/specification/types/#error-code) (`PROVIDER_NOT_READY`, `FLAG_NOT_FOUND`, `PARSE_ERROR`, `TYPE_MISMATCH`, `TARGETING_KEY_MISSING`, `INVALID_CONTEXT`, `PROVIDER_FATAL`, `GENERAL`), nor is it a valid `errorCode` in the [OFREP `evaluationFailure` schema](https://github.com/open-feature/protocol/blob/main/service/openapi.yaml) -(which only allows `PARSE_ERROR`, `TARGETING_KEY_MISSING`, `INVALID_CONTEXT`, `GENERAL`). Conversely, `DISABLED` *is* already a valid `reason` in OFREP's [`evaluationSuccess` schema](https://github.com/open-feature/protocol/blob/main/service/openapi.yaml). flagd's current treatment of disabled flags as errors is a spec violation on both the OpenFeature and OFREP sides. -5. **Loss of observability**: When disabled flags are treated as errors, they pollute error metrics and alerting. Operators who disable a flag for legitimate reasons (ongoing incident remediation, feature not ready for an environment) see false error signals. Conversely, if they suppress these errors, they lose visibility into flag state entirely. A successful evaluation with `reason=DISABLED` would give operators a clean signal without noise. -6. **Flag set use cases**: In multi-flag-set deployments, a flag may exist in a shared definition but be disabled in certain flag sets (e.g., disabled for `staging` but enabled for `production`). Treating this as an error forces the application into error-handling paths when the flag is simply not active — a normal operational state, not an exceptional one. +`FLAG_DISABLED` is also not a valid error code anywhere it is used. It is missing from the OpenFeature error code list and from the OFREP `evaluationFailure` schema, which only allows `PARSE_ERROR`, `TARGETING_KEY_MISSING`, `INVALID_CONTEXT`, and `GENERAL`. The OFREP success schema, on the other hand, does allow `reason=DISABLED`. The current behavior violates both specs at once. -Related context: +The error path also conflates two different situations. A missing flag is usually a deployment or configuration mistake that an operator wants to know about. A disabled flag is an intentional operational state, often used during incident remediation, environment-scoped rollouts, or features that are not yet ready. Today both surface as `connect.CodeNotFound` on gRPC v1, and OFREP rewrites `FLAG_DISABLED` into `FLAG_NOT_FOUND` in its structured error response, leaving the disabled distinction visible only in a free-text field. Clients cannot reliably tell the two apart. -- [OpenFeature Specification - Resolution Reasons](https://openfeature.dev/specification/types/#resolution-reason) -- [flagd Flag Definitions Reference](https://flagd.dev/reference/flag-definitions/) -- [ADR: Support Explicit Code Default Values](./support-code-default.md) — establishes the field-omission pattern for code-default deferral +These collapsed error paths hurt observability. Operators who disable a flag deliberately see false error signals in dashboards and alerts; if they suppress those alerts, they lose visibility into flag state altogether. The same problem appears in flag-set-based deployments, where a flag may legitimately be disabled in one set and active in another, and treating that as an exception forces normal operations through error-handling code. -## Requirements - -- Evaluating a disabled flag must return a successful response with `reason=DISABLED` across all surfaces (gRPC v1, gRPC v2, OFREP, in-process) -- The resolved value for a disabled flag must always defer to the application's code default — the server omits value and variant fields using the same field-omission pattern established in the [code-default ADR](./support-code-default.md), but with `reason=DISABLED` instead of `reason=DEFAULT` -- Evaluating a flag key that does not exist in the store or flag set must remain a `FLAG_NOT_FOUND` error -- `reason=DISABLED` must be distinct from all other reasons (`STATIC`, `DEFAULT`, `SPLIT`, `TARGETING_MATCH`, `ERROR`) and must not trigger error-handling paths in providers or SDKs -- Bulk evaluation (`ResolveAll`) must include disabled flags in the response with `reason=DISABLED`, rather than silently omitting them -- Telemetry and metrics must record disabled flag evaluations as successful (non-error) with the `DISABLED` reason -- Existing flag configurations must continue to work without modification (backward compatible at the configuration level) - -## Considered Options - -- **Option 1: Successful evaluation with `reason=DISABLED` deferring to code defaults** — Disabled flags evaluate successfully, always deferring to the application's code-defined default value with `reason=DISABLED` -- **Option 2: Successful evaluation with `reason=DISABLED` returning `defaultVariant`** — Disabled flags evaluate successfully, returning the flag configuration's `defaultVariant` value -- **Option 3: Return a successful evaluation with** `reason=DEFAULT` — Treat disabled flags as if they had no targeting, collapsing the disabled state into the default reason -- **Option 4: Status quo** — Keep the current error behavior and document it as intentional divergence from the OpenFeature spec - -## Proposal - -We propose **Option 1: Successful evaluation with `reason=DISABLED` deferring to code defaults**. - -When a flag exists in the store but has `state: DISABLED`, the evaluator should return a successful evaluation that always defers to the application's code-defined default value. A disabled flag means the flag management system is explicitly stepping aside — it has nothing to say about what the value should be. This is semantically different from returning a configured `defaultVariant`, which would still delegate the decision to the flag management system and contradict the meaning of `DISABLED`. - -The response has the following properties: - -- **value**: Omitted from the response. The SDK/provider uses the application's code-defined default value. In Go this surfaces as the type's zero value; on the wire (protobuf/JSON) the field is left unset; SDKs in languages with optional types should expose it as absent (`None`/`null`/`undefined`). -- **variant**: Omitted from the response, using the same omission semantics as `value` above. -- **reason**: `DISABLED` -- **error**: `nil` (no error) -- **metadata**: The merged flag set + flag metadata (consistent with current behavior for successful evaluations) - -This aligns with the OpenFeature specification's definition of `DISABLED` as a resolution reason and leverages the existing but unused `DisabledReason` constant already defined in flagd. The field-omission pattern reuses the mechanism established in the [code-default ADR](./support-code-default.md). - -### Interaction with other resolution reasons - -The [OpenFeature specification](https://openfeature.dev/specification/types/#resolution-reason) defines several resolution reasons. Here is how `DISABLED` interacts with each: - -| Reason | Current flagd usage | Interaction with DISABLED | -| ----------------- | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `STATIC` | Returned when a flag has no targeting rules and resolves to `defaultVariant` | When disabled, `DISABLED` takes precedence. No variant is returned — the application uses its code default. `STATIC` tells caching providers the value is safe to cache indefinitely, while `DISABLED` signals the flag management system is stepping aside entirely. | -| `DEFAULT` | Returned when targeting rules exist but evaluate to the `defaultVariant` | When disabled, `DISABLED` takes precedence. Targeting rules are not evaluated at all for disabled flags, so `DEFAULT` (which implies targeting was attempted) is not appropriate. | -| `SPLIT` | Defined in flagd but used for pseudorandom assignment (fractional targeting) | When disabled, `DISABLED` takes precedence. Fractional targeting rules are not evaluated for disabled flags. | -| `TARGETING_MATCH` | Returned when targeting rules match and select a specific variant | Not applicable. Targeting rules are never evaluated for disabled flags. | -| `ERROR` | Currently returned for disabled flags (this is what we are changing) | `DISABLED` replaces `ERROR` for this case. `ERROR` remains the reason for genuine errors (parse errors, type mismatches, etc.). | +Related reading: [OpenFeature resolution reasons](https://openfeature.dev/specification/types/#resolution-reason), the [flagd flag definitions reference](https://flagd.dev/reference/flag-definitions/), and the prior [ADR on explicit code defaults](./support-code-default.md), which establishes the field-omission pattern reused below. -**Key principle**: `DISABLED` is a terminal reason. When a flag is disabled, no targeting evaluation occurs and the flag management system defers entirely to the application's code default. Reasons that describe targeting outcomes (`STATIC`, `DEFAULT`, `SPLIT`, `TARGETING_MATCH`) never apply. The evaluation short-circuits to `reason=DISABLED` with value and variant omitted. - -### API changes - -**Evaluator core — `evaluateVariant`** (`core/pkg/evaluator/json.go`): - -The `evaluateVariant` function changes from returning an error to returning a successful result with an empty variant, which signals code-default deferral: - -```go -// Before -if flag.State == Disabled { - return "", flag.Variants, model.ErrorReason, metadata, errors.New(model.FlagDisabledErrorCode) -} - -// After — the empty string return is Go's zero value and is interpreted -// downstream as "variant omitted"; it is not a literal empty-string variant. -if flag.State == Disabled { - return "", flag.Variants, model.DisabledReason, metadata, nil -} -``` - -**Evaluator core — `resolve[T]`** (`core/pkg/evaluator/json.go`): - -The generic `resolve[T]` function currently only short-circuits for `FallbackReason` when the variant is empty. For any other reason it attempts a variant map lookup, which would produce a `TYPE_MISMATCH` error when the variant is empty. `resolve[T]` must treat `DisabledReason` the same way: - -```go -// Before -if reason == model.FallbackReason { - var zero T - return zero, variant, model.FallbackReason, metadata, nil -} - -// After -if reason == model.FallbackReason || reason == model.DisabledReason { - var zero T - return zero, variant, reason, metadata, nil -} -``` - -**Bulk evaluation — `ResolveAllValues`** (`core/pkg/evaluator/json.go`): +## Requirements -Disabled flags are no longer skipped. They are evaluated and included in the response. Since disabled flags always return an empty variant, the type switch on the variant value hits the `default` case. For gRPC v1 requests (where `ProtoVersionKey` is set), the current `default` branch skips unknown types via `continue`. This must be adjusted so disabled flags are always included regardless of proto version: +A disabled flag should evaluate successfully with `reason=DISABLED` on every surface: gRPC v1, gRPC v2, OFREP, and in-process. The resolved value should follow the same field-omission pattern as the code-default ADR, so the SDK uses the application's code default; only the `reason` differs. Unknown flag keys must continue to return `FLAG_NOT_FOUND`. The `DISABLED` reason must not feed into provider or SDK error paths, and bulk evaluation must include disabled flags in the response rather than skipping them. Telemetry should record these as successful evaluations. No change to existing flag configuration files is required. -```go -// Before -if flag.State == Disabled { - continue -} +## Considered options -// After: remove the skip — disabled flags flow through normal evaluation -// and will be returned with reason=DISABLED -``` - -```go -// Before (default branch of type switch) -default: - if ctx.Value(ProtoVersionKey) == nil { - value, variant, reason, metadata, err = resolve[interface{}](...) - } else { - continue - } - -// After: disabled flags must not be skipped even for old proto versions -default: - if ctx.Value(ProtoVersionKey) == nil { - value, variant, reason, metadata, err = resolve[interface{}](...) - } else if flag.State == Disabled { - value, variant, reason, metadata, err = resolve[interface{}](...) - } else { - continue - } -``` +1. Successful evaluation with `reason=DISABLED`, value omitted so the SDK falls back to code defaults. +2. Successful evaluation with `reason=DISABLED`, returning the configured `defaultVariant` value. +3. Successful evaluation with `reason=DEFAULT`, treating disabled as a special case of "no targeting matched". +4. Keep the current error behavior and document the spec divergence. -**OFREP success mapping** (`core/pkg/service/ofrep/models.go`): +We propose option 1. Option 2 still lets the management system pick a value, which contradicts the OpenFeature description of `DISABLED` and prevents the SDK from using its real fallback path. Option 3 hides the disabled state from clients and metrics, removing the very signal that motivated the change. Option 4 leaves the OFREP and OpenFeature spec violations in place and keeps the missing-vs-disabled confusion described above. -The `SuccessResponseFrom` function currently rewrites `FallbackReason` to `DefaultReason` and omits value/variant fields to signal code-default deferral. Disabled flags use the same field-omission pattern, but the reason must remain `DISABLED` (not be rewritten to `DEFAULT`): +## Proposal -```go -// Before -if result.Reason == model.FallbackReason { - return EvaluationSuccess{ - Value: nil, - Key: result.FlagKey, - Reason: model.DefaultReason, - Variant: "", - Metadata: result.Metadata, - } -} +When a flag exists with `state: DISABLED`, the evaluator returns a successful result with no value and no variant, `reason=DISABLED`, and the usual flag and flag-set metadata. The omission of `value` and `variant` is the same mechanism used in the code-default ADR; the SDK treats omission as a signal to use the application default. Targeting rules are not evaluated, so reasons that describe targeting outcomes (`STATIC`, `DEFAULT`, `SPLIT`, `TARGETING_MATCH`) never apply to a disabled flag. `ERROR` continues to mean a real failure such as a parse error or type mismatch. -// After: handle both fallback and disabled code-default deferral -if result.Reason == model.FallbackReason || result.Reason == model.DisabledReason { - return EvaluationSuccess{ - Value: nil, - Key: result.FlagKey, - Reason: lo.Ternary(result.Reason == model.FallbackReason, model.DefaultReason, model.DisabledReason), - Variant: "", - Metadata: result.Metadata, - } -} -``` +The behavior change is uniform across surfaces. The single-flag and bulk evaluation paths both include disabled flags with `reason=DISABLED` instead of erroring or skipping them. OFREP returns a success payload rather than an error response shaped like `FLAG_NOT_FOUND`. On the wire, gRPC and OFREP omit the value and variant fields. In-process providers already receive `"state": "DISABLED"` in the sync payload, so the change there is in the per-language evaluator: it must treat that state the same way as the core flagd evaluator. The provider and core changes need to ship together so that integrators see consistent behavior. -**gRPC response** (single flag evaluation — value and variant omitted, SDK uses code default): - -```json -{ - "reason": "DISABLED", - "metadata": { - "flagSetId": "my-app", - "scope": "production" - } -} -``` - -**OFREP response** (single flag evaluation, HTTP 200 — previously HTTP 404): +A typical OFREP single-flag response looks like this. The status moves from HTTP 404 (the current `FLAG_NOT_FOUND` rewrite) to HTTP 200, since the evaluation now succeeds. ```json { "key": "my-feature", "reason": "DISABLED", - "metadata": { - "flagSetId": "my-app" - } + "metadata": { "flagSetId": "my-app" } } ``` -**OFREP bulk response** (disabled flag now included, value/variant omitted): - -```json -{ - "flags": [ - { - "key": "my-feature", - "reason": "DISABLED", - "metadata": {} - }, - { - "key": "active-feature", - "value": true, - "variant": "on", - "reason": "STATIC", - "metadata": {} - } - ] -} -``` - -### In-process providers - -The sync.proto payload already includes disabled flags in the `flag_configuration` JSON with `"state": "DISABLED"`. No wire-format changes are needed. Each language SDK's in-process evaluator must be updated to return `reason=DISABLED` with code-default deferral (omitted value/variant) instead of raising an error when encountering a disabled flag. -This is functionally the same change as the flagd core evaluator, replicated in each SDK. This is a coordinated rollout across all SDK in-process providers and should be tracked alongside the flagd core changes. - -### Consequences - -- Good, because it aligns flagd with the OpenFeature specification's definition of `DISABLED` as a resolution reason -- Good, because it eliminates the OFREP bug where `FLAG_DISABLED` is silently masked as `FLAG_NOT_FOUND` -- Good, because operators get clean observability: disabled flags appear as successful evaluations with a distinct reason, not polluting error metrics -- Good, because it enables flag-set-based workflows where disabling a flag in one environment is a normal operational state -- Good, because the existing `DisabledReason` constant is finally used as designed -- Good, because it provides visibility into disabled flags in bulk evaluation responses, rather than silently omitting them -- Good, because applications can distinguish between "flag doesn't exist" (a real problem) and "flag is disabled" (an intentional state) -- Good, because always deferring to code defaults gives a clear semantic: disabled means the flag management system has nothing to say -- Bad, because it is a breaking change for clients that rely on `FLAG_DISABLED` error responses for control flow, alerting, or metrics -- Bad, because OFREP single-flag evaluation changes from HTTP 404 to HTTP 200, which is a breaking change for HTTP clients that branch on status codes -- Bad, because including disabled flags in `ResolveAll` responses increases payload size for flag sets with many disabled flags -- Bad, because it requires coordinated updates across flagd core, all gRPC/OFREP surfaces, in-process providers in each language SDK, and the testbed -- Neutral, because the `FlagDisabledErrorCode` constant and related error-handling code can be removed (code simplification) +File-level changes are out of scope for this ADR and will be tracked in the implementation PRs. -### Versioning and migration +## Consequences -- This is a behavior-breaking change. Because flagd is pre-1.0, it is shipped as a minor-version bump and called out as breaking in the release notes — there is no dual-mode or deprecation period. -- Operators relying on `FLAG_DISABLED` error signals (in dashboards, alerts, log filters, or HTTP 404 branches) must migrate to keying on successful evaluations with `reason=DISABLED`. Migration guidance is communicated through the release notes rather than a runtime compatibility flag. -- The `FlagDisabledErrorCode` constant and its handling in `errFormat`, `errFormatV2`, and `EvaluationErrorResponseFrom` are removed outright in the same release. Retaining them serves no purpose once the evaluator no longer produces the error path, and there is no straightforward way to keep the old behavior reachable without re-introducing the spec-violating code path. +The main benefits are spec alignment with both OpenFeature and OFREP, a clear distinction between missing and disabled flags, less noisy error metrics, and visibility into disabled flags in bulk responses. Operators get a clean signal that a flag is intentionally off, and applications can apply their normal default-value logic without going through an error branch. -### Implementation steps +The main cost is that this is a breaking change. Clients that switch on `FLAG_DISABLED` in error handling, alerting, or HTTP 404 responses from OFREP single-flag evaluation will need to change. Bulk responses also grow when a flag set contains many disabled flags. The rollout has to be coordinated across the flagd core, language SDKs and providers, and the testbed. -The work breaks down into three groups that must land together for a coherent release, but can be developed in parallel. +As a side effect, the existing `FlagDisabledErrorCode` plumbing in the error formatters can be removed once the evaluator no longer produces it. -**flagd core (single release, behavior-breaking minor bump):** +## Testing -1. Update `evaluateVariant` in `core/pkg/evaluator/json.go` to return `reason=DISABLED` with an omitted variant (code-default deferral) instead of an error. -2. Update `resolve[T]` in `core/pkg/evaluator/json.go` to handle `DisabledReason` with omitted variants (avoids `TYPE_MISMATCH`). -3. Remove the disabled-flag skip in `ResolveAllValues` and update the `default` branch of the type switch to include disabled flags for gRPC v1 requests. -4. Update `SuccessResponseFrom` in `core/pkg/service/ofrep/models.go` to preserve `reason=DISABLED` (with field omission) for disabled flags deferring to code defaults. -5. Update the gRPC v1 service layer (`flag_evaluator_v1.go`) to handle nil values in `ResolveAll` responses for disabled flags deferring to code defaults. -6. Remove `FlagDisabledErrorCode` handling from `errFormat`, `errFormatV2`, and `EvaluationErrorResponseFrom`. +Coverage for this change should live in the [flagd testbed](https://github.com/open-feature/flagd-testbed) so every SDK and provider can verify behavior against the same scenarios. We need cases for single-flag and bulk evaluation on gRPC v1, gRPC v2, OFREP, and in-process, including the case where a flag is disabled in one flag set and enabled in another. -**Ecosystem (rolled out alongside or shortly after the flagd core release):** +## Versioning and migration -1. Update each language SDK's in-process provider evaluator to return `reason=DISABLED` with code-default deferral instead of raising an error when encountering a disabled flag. -2. Update OpenFeature providers (RPC and in-process) to recognize `DISABLED` as a non-error, non-cacheable reason. +flagd is pre-1.0, so this ships as a minor-version bump with the breaking change called out in the release notes rather than as a long-running compatibility mode. Operators and client authors should: -**Validation and documentation:** +- Replace `FLAG_DISABLED` error handling with checks for a successful evaluation whose reason is `DISABLED`. +- Update OFREP and HTTP clients that branched on a 404 status for disabled single-flag evaluation. +- Audit dashboards, alerts, and log parsers keyed on disabled-flag errors. -1. Update flagd-testbed with test cases for disabled flag evaluation across all surfaces (gRPC v1, gRPC v2, OFREP single, OFREP bulk, in-process). -2. Update provider documentation and call out the behavior change prominently in the flagd release notes. +The obsolete error-code paths are removed in the same release. Keeping them around does not preserve any reachable behavior once the evaluator stops producing the error. -### Open questions +## Open questions -- Should bulk evaluation include an option to exclude disabled flags for clients that prefer the current behavior (smaller payloads)? +- Should bulk evaluation expose an option to omit disabled flags, for clients that prefer smaller payloads over visibility? -## More Information +## More information -- [OpenFeature Specification - Resolution Reasons](https://openfeature.dev/specification/types/#resolution-reason) -- [OpenFeature Specification - Error Codes](https://openfeature.dev/specification/types/#error-code) — notably, `FLAG_DISABLED` is not in the spec's error code list -- [flagd Flag Definitions Reference](https://flagd.dev/reference/flag-definitions/) -- [ADR: Support Explicit Code Default Values](./support-code-default.md) -- [flagd Testbed](https://github.com/open-feature/flagd-testbed) +- [OpenFeature resolution reasons](https://openfeature.dev/specification/types/#resolution-reason) +- [OpenFeature error codes](https://openfeature.dev/specification/types/#error-code) +- [flagd flag definitions](https://flagd.dev/reference/flag-definitions/) +- [ADR: Support explicit code default values](./support-code-default.md) +- [flagd testbed](https://github.com/open-feature/flagd-testbed) From 00b2a72e8d1f794c646e0ba2f8554fe238c039bd Mon Sep 17 00:00:00 2001 From: Parth Suthar Date: Tue, 28 Apr 2026 12:21:34 -0500 Subject: [PATCH 6/7] fix markdown lint line-length violations Signed-off-by: Parth Suthar --- .../disabled-flag-evaluation.md | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/docs/architecture-decisions/disabled-flag-evaluation.md b/docs/architecture-decisions/disabled-flag-evaluation.md index 4b688dc16..a5e6ca80d 100644 --- a/docs/architecture-decisions/disabled-flag-evaluation.md +++ b/docs/architecture-decisions/disabled-flag-evaluation.md @@ -17,7 +17,11 @@ The OpenFeature specification lists `DISABLED` as a resolution reason and descri `FLAG_DISABLED` is also not a valid error code anywhere it is used. It is missing from the OpenFeature error code list and from the OFREP `evaluationFailure` schema, which only allows `PARSE_ERROR`, `TARGETING_KEY_MISSING`, `INVALID_CONTEXT`, and `GENERAL`. The OFREP success schema, on the other hand, does allow `reason=DISABLED`. The current behavior violates both specs at once. -The error path also conflates two different situations. A missing flag is usually a deployment or configuration mistake that an operator wants to know about. A disabled flag is an intentional operational state, often used during incident remediation, environment-scoped rollouts, or features that are not yet ready. Today both surface as `connect.CodeNotFound` on gRPC v1, and OFREP rewrites `FLAG_DISABLED` into `FLAG_NOT_FOUND` in its structured error response, leaving the disabled distinction visible only in a free-text field. Clients cannot reliably tell the two apart. +The error path also conflates two different situations. +A missing flag is usually a deployment or configuration mistake that an operator wants to know about. +A disabled flag is an intentional operational state, often used during incident remediation, environment-scoped rollouts, or features that are not yet ready. +Today both surface as `connect.CodeNotFound` on gRPC v1, and OFREP rewrites `FLAG_DISABLED` into `FLAG_NOT_FOUND` in its structured error response, leaving the disabled distinction visible only in a free-text field. +Clients cannot reliably tell the two apart. These collapsed error paths hurt observability. Operators who disable a flag deliberately see false error signals in dashboards and alerts; if they suppress those alerts, they lose visibility into flag state altogether. The same problem appears in flag-set-based deployments, where a flag may legitimately be disabled in one set and active in another, and treating that as an exception forces normal operations through error-handling code. @@ -25,7 +29,12 @@ Related reading: [OpenFeature resolution reasons](https://openfeature.dev/specif ## Requirements -A disabled flag should evaluate successfully with `reason=DISABLED` on every surface: gRPC v1, gRPC v2, OFREP, and in-process. The resolved value should follow the same field-omission pattern as the code-default ADR, so the SDK uses the application's code default; only the `reason` differs. Unknown flag keys must continue to return `FLAG_NOT_FOUND`. The `DISABLED` reason must not feed into provider or SDK error paths, and bulk evaluation must include disabled flags in the response rather than skipping them. Telemetry should record these as successful evaluations. No change to existing flag configuration files is required. +A disabled flag should evaluate successfully with `reason=DISABLED` on every surface: gRPC v1, gRPC v2, OFREP, and in-process. +The resolved value should follow the same field-omission pattern as the code-default ADR, so the SDK uses the application's code default; only the `reason` differs. +Unknown flag keys must continue to return `FLAG_NOT_FOUND`. +The `DISABLED` reason must not feed into provider or SDK error paths, and bulk evaluation must include disabled flags in the response rather than skipping them. +Telemetry should record these as successful evaluations. +No change to existing flag configuration files is required. ## Considered options @@ -38,9 +47,17 @@ We propose option 1. Option 2 still lets the management system pick a value, whi ## Proposal -When a flag exists with `state: DISABLED`, the evaluator returns a successful result with no value and no variant, `reason=DISABLED`, and the usual flag and flag-set metadata. The omission of `value` and `variant` is the same mechanism used in the code-default ADR; the SDK treats omission as a signal to use the application default. Targeting rules are not evaluated, so reasons that describe targeting outcomes (`STATIC`, `DEFAULT`, `SPLIT`, `TARGETING_MATCH`) never apply to a disabled flag. `ERROR` continues to mean a real failure such as a parse error or type mismatch. - -The behavior change is uniform across surfaces. The single-flag and bulk evaluation paths both include disabled flags with `reason=DISABLED` instead of erroring or skipping them. OFREP returns a success payload rather than an error response shaped like `FLAG_NOT_FOUND`. On the wire, gRPC and OFREP omit the value and variant fields. In-process providers already receive `"state": "DISABLED"` in the sync payload, so the change there is in the per-language evaluator: it must treat that state the same way as the core flagd evaluator. The provider and core changes need to ship together so that integrators see consistent behavior. +When a flag exists with `state: DISABLED`, the evaluator returns a successful result with no value and no variant, `reason=DISABLED`, and the usual flag and flag-set metadata. +The omission of `value` and `variant` is the same mechanism used in the code-default ADR; the SDK treats omission as a signal to use the application default. +Targeting rules are not evaluated, so reasons that describe targeting outcomes (`STATIC`, `DEFAULT`, `SPLIT`, `TARGETING_MATCH`) never apply to a disabled flag. +`ERROR` continues to mean a real failure such as a parse error or type mismatch. + +The behavior change is uniform across surfaces. +The single-flag and bulk evaluation paths both include disabled flags with `reason=DISABLED` instead of erroring or skipping them. +OFREP returns a success payload rather than an error response shaped like `FLAG_NOT_FOUND`. +On the wire, gRPC and OFREP omit the value and variant fields. +In-process providers already receive `"state": "DISABLED"` in the sync payload, so the change there is in the per-language evaluator: it must treat that state the same way as the core flagd evaluator. +The provider and core changes need to ship together so that integrators see consistent behavior. A typical OFREP single-flag response looks like this. The status moves from HTTP 404 (the current `FLAG_NOT_FOUND` rewrite) to HTTP 200, since the evaluation now succeeds. From 7bce251d5fb1e555ea20115822882f90b2da1654 Mon Sep 17 00:00:00 2001 From: Michael Beemer Date: Wed, 20 May 2026 13:14:26 -0400 Subject: [PATCH 7/7] Update docs/architecture-decisions/disabled-flag-evaluation.md Signed-off-by: Michael Beemer --- docs/architecture-decisions/disabled-flag-evaluation.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/architecture-decisions/disabled-flag-evaluation.md b/docs/architecture-decisions/disabled-flag-evaluation.md index a5e6ca80d..e4ec8f10f 100644 --- a/docs/architecture-decisions/disabled-flag-evaluation.md +++ b/docs/architecture-decisions/disabled-flag-evaluation.md @@ -1,6 +1,6 @@ --- # Valid statuses: draft | proposed | rejected | accepted | superseded -status: draft +status: accepted author: Parth Suthar (@suthar26) created: 2026-04-01 updated: 2026-04-28