Skip to content

docs(flags): propose typed flag values and evaluation details - #104

Draft
andehen wants to merge 3 commits into
mainfrom
docs/typed-flag-values
Draft

andehen wants to merge 3 commits into
mainfrom
docs/typed-flag-values

Conversation

@andehen

@andehen andehen commented Oct 7, 2026 •

Copy link
Copy Markdown

Summary

This adds the OpenSpec change add-typed-flag-values. It defines the SDK API for typed feature flag values and evaluation details, read from the /flags?v=3 response: what an application developer calls, what comes back, and what the SDK does behind it.

This is a starting point for iteration, not a finished contract. Its main job is to make the problem areas visible so we can settle them. Where the inputs leave a choice open, the change takes an assumption, writes it into the spec, and lists it below with the main alternative.

Inputs:

Files:

  • openspec/changes/add-typed-flag-values/proposal.md
  • openspec/changes/add-typed-flag-values/design.md: the decisions in more detail, scored on developer experience, OpenFeature compatibility and migration
  • openspec/changes/add-typed-flag-values/tasks.md
  • openspec/changes/add-typed-flag-values/specs/typed-flag-values/spec.md (new capability)
  • openspec/changes/add-typed-flag-values/specs/evaluate-flags/spec.md (delta)
  • openspec/changes/add-typed-flag-values/specs/feature-flag-called-tracker/spec.md (delta)

Decisions to settle

