Repository navigation
docs(adr): ISO 8601 timestamp conversion operator #2111
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
Open
cvas22
wants to merge
6
commits into
open-feature:main
Choose a base branch
from
cvas22:adr/1927-iso8601-timestamps
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 4 commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
6e5c45d
docs(adr): add ISO 8601 timestamp conversion operator ADR
cvas22 882e769
docs(adr): make invalid to_timestamp input an evaluation error and ti…
cvas22 18349a7
docs(adr): validate every time field and wrap a long paragraph
cvas22 112c85b
docs(adr): wrap the validation note
cvas22 b043164
Merge branch 'main' into adr/1927-iso8601-timestamps
cvas22 b707026
docs(adr): limit offsets to ±14:00 and round-trip check before the ti…
cvas22 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
160 changes: 160 additions & 0 deletions
160
docs/architecture-decisions/iso8601-timestamp-conversion.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,160 @@ | ||
| --- | ||
| # Valid statuses: draft | proposed | rejected | accepted | superseded | ||
| status: proposed | ||
| author: Srinivas Chippagiri | ||
| created: 2026-10-28 | ||
| updated: 2026-10-28 | ||
| --- | ||
|
|
||
| # ISO 8601 Timestamp Conversion Operator | ||
|
|
||
| This ADR proposes a `to_timestamp` custom operation that converts ISO 8601 dates and date-times into Unix timestamps (seconds), so that time-based targeting rules can be written with human-readable times while still using the existing numeric comparison operators and `$flagd.timestamp`. | ||
|
|
||
| ## Background | ||
|
|
||
| Time-based targeting in flagd works on Unix timestamps: `$flagd.timestamp` is the evaluation time in epoch seconds, and rules compare it with numeric literals, for example `{">": [{"var": "$flagd.timestamp"}, 1775674200]}`. | ||
| Numbers like `1775674200` are hard to read and review, and easy to get wrong (milliseconds instead of seconds, wrong timezone when converting by hand). | ||
| Users have asked to express these times as ISO 8601 strings instead ([#1927](https://github.com/open-feature/flagd/issues/1927)). | ||
|
|
||
| Two approaches were discussed on the issue: a conversion operator that turns an ISO 8601 string into a Unix timestamp, and an ISO 8601 "sub-type" with dedicated comparison operators (`is_before`, `is_after`, ...). | ||
| There was consensus on the conversion operator, as it reuses every existing numeric operator, and adds a single operator to implement across the flagd evaluation engines in all supported languages. | ||
| This ADR records that decision and specifies the behavior precisely enough for consistent cross-language implementations. | ||
|
|
||
| ## Requirements | ||
|
|
||
| - **Readable**: times in targeting rules can be written as ISO 8601 dates and date-times. | ||
| - **Composable**: the result works with all existing numeric JSONLogic operators and with `$flagd.timestamp`. | ||
| - **Deterministic**: the same rule and input produce the same timestamp in every flagd implementation (flagd, the in-process providers and the language SDK evaluators), regardless of the host the evaluation runs on. | ||
| - **Cross-language portable**: the accepted input format can be parsed identically in Go, Java, JavaScript, Python and .NET without relying on parser behavior that differs between runtimes. | ||
| - **JSONLogic conventions**: invalid input yields `null` (as other custom operations do) rather than failing the evaluation. | ||
|
|
||
| ## Considered Options | ||
|
|
||
| 1. **Conversion operator** (`to_timestamp`): converts an ISO 8601 string to a Unix timestamp; rules use existing operators. | ||
| 2. **ISO 8601 sub-type** with dedicated operators (`is_before`, `is_after`, ...), similar to `sem_ver`. | ||
|
|
||
| Option 2 has high semantic clarity, but requires a family of new operators and schema changes, makes the existing timestamp-based rules second-class, and has no natural limit on the operators it invites (`is_weekday`, `is_leap_year`, ...). | ||
| **Option 1 is proposed.** | ||
|
|
||
| ## Proposal | ||
|
|
||
| ### Operator Syntax | ||
|
|
||
| ```jsonc | ||
| // literal | ||
| { "to_timestamp": "2026-04-08T18:50:00Z" } | ||
|
|
||
| // any JSONLogic expression that yields a string, e.g. a context attribute | ||
| { "to_timestamp": { "var": "subscription_end" } } | ||
|
|
||
| // array form, as with other operations | ||
| { "to_timestamp": ["2026-04-08"] } | ||
| ``` | ||
|
|
||
| The result is an integer number of seconds since the Unix epoch, the same unit as `$flagd.timestamp`, so rules compose directly: | ||
|
|
||
| ```jsonc | ||
| { | ||
| "if": [ | ||
| { ">=": [{ "var": "$flagd.timestamp" }, { "to_timestamp": "2026-04-08T18:50:00Z" }] }, | ||
| "on", | ||
| "off" | ||
| ] | ||
| } | ||
| ``` | ||
|
|
||
| ### Accepted Formats | ||
|
|
||
| To guarantee identical results across runtimes, `to_timestamp` accepts a defined profile of ISO 8601, aligned with RFC 3339, rather than "whatever the platform parser accepts": | ||
|
|
||
| | Form | Example | Meaning | | ||
| | -------------------------- | --------------------------- | ---------------------------------------------------------------- | | ||
| | Date | `2026-04-08` | start of that day (`00:00:00`) in the evaluation timezone | | ||
| | Date-time with offset | `2026-04-08T18:50:00+02:00` | that instant | | ||
| | Date-time in UTC | `2026-04-08T18:50:00Z` | that instant | | ||
| | Date-time without offset | `2026-04-08T18:50:00` | that local time in the evaluation timezone | | ||
| | Optional seconds/fractions | `2026-04-08T18:50`, `...:00.250Z` | seconds default to `0`; fractional seconds are truncated | | ||
|
|
||
| Rules: | ||
|
|
||
| - Separators are required (extended format): `YYYY-MM-DD`, `T`, `HH:MM[:SS[.fraction]]`, and an offset of `Z` or `±HH:MM`. `T` and `Z` may be lowercase, as RFC 3339 allows. | ||
| - Week dates (`2026-W15-3`), ordinal dates (`2026-098`), basic format without separators, durations, intervals and reduced precision other than the forms above are **not** accepted. | ||
| - Years are `0001` to `9999`, and the date must exist in the calendar: `2026-02-30`, `2026-04-31` and `2025-02-29` are invalid, `2024-02-29` is valid. | ||
| - A fraction has one or more digits, with no upper limit. It is discarded (truncated, not rounded), so its length never changes the result, and the result is always an integer. | ||
| - Truncation drops the fractional part of the civil time before conversion, which is the same as rounding the instant down (towards negative infinity). Before 1970 this differs from truncating the numeric value towards zero: `1969-12-31T23:59:59.750Z` gives `-1`, not `0`. | ||
| - An integer number argument is returned unchanged, so rules can accept either form from context. A number with a fractional part (such as `3.5`) is invalid. | ||
| - Anything else (invalid format, out-of-range fields such as month `13`, a date that doesn't exist, a leap second `23:59:60`, a non-integer number, or a value that is neither a string nor a number) is an **evaluation error**, and a warning is logged. | ||
|
|
||
| `to_timestamp` deliberately does not return `null` for invalid input, unlike the other custom operations. | ||
| JSONLogic implementations convert `null` to `0` in numeric comparisons (the Go `jsonlogic` library used by flagd does), | ||
| so `{ ">=": [{ "var": "$flagd.timestamp" }, { "to_timestamp": "not a date" }] }` would be true and a malformed time would enable a flag. | ||
| As an evaluation error, the flag resolves the same way as for any other targeting rule that can't be evaluated, | ||
| so a malformed time never enables a flag by accident. | ||
|
|
||
| ### Evaluation Timezone | ||
|
|
||
| Inputs with an explicit offset (`Z`, `±HH:MM`) are unambiguous. | ||
| Inputs without one (dates and date-times without offset) are interpreted in the **evaluation timezone**: | ||
|
|
||
| - The default is **UTC**. | ||
| - It can be overridden in configuration (for flagd, a startup flag/environment variable such as `--timezone` / `FLAGD_TIMEZONE`; for in-process providers, the equivalent provider option), using an IANA zone name such as `America/Chicago`. | ||
|
|
||
| The issue discussion suggested defaulting to the host OS timezone. | ||
| This ADR proposes UTC instead, because flag rules are evaluated in many places (the flagd server and in-process providers in each application) whose hosts may run in different timezones; an OS default would make the same rule switch at different instants depending on where it is evaluated, which breaks the **Deterministic** requirement. | ||
| With UTC as the default, the result only changes when an operator opts in to a timezone explicitly. See [Open Questions](#open-questions). | ||
|
|
||
| ### Daylight Saving Time | ||
|
|
||
| For local times (no offset) in a timezone with daylight saving time: | ||
|
|
||
| - **Ambiguous times** (repeated during a backward transition): the **earlier** instant is used. | ||
| - **Non-existent times** (skipped during a forward transition): the time is shifted **forward** by the length of the gap (e.g. `02:30` on a day that skips `02:00`–`03:00` becomes `03:30`). | ||
|
|
||
| These match the defaults of `java.time.ZonedDateTime.of`. | ||
| Other runtimes need to apply them explicitly: Go's `time.Date`, for example, documents that it does not guarantee which instant it returns in a transition, so the Go implementation has to detect both cases and resolve them as above. | ||
|
|
||
| ### Cross-Language Implementation Notes | ||
|
coderabbitai[bot] marked this conversation as resolved.
Resolved
|
||
|
|
||
| The accepted profile is deliberately narrow so that each implementation can use a strict parser: | ||
|
|
||
| - **All runtimes**: validate every field before constructing the time: month `01`–`12`, day within the month (including leap years), | ||
| hour `00`–`23`, minute and second `00`–`59`, and an offset of at most `±23:59`. | ||
|
coderabbitai[bot] marked this conversation as resolved.
Resolved
|
||
| Alternatively, construct it and check that the components round-trip. | ||
|
coderabbitai[bot] marked this conversation as resolved.
Resolved
|
||
| Most constructors normalize out-of-range values instead of rejecting them, e.g. `2026-02-30` becomes `2026-03-02` and `24:00:00` becomes the next day, | ||
| so without these checks `24:00:00`, `18:60:00` or `23:59:60` would be accepted instead of being evaluation errors. | ||
| - **Go**: a regular expression for the profile, then `time.Date` in the evaluation `*time.Location` (or the parsed fixed offset), after the calendar check above. Avoid `time.Parse` layouts with optional parts. | ||
| - **Java**: `java.time` (`OffsetDateTime`, `LocalDateTime`, `LocalDate`), after validating the profile with a regular expression, as `DateTimeFormatter.ISO_*` accepts more than the profile. | ||
| - **JavaScript**: a regular expression plus an offset computed from the evaluation timezone with `Intl.DateTimeFormat`; do **not** use `Date.parse`, whose handling of date-only and offset-less strings differs from the rules above. `Date.UTC` maps years `0`–`99` to `1900`–`1999`, so set the year with `setUTCFullYear` (e.g. on `new Date(0)`) instead of passing it to `Date.UTC`. | ||
| - **Python**: a regular expression plus `datetime` and `zoneinfo`; do not rely on `datetime.fromisoformat`, which accepts different inputs across Python versions. | ||
| - **.NET**: a regular expression plus `DateTimeOffset` and `TimeZoneInfo`, rather than culture-sensitive parsing. | ||
|
|
||
| ### API Changes | ||
|
|
||
| - `to_timestamp` is added to the targeting JSON schema in `flagd-schemas` as a custom operation taking a single string, integer or JSONLogic expression (or a one-element array of these). | ||
| - flagd and the in-process providers get the evaluation timezone option described above. | ||
| - Documentation: a new page under `docs/reference/custom-operations/`, and a cross-reference from the `$flagd.timestamp` documentation. | ||
|
|
||
| ### Test Plan | ||
|
|
||
| Shared test cases in the flagd testbed (Gherkin), so every implementation is checked against the same expectations: | ||
|
|
||
| - each accepted form, with `Z`, positive and negative offsets, and fractional seconds, including a fraction longer than nine digits; | ||
| - a time before 1970 with a fraction (`1969-12-31T23:59:59.750Z` gives `-1`) and the earliest year (`0001-01-01`); | ||
| - date-only and offset-less inputs with the default (UTC) and an overridden timezone; | ||
| - an ambiguous and a non-existent local time across a DST transition; | ||
| - invalid inputs: wrong separators, week and ordinal dates, month `13`, dates that don't exist (`2026-02-30`, `2025-02-29`), `24:00:00`, `18:60:00`, `23:59:60`, an offset of `+24:00`, empty string, a non-integer number, and values that are neither strings nor numbers; all are evaluation errors, and a rule comparing `$flagd.timestamp` with one of them does not match; | ||
| - an integer argument returned unchanged; | ||
| - composition with `$flagd.timestamp` and numeric operators. | ||
|
|
||
| ### Consequences | ||
|
|
||
| - Positive: readable, reviewable time-based rules without a new family of operators; existing timestamp rules keep working. | ||
| - Positive: one operator to implement and test across all flagd evaluation engines. | ||
| - Negative: rules need an explicit conversion, which is more verbose than a dedicated `is_after`. | ||
| - Negative: implementations must carry a small, strict parser instead of using the platform's ISO 8601 parser. | ||
|
|
||
| ## Open Questions | ||
|
|
||
| 1. Should the default evaluation timezone be UTC (proposed, for determinism across evaluation sites) or the host OS timezone (as initially suggested on the issue)? | ||
| 2. Is `to_timestamp` the preferred name, or should it be `to_unix` to make the unit explicit? | ||
| 3. Should durations (e.g. adding `PT2H` to a time, raised on the issue) be a follow-up operator, or stay out of scope? | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.