Skip to content
Open
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions docs/architecture-decisions/numeric-coercion.md
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.

Copy link
Copy Markdown
Contributor

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 integer instead, because this limitation does not only apply to JavaScript, as it is done elsewhere in this document

@toddbaert toddbaert Jun 9, 2026

Copy link
Copy Markdown
Member Author

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 integer is a better way to talk about this number so I will use that naming (I have in other places in the doc already).

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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The 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 |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 IEEE 754 64-Bit? I think we should call out explicitely that we will reject those, since they can be represented in JSON

@toddbaert toddbaert Jun 9, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Maybe I'm confused, but this proposal caps numeric flags at the iEEE 754 max safe int:

flagd additionally caps numeric flag values at the IEEE-754 safe-integer range, [-(2^53 - 1), 2^53 - 1].

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 PARSE_ERROR.

| ------------------------------------------------------------- | ------------------ | ----------------- |
| 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) |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

For mathematical precision, it is better to refer to the safe-integer range [-(2^53 - 1), 2^53 - 1] rather than just 2^53-1 or within 2^53-1, as negative integers can also exceed the safe range in absolute value (e.g., -9007199254740992). Using "outside the safe-integer range" or "within the safe-integer range" makes this explicit.

Suggested change
| 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) |
| int, exceeds the maximum of the numeric resolver in use, but within the safe-integer range | <code>TYPE_MISMATCH</code> | value (widened) |
| int, outside the safe-integer range <code>[-(2^53 - 1), 2^53 - 1]</code> | rejected at load (or <code>PARSE_ERROR</code> at evaluation if not validated) | rejected at load (or <code>PARSE_ERROR</code> at evaluation if not validated) |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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.
Should we in this case refer to the used programming language? I.e. if no such value exists due to limitations of the language, e.g. JavaScript, we return PARSE_ERROR, else we return the value. Programmers would already know which numbers their languages can handle and which it cannot.

@toddbaert toddbaert Jun 9, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Programmers would already know which numbers their languages can handle and which it cannot.

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:

As a PM, I want to define the amount in of data in bytes that a customer can store for some purpose

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 |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 Number type.
With this rule, however, fetching a flag that evaluates to 2^53 + 1 (int) would fail no matter if it is fetched as int or float, but 2^53 + 1.1 (float) would work when fetched as float. I think that all values that fit inside an IEEE 754 64-Bit max safe integer should be safely returned when fetching a float flag.

@toddbaert toddbaert Jun 9, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The 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 Number type.

We don't in the spec, but languages and flagd does (in terms of how the resolvers behave).

With this rule, however, fetching a flag that evaluates to 2^53 + 1 (int) would fail no matter if it is fetched as int or float, but 2^53 + 1.1 (float) would work when fetched as float. I think that all values that fit inside an IEEE 754 64-Bit max safe integer should be safely returned when fetching a float flag.

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. 2^53 + 1.1 from your example is specifically classed as out-of-bounds on the config level by this proposal, though the table didn't explicitly mention that (it was just mentioned in prose elsewhere). I've included that now, but the fetched as float column is pretty unsurprising and uninteresting.


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.
Loading