Each decision has the assumption the spec takes, the main alternative, and the reason. The D numbers match the headings in design.md, which has the full reasoning.

  • D1. Native typed accessors or OpenFeature only
    • Assumption: every SDK gets native typed accessors; PostHog's OpenFeature providers wrap them.
    • Alternative: offer typed values only through OpenFeature providers.
    • Why: most users do not use OpenFeature, providers exist in four languages only, and the native API keeps PostHog behaviour OpenFeature has no place for ($feature_flag_called on access, snapshot reuse in capture).
  • D2. Where the accessors live
    • Assumption: server SDKs put them on the evaluateFlags() snapshot only, with no typed single-flag client methods. Client SDKs put them on the client and read the loaded flags.
    • Alternative: also add client.getBooleanValue(key, default, distinctId, options) and the rest to server clients.
    • Why: the snapshot is already the preferred server API and keeps one evaluation per request; a provider can call evaluateFlags for one key at the same cost.
  • D3. Caller default versus flag default
    • Assumption: legacy enablement keeps "a present record wins over the caller default", and a null value counts as present (it renders as false). The typed boolean accessor returns a present false as is (the flag default wins), and returns the caller default for null, failed and missing flags. Non-boolean accessors treat false and null as "no value": caller default, no error.
    • Alternative: let isEnabled(key, { defaultValue }) treat a v3 null as "no value".
    • Why: legacy results must not depend on the response version, and older servers send a null value as enabled: false. Visible consequence: for a null flag default, isEnabled(k, { defaultValue: true }) is false but getBooleanValue(k, true) is true.
  • D4. The snapshot's value projection for number and object flags
    • Assumption: getFlag and enablement keep the legacy rendering. Number and object values are true, and the value is available through the payload accessor and the typed accessors. null is false. No new untyped getValue.
    • Alternative: let getFlag return the typed value, or add an untyped accessor.
    • Why: getFlag is typed boolean | string | undefined in several SDKs and feeds $feature/<key>, $active_feature_flags and bootstrap export.
  • D5. Shape of the details object
    • Assumption: key, value, variant, reason (OpenFeature reason string), error_code (OpenFeature codes only, see D6), error_message, flag_metadata. flag_metadata is a scalar map with the contract's keys (id, version, config_version, reason_code, condition_index, rule_type, rule_id, variant_key), using wire names in every language. It leaves out the payload and the reason description. variant is left out when an error code is set.
    • Alternative: the #1031 shape (a structured PostHog reason and a typed metadata object); or flagKey, camelCase metadata keys, or PostHog-specific error codes.
    • Why: a provider can copy the object as it is, the fine-grained code survives as reason_code, and every SDK has one shape.
  • D6. Error codes, and the OpenFeature reason for version 1 flags (the contract maps only config version 2 codes)
    • Assumption: OpenFeature error codes only (FLAG_NOT_FOUND, PARSE_ERROR, TYPE_MISMATCH, INVALID_CONTEXT, PROVIDER_NOT_READY, GENERAL); the PostHog code stays in flag_metadata.reason_code. For version 1 flags, a failed record is ERROR, flag_disabled is DISABLED, a true or string value is TARGETING_MATCH, anything else DEFAULT. This is what the providers report today. An unknown config version 2 code is UNKNOWN.
    • Alternative: PostHog error codes such as DEPENDENCY_ERROR, or a per-code table for version 1 reason codes, added to the contract.
    • Why: extra error codes would not survive a provider, there is no table to maintain, and providers keep their current reasons.
  • D7. What typed reads put in $feature_flag_called
    • Assumption: the same event, tracker and dedupe key as a legacy read. $feature_flag_response is the legacy rendering (true for number and object values, false for null), never the caller default. A type mismatch is not reported as $feature_flag_error. Attribution properties and any dedupe-key change come with $experiment_exposure.
    • Alternative: send the value the accessor returned, and add config version and rule context to the dedupe key now.
    • Why: users can mix typed and legacy reads without duplicate events or broken insights, and the public event schema allows only a boolean or a non-empty string in $feature_flag_response.
  • D8. Older servers, and how to tell you are on one
    • Assumption: follow the contract's reader rule (value is variant ?? enabled, config version 1, no rule context). Typed number and object reads return the caller default with TYPE_MISMATCH, and the error message names the likely cause. The SDK logs one diagnostic per client. No public field marks the state.
    • Alternative: a public marker (a response version in metadata or on the snapshot), reason STALE, or rebuilding values from payloads.
    • Why: the state is temporary and a public field would outlive it; the symptom shows up in error_code and error_message; rebuilding values from payloads would be coercion.
  • D9. Payloads
    • Assumption: the payload accessor stays and keeps its role for older flags. It returns version 1 payloads as today and config version 2 number and object values (the SDK supplies them, because v3 sends metadata.payload: null for those flags). Users who model config as variant plus payload keep their code. To move, they recreate the flag as an object flag and read it with getObjectValue; details.variant still names the arm.
    • Alternative: make the payload accessor legacy-only (nothing for config version 2 flags), or let typed object reads fall back to a version 1 payload.
    • Why: legacy readers keep working when a flag becomes a number or object flag, and typed reads never coerce.
  • D10. Naming per language, and getFeatureFlagResult
    • Assumption: OpenFeature names everywhere, on the snapshot and on the client root (posthog.getStringValue('banner-copy', 'Welcome')). Types are boolean, string, number and object, plus integer and float accessors where the language separates them. getFeatureFlagResult stays separate and unchanged; get<Type>Details is the details form.
    • Alternative: flag-qualified client names (posthog.getStringFlag(...)), or growing FeatureFlagResult into the details form.
    • Why: one vocabulary across SDKs, docs and OpenFeature. Growing FeatureFlagResult would give one object two value channels.
  • D11. No coercion for existing (config version 1) flags
    • Assumption: getBooleanValue on a multivariate flag is TYPE_MISMATCH, while isFeatureEnabled returns true. Typed accessors do not parse variant strings and do not fall back to payloads.
    • Alternative: coerce version 1 flags like today's OpenFeature providers.
    • Why: one rule for every flag, as OpenFeature asks, and code keeps working when a flag moves to config version 2.
  • D12. Number and object values
    • Assumption: numbers are binary64. Integer accessors reject fractional or out-of-range numbers. Object accessors take JSON objects only, not top-level arrays. A returned object cannot be used to change the snapshot.
    • Alternative: accept arrays as OpenFeature structures.
    • Why: the config schema admits only top-level objects.
  • D13. Client reads before flags load
    • Assumption: return the caller default with PROVIDER_NOT_READY.
    • Alternative: FLAG_NOT_FOUND.
    • Why: it tells a start-up race apart from a typo or a deleted flag.
  • D14. Bootstrap and overrides
    • Assumption: reasons CACHED and override, with empty metadata. The bootstrap format does not change, so number and object flags cannot be bootstrapped as typed values.
    • Alternative: allow typed values in bootstrap.featureFlags.
    • Why: older client SDKs consume bootstrap maps that newer server SDKs build.
  • D15. OpenFeature providers
    • Assumption: providers pass the details through. They may keep their released version 1 coercions (boolean as enabled, number from the variant, object from the payload) for version 1 flags until their next major release, and never apply them to config version 2 flags.
    • Alternative: switch providers fully to the typed accessors now.
    • Why: no behaviour change for existing provider users.
  • D16. Deprecation guidance
    • Assumption: deprecate nothing now. Docs recommend the typed accessors for new code.
    • Alternative: soft-deprecate getFeatureFlagPayload and getFeatureFlagResult now.
    • Why: version 1 payloads have no other home yet.

Before and after

Python (server snapshot):

# before
flags = posthog.evaluate_flags("user-1")
limit = 10
if flags.is_enabled("upload-limit"):
    payload = flags.get_flag_payload("upload-limit")
    if isinstance(payload, dict) and isinstance(payload.get("limit"), int):
        limit = payload["limit"]

# after
flags = posthog.evaluate_flags("user-1")
limit = flags.get_number_value("upload-limit", 10)

details = flags.get_number_details("upload-limit", 10)
details.reason, details.error_code, details.flag_metadata.get("reason_code")

Node (server snapshot):

