-
Notifications
You must be signed in to change notification settings - Fork 119
docs: add numeric coercion proposal #1979
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,98 @@ | ||||||||||
| --- | ||||||||||
| # Valid statuses: draft | proposed | rejected | accepted | superseded | ||||||||||
| status: draft | ||||||||||
| author: Todd Baert (@toddbaert) | ||||||||||
| created: 2026-06-08 | ||||||||||
| updated: 2026-06-08 | ||||||||||
| --- | ||||||||||
| # Numeric coercion contract for typed flag accessors | ||||||||||
|
|
||||||||||
| flagd's evaluators don't currently agree on what should happen when a numeric flag is fetched via a different numeric accessor than its parsed JSON type, or when a value doesn't fit the requested type. | ||||||||||
| This ADR proposes a single contract for all flagd implementations to follow. | ||||||||||
| Tracking issue: [#1978](https://github.com/open-feature/flagd/issues/1978). | ||||||||||
|
|
||||||||||
| ## Background | ||||||||||
|
|
||||||||||
| JSON does not distinguish integers from floats; `10` and `10.0` are the same kind of token. | ||||||||||
| flagd's wire formats (gRPC and OFREP) and its typed evaluator methods (`ResolveInt`, `ResolveFloat`, etc.) do distinguish them, and each language's flagd-core implementation has made different choices about what to do at that boundary. | ||||||||||
| The result is observable inconsistency. | ||||||||||
|
|
||||||||||
| Examples seen across implementations today: | ||||||||||
|
|
||||||||||
| | Variant | Accessor | Go | Java | Python | .NET | | ||||||||||
| | ------------ | -------- | --------------- | ------------------------------------------------------------------------- | ------------- | ------------- | | ||||||||||
| | `3.14` | Integer | `3` (truncates) | `3` (truncates) | TYPE_MISMATCH | TYPE_MISMATCH | | ||||||||||
| | `9000000000` | Integer | ok (int64) | TYPE_MISMATCH (in-process); `410065408` (RPC, int32 overflow on the wire) | ok | TYPE_MISMATCH | | ||||||||||
| | `9000000000` | Float | ok | TYPE_MISMATCH (in-process) | ok | ok | | ||||||||||
| | `10.0` | Integer | `10` | `10` | TYPE_MISMATCH | TYPE_MISMATCH | | ||||||||||
|
|
||||||||||
| These cases are reachable with simple flag definitions and surface as silent data corruption, not just inconsistent error codes. | ||||||||||
|
|
||||||||||
| ## Considered options | ||||||||||
|
|
||||||||||
| 1. **Lossless coercion only, with a hard cap at the JSON-safe integer range (2^53 - 1)**: an accessor returns a value if and only if the conversion is lossless. | ||||||||||
| Variants outside the safe-integer range are rejected up front so no transport ever has to silently lose precision. | ||||||||||
| 2. **Lossless coercion only, no cap**: same accessor rule as option 1, but variants beyond 2^53 are accepted with documented precision loss in JS / OFREP / JSON paths. | ||||||||||
| 3. **Strict per parsed type**: the variant's parsed JSON type (int vs float) is fixed; cross-type fetches always return `TYPE_MISMATCH`. | ||||||||||
| 4. **Permissive coercion**: any numeric variant is returnable through any numeric accessor; truncation and overflow happen silently, matching today's Go and Java behavior. | ||||||||||
| 5. **Status quo**: leave each implementation as it is. | ||||||||||
|
|
||||||||||
| I propose option 1. | ||||||||||
|
|
||||||||||
| Option 2 is close but leaves a silent-precision-loss case in place for JS clients and any JSON-based wire (OFREP), which contradicts the lossless principle the rest of the contract is built on. | ||||||||||
|
|
||||||||||
| Option 3 is the simplest specification but produces surprising behaviors. | ||||||||||
| JSON treats `10` and `10.0` interchangeably, so rejecting `10.0` from an integer accessor surprises users who weren't thinking about the JSON parser's typing decisions. | ||||||||||
| flagd's own JsonLogic engine treats numeric values uniformly during targeting, so strict typing only at the accessor boundary is internally inconsistent. | ||||||||||
|
|
||||||||||
| Option 4 preserves the silent-truncation and silent-overflow behaviors that prompted this ADR. | ||||||||||
| These are the cases we most need to fix. | ||||||||||
|
|
||||||||||
| Option 5 leaves the inconsistencies in place. | ||||||||||
|
|
||||||||||
| ## Proposal | ||||||||||
|
|
||||||||||
| A numeric variant is returnable through a numeric accessor when the conversion is lossless. | ||||||||||
| Otherwise the evaluator returns `TYPE_MISMATCH`. | ||||||||||
|
|
||||||||||
| flagd additionally caps numeric flag values at the IEEE-754 safe-integer range, `[-(2^53 - 1), 2^53 - 1]`. | ||||||||||
| Variants whose absolute value exceeds this range are considered invalid per the JSON schema (we'll add this limit there). | ||||||||||
|
Comment on lines
+58
to
+59
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is somewhat opinionated, but for now I think I'd rather define a hard cap than say we'll possibly lose precision at some integer value. This number is sufficiently high, IMO, it can support extremely large values such as 8 petabytes (in bytes), or timestamps in millis. |
||||||||||
|
|
||||||||||
| | Variant kind | Fetched as Integer | Fetched as Float | | ||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. How do we treat values that exceed even the maximum finite number that can be represented with
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe I'm confused, but this proposal caps numeric flags at the
This would make it invalid to configure such a value; what happens if the config validation is ignored can be decided here, but I would suggest |
||||||||||
| | ------------------------------------------------------------- | ------------------ | ----------------- | | ||||||||||
| | int, fits the maximum of the numeric resolver in use | value | value (widened) | | ||||||||||
| | int, exceeds the maximum of the numeric resolver in use, but within 2^53-1 | `TYPE_MISMATCH` | value (widened) | | ||||||||||
| | int, exceeds 2^53-1 | rejected at load (or `PARSE_ERROR` at evaluation if not validated) | rejected at load (or `PARSE_ERROR` at evaluation if not validated) | | ||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For mathematical precision, it is better to refer to the safe-integer range
Suggested change
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't really like the idea that we reject values that we could still safely represent. E.g. in Java, we do have the option to represent 64 bit integers, but we would still reject the numbers between 2^53 and 2^64 for no apparent reason for a Java developer.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Programmers do, but I think there's a desire for cross languages consistency - and for that reason I feel that putting an upper bound on what flagd supports is a good idea. For OpenFeature itself, I agree with you: defer to the language - but I would like to assure users that flagd operates consistent for numeric values across all languages, event if it involves a hard cap, personally. Imagine this real use case:
I don't want the PM to have to consider the numeric types of Python/Java/JS - I just want the configuration plane to give them solid feedback. If we aren't consistent here, it's likely that a some validation that passes in the UI doesn't work in the backend, or some batch job, because of precision loss. If we prevent the configuration of such values in the first place, it seems like a good trade-off to me. (edited) |
||||||||||
| | float, whole-valued and within the resolver's int range (e.g. `10.0`) | value (e.g. `10`) | value | | ||||||||||
| | float, fractional or out of the resolver's int range | `TYPE_MISMATCH` | value | | ||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think this is may be an inconsistency: IIRC, we do not distinguish numbers in the flagd config per type (int vs float), we just have a
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
We don't in the spec, but languages and flagd does (in terms of how the resolvers behave).
I think my table was not clear; it's mostly about what happens if you evaluate a fractional decimal number with some kind of int resolver. For float evaluations themselves - I think since all languages support IEEE-754 we don't need to say much. |
||||||||||
|
|
||||||||||
| The contract applies identically across all interfaces (gRPC, OFREP, and in-process evaluation). | ||||||||||
| "Numeric resolver in use" means the accessor the caller invoked: a 32-bit `Integer` accessor caps at `int32` max, a `Long` accessor (e.g. forthcoming Java `getLong`, .NET `Int64`) caps at `int64` max, a `Float` accessor caps at the safe-integer range. | ||||||||||
| Each language applies the rule against whichever resolver was called, not against a fixed type. | ||||||||||
|
|
||||||||||
| The 2^53-1 cap matches the interoperable integer range that every JSON parser, including JavaScript's, can faithfully represent. | ||||||||||
| Capping at this range removes the silent precision-loss case entirely; a value either round-trips exactly through every transport flagd supports, or it is rejected. | ||||||||||
| The alternative (permitting larger values and documenting precision loss past 2^53) preserves silent corruption in the JS and OFREP paths and contradicts the lossless principle this contract is built on. | ||||||||||
|
|
||||||||||
| ## Consequences | ||||||||||
|
|
||||||||||
| The benefit is a single rule that all implementations follow, with no silent truncation, no silent overflow, and no silent precision loss. | ||||||||||
| The motivating bugs (`3.14` quietly becoming `3`, `9000000000` quietly becoming `410065408`) go away, and a value either round-trips exactly through every transport flagd supports or is rejected. | ||||||||||
|
|
||||||||||
| The cost is that this is a breaking change in two ways. | ||||||||||
| Go and Java users who currently rely on permissive coercion will see `TYPE_MISMATCH` where they previously got truncated values. | ||||||||||
| Operators with flag definitions containing values outside `[-(2^53 - 1), 2^53 - 1]` will see those flags fail validation; today such values either work (Go, Python) or fail unpredictably (Java, .NET). | ||||||||||
| Both changes are detectable; neither silently alters returned values. | ||||||||||
|
|
||||||||||
| A side effect is that the rule requires evaluators to inspect the value, not only the parsed type, when servicing a cross-type request. | ||||||||||
| This is a small cost; every implementation already has the value in hand at the point the type check occurs. | ||||||||||
|
|
||||||||||
| ## Testing | ||||||||||
|
|
||||||||||
| Coverage lives in the [flagd testbed](https://github.com/open-feature/flagd-testbed) so every SDK and provider verifies the same contract. | ||||||||||
| We will add a new "numeric" suite capturing all these requirements. | ||||||||||
|
|
||||||||||
| ## Versioning and migration | ||||||||||
|
|
||||||||||
| flagd is pre-1.0, so this ships as a minor-version bump with the breaking change called out in the release notes. | ||||||||||
| Provider releases follow, in tight coordination as usual. | ||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is it the JSON max safe integer or the JavaScript max safe integer? From what I read into the spec, there is no max safe int (https://www.json.org/json-en.html) in JSON. I think we should use the wording
IEEE 754 64-Bit max safe integerinstead, because this limitation does not only apply to JavaScript, as it is done elsewhere in this documentUh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You're right, this is mostly a JS not a JSON limitation. The reason it involves JSON is because many JSON parsers (including JS's) don't support parsing integers larger than the JS number in question, even some Java parsers by default have this issue (Gson I believe) so it gets a bit messy.
But I fully agree that the
IEEE 754 64-Bit max safe integeris a better way to talk about this number so I will use that naming (I have in other places in the doc already).