
Optionality
OpenFeature Ports Your Code, Not Your Flag Data
OpenFeature standardises the flag call, not the flag definitions. What actually moves when you swap self-hosted Flagsmith for Unleash, and what does not.
A team can own its clusters, its pipelines and its databases and still hand a stranger the switch that changes production behaviour without a deploy. In our engagements the flag plane is routinely the last thing repatriated, because it looks like a convenience rather than a control surface.
The Last Control Plane You Still Rent
A feature flag is not a deployment. Deployment-time promotion decides which build runs — Argo Rollouts shifts traffic to a new ReplicaSet and aborts on analysis. A flag decides what an already-running build does, per request, per user. Two control surfaces, and only one tends to be self-hosted.
The runtime one is a live kill-switch on production behaviour: whoever holds it changes what your service does without touching your registry, cluster or pipeline. When that party is a SaaS vendor, the switch sits outside your trust boundary — and so does the evaluation context you send on every call: a user identifier plus whatever your targeting rules need, such as plan tier, region or account age.
The dependency is sharper than it looks, because the specification's semantics make the failure silent. OpenFeature is explicit: "In the case of any error during flag evaluation, the default value will be returned, so give consideration to your default values!" An unreachable backend raises nothing in your request path. It quietly reverts every gated behaviour in the fleet to whatever a developer typed as the default argument, months ago.
What OpenFeature Standardises — And What It Does Not
OpenFeature is a specification, not a product. Its own introduction says so: "OpenFeature is an open specification that provides a vendor-agnostic, community-driven API for feature flagging that works with your favorite feature flag management tool." The tool stays yours to run; the specification gives you a stable shape for the code that calls it.
It is a CNCF project at the Incubating maturity level — accepted on 17 June 2022, moved to Incubating on 21 November 2023, not graduated. Do not read the version number as instability either. As of August 2026 the most recent release is v0.9.0, published on 29 July 2026; it carries breaking changes, and the same release stabilised the evaluation and provider sections while hardening hooks, events and context.
Four things are in scope: the evaluation API (typed resolution of booleans, strings, numbers and structures, each with a caller-supplied default), the evaluation context, the hook chain that wraps every evaluation, and the provider — which the docs call "the translation layer between the evaluation API and the flag management system in use", responsible for mapping the API's arguments "to their equivalent representation in the associated flag management system".
The provider interface is specified tightly enough to be portable. It "MUST define methods to resolve flag values, with parameters flag key (string, required), default value (boolean | number | string | structure, required) and evaluation context (optional), which returns a resolution details structure." It is the seam a migration cuts along:
package main
import (
"context"
"fmt"
"log"
"github.com/open-feature/go-sdk/openfeature"
)
func main() {
// The only line a backend migration touches. Community providers live in
// go-sdk-contrib, outside the SDK's support surface — vendor-written or
// not, case by case. Signatures differ — check each one:
// providers/flagd v0.6.0 — NewProvider(WithInProcessResolver())
// providers/flagsmith v0.1.6 — .../flagsmith/pkg
// providers/unleash v0.1.1-alpha — alpha; read it before you ship
// Check the error — discarding it blocks without learning that
// initialisation failed, which is the silent-defaults window itself.
if err := openfeature.SetProviderAndWait(openfeature.NoopProvider{}); err != nil {
log.Fatalf("provider init failed: %v", err)
}
client := openfeature.NewDefaultClient()
// Boolean returns the value alone: on any evaluation error it swallows the
// error and hands back this default, which is why the default is your
// production behaviour during a backend outage. Use BooleanValue, which
// returns (bool, error), when the caller needs to know it failed.
v2Enabled := client.Boolean(
context.TODO(), "checkout-v2", false, openfeature.EvaluationContext{},
)
fmt.Println("checkout-v2:", v2Enabled)
}Not a novel observation — ConfigCat published the same API-versus-dashboard distinction in May 2026, with the residual work a migration leaves behind. What follows from it is where the money is.
Two Backends, Two Licence Trajectories
With the call site standardised, choosing a backend becomes a licence and capability question. The two obvious open-source candidates have moved in different directions, and neither movement shows up in a feature matrix.
Flagsmith ships its core under the BSD 3-Clause licence. The boundary is identity: its own FAQ states that "while the majority of the code is BSD 3-Clause, the Enterprise Edition (EE) features—such as Role-Based Access Control (RBAC), SAML/SSO, and certain database integrations—are closed source and require a license." RBAC is what stops any engineer with a login toggling a payment path in production. Self-hosting is otherwise well trodden:
# Flagsmith's own Kubernetes quick start. The docs present this as a
# testing/dev install — read the production notes before you trust it.
helm repo add flagsmith https://flagsmith.github.io/flagsmith-charts/
helm repo update
helm install -n flagsmith --create-namespace flagsmith flagsmith/flagsmith
# RBAC, SAML and SSO are Enterprise Edition. Confirm what your chart values
# actually enable before you write "access control" on a compliance form.
kubectl -n flagsmith get podsUnleash moved the other way. Three facts to price. First, licence: "Source code is licensed under AGPLv3 from v8.0.0 onwards, and under Apache 2.0 in earlier versions. Official Docker images are licensed under Apache 2.0." Unleash's upgrade documentation adds the operational qualifier — an unmodified official image is unaffected. AGPLv3 is OSI-approved open source and the change is legitimate; what it is not is a no-op for a legal review that signed off on Apache 2.0. Route it through counsel, not an engineering assumption.
Second, capacity. Unleash's resource-limits page gives the Open Source edition 1 project and 2 environments, against 500 and 50 on Enterprise, and states that "the only limits that can't be changed, are projects and environments for Open Source instances." Every other limit is an environment variable; these two are not — two environments is production plus one, for the whole organisation. Unleash is candid: the OSS edition "is designed for small-scale deployments" and "for production workloads, we recommend Unleash Enterprise."
Third, a dated end-of-life. Open-source Unleash Edge — the caching and proxy layer, not Unleash itself — is "Deprecated in favor of the Enterprise edition. Long-term support starts December 10, 2025. End-of-life is December 31, 2026." If your topology depends on OSS Edge for SDK fan-out, that is a dated migration on the roadmap, not a rumour.
Evaluation Topology: RPC or In-Process
Where evaluation happens is a separate decision from which backend holds the flags, and it decides whether your evaluation context leaves the pod. Take flagd, the Apache-2.0 flag daemon maintained in the OpenFeature organisation, whose documentation names exactly two categories: "flagd architectures fall into two broad categories: those where the evaluation engine is deployed in a standalone process to which the client application connects (RPC), and those where the evaluation engine is embedded into the client application (in-process)."
In the RPC category, "flagd RPC providers use HTTP or gRPC to request flag evaluations from flagd. The request payload contains the flag key identifying the flag to be evaluated, as well as the relevant evaluation context." You pay a request per evaluation. flagd's own documentation puts that at "typically <10ms for an evaluation" — its figure for its own engine, not an SLA for your network path. Budget your own hop.
In the in-process category, "in-process deployments embed the flagd evaluation engine directly into the client application through the use of an in-process provider. The in-process provider is connected via the sync protocol to an implementing gRPC service that provides the flag definitions." The benefit is blunt: "no I/O overhead for flag evaluations, since no inter-process communication is required."
A pod-local sidecar is not a third category. It is a placement of RPC — one flagd container per pod that the application dials over loopback, so the evaluation context never crosses the pod boundary. Placement is not isolation: flagd has no bind-address flag, so the sidecar also listens on the pod IP, and containerPort documents rather than restricts. A NetworkPolicy is what closes that boundary. Its failure mode is a dead loopback endpoint and a pod-coupled lifecycle, not a stale rule set. Plain apps/v1, no operator, no CRDs:
apiVersion: apps/v1
kind: Deployment
metadata:
name: checkout
namespace: payments
spec:
replicas: 3
selector:
matchLabels:
app: checkout
template:
metadata:
labels:
app: checkout
spec:
containers:
- name: checkout
image: registry.internal.example.com/checkout:1.42.0
env:
# Our application's own setting. Loopback only, so the
# evaluation context never leaves the pod.
- name: CHECKOUT_OFREP_BASE_URL
value: "http://127.0.0.1:8016"
ports:
- containerPort: 8080
- name: flagd
image: ghcr.io/open-feature/flagd:v0.16.2
args: ["start", "--uri", "file:/etc/flagd/flags.json", "--ofrep-port", "8016"]
ports:
- containerPort: 8016
name: ofrep
volumeMounts:
- name: flag-definitions
mountPath: /etc/flagd
readOnly: true
readinessProbe:
httpGet:
path: /readyz
port: 8014
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
runAsNonRoot: true
volumes:
- name: flag-definitions
configMap:
name: checkout-flagsThe transport there is OFREP, the OpenFeature Remote Evaluation Protocol — "an API specification for feature flagging that enables vendor-agnostic communication between applications and flag management systems" and, in its own words, "a protocol, not a provider." It is the portability bet at the wire level: a backend that speaks OFREP needs no bespoke provider. Treat it as a bet — flagd's OFREP reference page carries an EXPERIMENTAL badge, and its mature RPC transport remains gRPC via evaluation.proto. The service "starts on port 8016 by default and this can be changed using startup flag --ofrep-port":
# The application image is distroless, so exec has no curl. An ephemeral
# container joins the pod's network namespace and can dial the sidecar:
POD=$(kubectl -n payments get pod -l app=checkout -o jsonpath='{.items[0].metadata.name}')
kubectl -n payments debug -it "$POD" --image=curlimages/curl -- curl -sS -X POST http://127.0.0.1:8016/ofrep/v1/evaluate/flags/checkout-v2 -H 'Content-Type: application/json' -d '{"context":{"targetingKey":"user-1029","email":"ada@example.com"}}'
# checkout-v2 below targets email, so that address resolves TARGETING_MATCH,
# variant "on", value true; an address outside the domain resolves off.
# Connection refused is the real failure mode: the application falls back to
# its compiled-in defaults, silently.Failure Modes That Bite in Production
Five, in the order they tend to be discovered.
- The default is your outage behaviour. Any evaluation error returns the caller-supplied default, so a dead backend does not fail the request — it changes the product. Write defaults as the safe state, and review them as configuration.
- The initialisation race. Evaluations issued before the provider finishes initialising resolve to defaults and look normal in logs.
SetProviderAndWaitexists for this: register, block, check the error, then serve traffic. A readiness probe that goes green first hands real users a window of default-valued traffic. - A stale rule set, in-process only. If the gRPC sync stream feeding an embedded engine breaks, the engine keeps evaluating the last definitions it received. Every evaluation succeeds, latency is perfect, the answers are yesterday's. Alert on sync-stream age, not evaluation errors.
- Hook ordering. Hooks "MUST be executed 'stack-wise' with respect to flag resolution, prioritizing increasing specificity (API, Client, Invocation, Provider) first, and the order in which they were added second", then run in reverse after resolution. A validation hook at API level and a mutation hook at invocation level do not compose the way the registration reads.
- Cohorts are not portable. Unleash's gradual rollout "uses a normalized MurmurHash of a user's unique ID, ensuring consistent and random feature distribution." Change the backend — or which attribute is the unique ID — and the same 10% is a different 10%, mid-experiment.
Four of the five are invisible to a health check. That is the argument for building the evidence plane before you need it.
Audit-Grade Rigour: Hooks as the Evidence Plane
"Who turned that on, for whom, and when" is an audit question long before it is a debugging one. The backend's own change log answers half: when the rule changed, not what each request resolved to. The evaluation record is the evidence, and the specification has the right place to emit it.
It requires that "the finally hook MUST run after the before, after, and error stages" and that "it accepts a hook context (required), evaluation details (required) and hook hints (optional)." That is what makes it a sound audit sink: it runs on the failure path as well as the success path and is handed the resolution details either way, so evaluations that quietly returned a default are recorded rather than missing.
import type {
EvaluationDetails,
FlagValue,
Hook,
HookContext,
} from "@openfeature/server-sdk";
/**
* One structured record per evaluation, including the failed ones.
* Written against the specification's hook stages, so it survives a
* backend swap. Checked against @openfeature/server-sdk 1.23.0.
*/
export const auditHook: Hook = {
finally(
hookContext: Readonly<HookContext>,
details: EvaluationDetails<FlagValue>,
): void {
process.stdout.write(
JSON.stringify({
event: "flag.evaluation",
flag_key: details.flagKey,
// Never log the whole evaluation context: it is user data.
subject: hookContext.context.targetingKey ?? "anonymous",
resolved: details.value,
variant: details.variant ?? null,
reason: details.reason ?? null,
error_code: details.errorCode ?? null,
// Not value === defaultValue: a healthy resolution of the off
// variant equals it too. A fallback is an error outcome.
fell_back: details.reason === "ERROR" || details.errorCode != null,
}) + "\n",
);
},
};Ship those records to a SIEM you operate — the pipeline that already carries your Kubernetes audit log — and the evidence trail stops being a vendor feature you rent. Two disciplines make it hold up: log the targeting key and never the whole evaluation context, which is user data; and judge fallbacks by reason and errorCode, not by the resolved value, since a healthy default-variant hit and a dead backend return the same one. That is what separates a deliberate rollout from a silent fallback in the record.
The Exit Ramp OpenFeature Does Not Give You
Here is the honest accounting. Above the portability boundary, the swap is free. Below it, nothing is standardised at all.
Flag definitions are the clearest case. flagd's are JSON documents against its own schema — still at version v0, with a required top-level flags property — and the targeting expression is a JsonLogic-style tree with no equivalent in Unleash's strategy-and-constraint model:
{
"$schema": "https://flagd.dev/schema/v0/flags.json",
"flags": {
"checkout-v2": {
"state": "ENABLED",
"variants": { "on": true, "off": false },
"defaultVariant": "off",
"targeting": {
"if": [{ "ends_with": [{ "var": "email" }, "@example.com"] }, "on", "off"]
},
"metadata": { "owner": "payments", "retire-after": "2026-11-30" }
}
},
"metadata": { "flagSetId": "checkout" }
}There is no standard interchange format for flag definitions, targeting rules or segments, so a migration is a hand-rewrite plus a re-verification — and the re-verification is the part teams skip. Vendor explainers publish much the same residual list; none of them prove the rewrite equivalent before it ships.
Write the equivalence down as a fixture and make it a test. The contract is small: a matrix of evaluation contexts, a list of flag keys, the variant each pair must resolve to. Run it through both providers behind the same client and diff — the migration becomes decidable, not a judgement call.
{
"contexts": [
{ "id": "eu-staff", "targetingKey": "user-1029", "email": "ada@example.com", "region": "eu" },
{ "id": "in-external", "targetingKey": "user-7741", "email": "ravi@example.net", "region": "in" },
{ "id": "anon", "targetingKey": "session-0004", "region": "us" }
],
"flags": ["checkout-v2", "risk-engine-v3", "pricing-experiment"],
"expect": {
"eu-staff": { "checkout-v2": "on", "risk-engine-v3": "on", "pricing-experiment": "control" },
"in-external": { "checkout-v2": "off", "risk-engine-v3": "on", "pricing-experiment": "variant-b" },
"anon": { "checkout-v2": "off", "risk-engine-v3": "off", "pricing-experiment": "control" }
}
}Percentage rollouts will not survive that diff: different backends hash differently, so cohort membership genuinely changes. Migrate those at 0% or 100% and re-ramp on the far side. It is the same exit-cost arithmetic any dependency deserves, and the same lesson OpenTofu's migration taught about registries: the SDK is never the hard part.
The Long Game: Flags Are Debt With a Half-Life
A flag plane that nobody retires becomes a second configuration system — no schema, no owner, no review — and it outlives the people who created it. The failure is slow enough that it never gets prioritised, which is why it needs a mechanical answer rather than a cultural one.
Three practices carry the weight. Put the owner and a retirement date in the flag's own metadata — flagd's format carries an arbitrary metadata object per flag, making expiry queryable, not a ticket comment. Drive removal from the evaluation telemetry the audit hook already produces: a flag that has resolved to the same variant for a quarter is not a flag, it is a constant. And make both properties of the paved road, like golden paths that are enforced, not recommended.
Then keep the definitions in Git: a ConfigMap of them is an ordinary GitOps artefact, so the flag plane inherits the review, history and rollback you already run across your clusters — inside the same trust boundary as everything it can change.
Owning this plane will not be the most impressive thing your platform does — a small service, a mounted JSON file, a hook. But it is the difference between a production kill-switch you operate and one you rent, and OpenFeature keeps that choice reversible for the decade the system has to last.
§FAQ/Common questions
Frequently asked
Does OpenFeature let me switch feature flag vendors without a migration?
It removes one half of the migration and leaves the other. OpenFeature standardises the application side: the typed evaluation API, the evaluation context, the hook chain and the provider interface. Swapping backends replaces the provider object and one registration line, so call sites, hooks and telemetry are untouched. What OpenFeature deliberately does not standardise is the flag itself — the definition format, the targeting grammar, the segment model, the stickiness and rollout hashing, and the change-request workflow are all specific to each backend, and there is no interchange format between them. Budget a hand-rewrite of your flag data plus a re-verification pass, and treat the SDK portion as approximately free.
Is OpenFeature production-ready given it is still on a 0.x specification version?
The version number is misleading here. OpenFeature is a CNCF project at the Incubating maturity level, accepted on 17 June 2022 and moved to Incubating on 21 November 2023. As of August 2026 the most recent specification release is v0.9.0, published on 29 July 2026. That release does carry breaking changes, but it is also the release that stabilised the evaluation and provider sections while hardening hooks, events and context — so the parts most applications write against are the parts explicitly marked stable. Pin your SDK version, read the release notes before upgrading, and judge stability from the section badges rather than the tag.
Flagsmith or Unleash for self-hosted feature flags in 2026?
Decide it on licence and capability boundaries rather than a feature matrix, because the two fail a sovereignty requirement in different places. Flagsmith's core is BSD 3-Clause, but its own FAQ states that Enterprise Edition features — RBAC, SAML/SSO and certain database integrations — are closed source and require a licence, so access control over your flags is the boundary of the open edition. Unleash's source is AGPLv3 from v8.0.0 onwards (Apache 2.0 before that) with official Docker images still Apache 2.0, and its Open Source edition is capped at 1 project and 2 environments — the only two resource limits Unleash says cannot be changed on OSS. Unleash's own documentation recommends Enterprise for production workloads. If paid identity is the blocker, Flagsmith; if the AGPL relicence is the blocker and two environments are enough, Unleash.
What is the difference between flagd RPC and in-process evaluation?
They are the only two architectural categories flagd defines. In RPC evaluation the engine runs in a standalone flagd process and your application's RPC provider requests evaluations over HTTP or gRPC, sending the flag key and the evaluation context in the payload; flagd's documentation quotes a typical evaluation at under 10ms, which is its figure for its own engine rather than an SLA for your network path. In in-process evaluation the engine is embedded in the application through an in-process provider fed by a gRPC sync stream, so evaluation costs no I/O at all — at the price of a replication problem, because a broken sync stream leaves the engine serving a stale rule set with no visible errors. A pod-local sidecar is a placement of RPC, not a third category: the application dials it over loopback, so the evaluation context never leaves the pod. Note that flagd has no bind-address flag, so the sidecar still listens on the pod IP — a NetworkPolicy, not the sidecar placement, is what enforces that boundary.
When does open-source Unleash Edge reach end of life?
Unleash's availability documentation states that open-source Unleash Edge is deprecated in favour of the Enterprise edition, that long-term support started on 10 December 2025, and that end-of-life is 31 December 2026. Read the scope carefully: the deprecation applies to Unleash Edge — the caching and proxy layer used to fan out to SDKs — and not to Unleash OSS itself, which continues under its own licence and resource limits. If your self-hosted topology depends on OSS Edge, that is a dated migration to put on the roadmap rather than a surprise to discover after the date passes.
Further reading
- Argo Rollouts: evidence-gated progressive delivery
- Vendor lock-in: pricing your exit as a number
- OpenTofu migration: the registry is the hard part
- Golden paths need enforcement, not documentation
- Kubernetes audit logs into a SIEM you operate
- Multi-cluster GitOps at 100+ clusters
- Platform and delivery engineering
Executive Briefing
Thirty minutes to clarify your infrastructure risk
Walk us through your vendor footprint and regulatory constraints. We will tell you honestly where sovereignty creates leverage — and where it does not. No pitch deck. No obligation.