// before
const flags = await posthog.evaluateFlags('user-1')
const copy = flags.getFlag('banner-copy')
const text = typeof copy === 'string' ? copy : 'Welcome'

// after
const flags = await posthog.evaluateFlags('user-1')
const text = flags.getStringValue('banner-copy', 'Welcome')
const { value, variant, reason, errorCode, flagMetadata } = flags.getStringDetails('banner-copy', 'Welcome')

Browser JS (client):

// before
const result = posthog.getFeatureFlagResult('checkout-config')
const config = result?.enabled && result.payload ? result.payload : { steps: 3 }

// after
const config = posthog.getObjectValue('checkout-config', { steps: 3 })

// a multivariate flag: same value, same $feature_flag_called event
const arm = posthog.getStringValue('pricing-test', 'control') // was posthog.getFeatureFlag('pricing-test')

// a missing or failed flag
const nav = posthog.getBooleanDetails('new-nav', false)
if (nav.errorCode === 'FLAG_NOT_FOUND') {
    // typo, deleted flag, or not in this project
}

Migration story

  • Nothing changes until you change a call. Every existing accessor returns what it returns today, for v3 and older responses alike, and emits the same events.
  • One call at a time. A typed read and a legacy read of the same flag produce the same $feature_flag_called and dedupe together, so a code base can mix them freely.
  • How far the model moves. Today a flag is on or off, may name a variant, and may carry a payload per variant: up to three calls. With typed accessors a flag returns one value of a declared type plus the reason, in one call with your fallback. In config version 2 experiments the variant names the arm and the value is what the arm returns: branch on the value, and read details.variant only to know the arm.
  • Boolean users: isFeatureEnabled(k) becomes getBooleanValue(k, false). Two differences: a multivariate flag is TYPE_MISMATCH, and a config version 2 flag with a null default returns your default.
  • Multivariate users: getFeatureFlag(k) becomes getStringValue(k, 'control'). It returns the same variant key, and returns your default instead of false or undefined when nothing matches.
  • Payload users: existing flags keep working unchanged. New number and object flags read in one call. Moving variant-plus-payload configuration means recreating the flag as an object flag.
  • OpenFeature users: no change for existing flags (D15). Config version 2 flags resolve with full reasons and metadata.
  • Deprecation: none now (D16).

Differences from posthog-python#1031

Topic #1031 This change
reason Structured PostHog reason (code, condition_index, description) OpenFeature reason string; reason_code and condition_index in flag_metadata
Metadata Typed metadata object, including the payload Scalar flag_metadata map of the contract's keys, without the payload
variant when an error code is set Kept from metadata Left out
Locally evaluated version 1 flags No reason or metadata Derived reason and config_version 1
Records from older servers Silent One diagnostic log; the TYPE_MISMATCH message names the cause

Placement, resolution rules, error codes, legacy rendering, payload synthesis and event behaviour match.

Worth looking at first

  • D3: the one place where a legacy accessor and a typed accessor disagree for the same flag.
  • D5 and D6: the details shape and the version 1 reason mapping. #1031 has to change if we keep them.
  • D10: OpenFeature names on the client root versus flag-qualified names.
  • D11 and D15: no coercion in the native API, with a temporary version 1 shim in the providers.
  • The spec says SDKs request v=3. The harness compliance test flags_request_uses_v2_query_param still asserts v=2 for server SDKs, so it needs a change before SDKs adopt this.
  • This repository describes shipped behaviour. The only implementation so far, #1031, is still a draft, so merging can wait for it.

Process

  • Proposal only. The change is not applied or archived. The repository requires both in the same PR before merge, and they come after review (tasks.md sections 2 to 4). The acceptance feature and the README index entry are part of that step.
  • openspec validate add-typed-flag-values --strict and openspec validate --specs --strict pass with OpenSpec 1.4.1.

🤖 Generated with Claude Code

Add the add-typed-flag-values OpenSpec change. It defines the SDK API
for typed flag values read from the /flags?v=3 response:

- typed value and details accessors for boolean, string, number and
  object, on the evaluateFlags() snapshot and on client SDKs;
- resolution without coercion, and how the caller default relates to
  the flag default;
- one evaluation-details object with OpenFeature reasons and error
  codes and the contract's flag metadata keys;
- legacy accessors and $feature_flag_called stay unchanged;
- behaviour against older servers, bootstrap, overrides and local
  evaluation, and how OpenFeature providers wrap the accessors.

Proposal only: the change is not applied or archived yet. The design
lists the decisions still to settle.
A config version 2 experiment rule carries no experiment ID, so no
/flags?v=3 record carries metadata.experiment_id. Remove it from the
flag_metadata key list, the experiment split scenario and the design
assumption.
Config version 2 holdouts are rule-local and have no ID, so no
/flags?v=3 record carries metadata.holdout_id. A holdout result is
identified by its rule ID and the holdout reason code. Remove the key
from the flag_metadata key list and the design assumption.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant