You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
When converting a CQL System.DateTime that carries no timezone offset to FHIR, the SDK emits UTC (Z). The CQL specification says an absent offset defaults to the timezone offset of the evaluation request, not UTC.
Only DateTime values may specify a timezone offset, either as UTC ( Z ), or as a timezone offset. If no timezone offset is specified, the timezone offset of the evaluation request timestamp is used.
The only component that is ever defaulted is the timezone offset component. If no timezone offset component is supplied, the timezone offset component is defaulted to the timezone offset of the timestamp associated with the evaluation request.
The same rule is restated in 02-authorsguide.md (extracting a defaulted offset yields the request's offset, not null) and in spec/cql/condensed/04-logicalspecification.md.
Split out of the review of #1458, which introduced the offset emission required by #1506 and documents this deviation in docs/releases/vnext/1458-time-precision-extension.md. #1458 is correct to emit an offset — FHIR requires one once hours and minutes are present — this issue is about which offset.
Impact
Every FHIR dateTime the SDK emits from an offset-less CQL value asserts an instant that is wrong by the evaluation request's offset whenever the request is not UTC — up to ±14 hours. It affects dateTime values and both boundaries of a Period converted from an Interval<DateTime> or Interval<Time>, i.e. the artifacts a receiver validates, stores and may re-evaluate against.
A value sitting near a measurement-period boundary can be shifted across it. Nothing signals the substitution: the output is lexically valid FHIR, so no validator complains.
This is not an edge case. Cql/Cql.Runtime/Operators/CqlOperators.DateTimeOperators.cs leaves the offset components null whenever CQL's DateTime(...) constructor is given no offset, so the SDK never applies the spec's default at construction either — every offset-less CQL literal reaches the converter unset.
Observed Behavior
With an evaluation request at +02:00 and the CQL value @2014-02-01T10:30:00:
CQL semantics: the value denotes 2014-02-01T10:30:00+02:00, i.e. 08:30Z.
Emitted FHIR: 2014-02-01T10:30:00Z — two hours later than the value the engine computed with.
Root Cause
Two independent gaps:
Construction — CqlOperators.DateTimeOperators.cs (DateTime(int? year, …, decimal? offset)) only populates osHours/osMinutes when offset.HasValue, so an absent offset stays absent instead of being defaulted to the evaluation request's offset as the spec requires.
Conversion — Cql/Cql.Firely/FhirTypeConverter.cs has to materialise some offset to satisfy FHIR, and cannot see the evaluation request: FhirCqlContext.CreateOperators builds nowIso8601 from its now argument and hands it to CqlOperators, while the converter is created independently via FhirTypeConverter.Create(modelInspector, cacheSize). UTC is therefore a stand-in chosen for lack of the real value.
Fixing (1) alone would make (2) moot for CQL-authored values, since the offset would already be populated by the time the converter sees it — but it is not a free change, because of a third gap found while reviewing #1458:
Populating the offset at construction silently flips an existing comparison branch.Cql/Cql.Abstractions/Primitives/CqlDateTime.cs skips normalization when exactly one side has an offset (if (self.RationalOffset.HasValue ^ other.RationalOffset.HasValue)), under a comment stating that "when one datetime has a timezone specified and the other does not, the timezone should be ignored. Functionally, this means don't normalize" — and it does so at four arms: :340, :369, :401, :440, which are Hour, Minute, Second and Millisecond. Those are exactly the four precisions the spec names in "normalize … but only when the comparison precision is hours, minutes, seconds, or milliseconds", so the branch is the deliberate inverse of the rule at every precision the rule covers, with tests pinning it. Default the offset at construction and the XOR stops firing for CQL-authored values, so those comparisons switch to the normalized path — a change in results, arriving without anyone touching the comparison code. That is also what rules out a partial fix: all four arms get re-derived, or none does.
Nothing outside Now()/TimeOfDay()/Today() can currently see the evaluation request, so none of the normalization rules above is implemented today; they need the request timestamp threaded into the comparison, duration and extraction paths, not only into construction.
Expected Behavior
An offset-less CQL DateTime converts to FHIR carrying the evaluation request's timezone offset. When no evaluation timestamp was supplied to the context, the fallback must be a deliberate, documented choice rather than an implicit one.
Acceptance Criteria
Decide and document where the default is applied: at DateTime/Time construction in CqlOperators (spec-faithful, fixes every downstream consumer at once) or at conversion in FhirTypeConverter (narrower, but leaves CQL-internal semantics still deviating).
An offset-less CQL DateTime converted with an evaluation request at a non-UTC offset emits that offset, not Z, on the padded path, the second-or-finer path, and both Period boundary paths.
The behaviour when no evaluation timestamp is supplied is explicit in code and documented in the XML docs.
timezoneoffset from X on a value whose offset was defaulted returns the evaluation request's offset rather than null, per 02-authorsguide.md.
Tests cover a non-UTC evaluation request end to end, including a value near a measurement-period boundary that the current UTC substitution would move across it.
Release-note fragment records the change, and the deviation note added by Pad partial-precision times/dateTimes for FHIR #1458 to docs/releases/vnext/1458-time-precision-extension.md (or its consolidated successor) is removed or updated.
Comparison, duration between and difference between normalize to the evaluation request's offset at hour-or-finer precision, and the existing "one side has no offset ⇒ do not normalize" branch is re-derived at all four arms against the defaulted-offset world rather than left to flip implicitly.
time from X on a DateTime normalizes to the evaluation request's offset.
Existing tests whose names assert the UTC substitution (…_AssumesUtc…, RoundTripCqlDateTime_FhirDateTime_RestoresPrecisionAndAcquiresUtcOffset) are renamed or re-pinned to the new behaviour.
Summary
When converting a CQL
System.DateTimethat carries no timezone offset to FHIR, the SDK emits UTC (Z). The CQL specification says an absent offset defaults to the timezone offset of the evaluation request, not UTC.From
spec/cql/condensed/02-authorsguide.md:The same rule is restated in
02-authorsguide.md(extracting a defaulted offset yields the request's offset, not null) and inspec/cql/condensed/04-logicalspecification.md.Split out of the review of #1458, which introduced the offset emission required by #1506 and documents this deviation in
docs/releases/vnext/1458-time-precision-extension.md. #1458 is correct to emit an offset — FHIR requires one once hours and minutes are present — this issue is about which offset.Impact
Every FHIR
dateTimethe SDK emits from an offset-less CQL value asserts an instant that is wrong by the evaluation request's offset whenever the request is not UTC — up to ±14 hours. It affectsdateTimevalues and both boundaries of aPeriodconverted from anInterval<DateTime>orInterval<Time>, i.e. the artifacts a receiver validates, stores and may re-evaluate against.A value sitting near a measurement-period boundary can be shifted across it. Nothing signals the substitution: the output is lexically valid FHIR, so no validator complains.
This is not an edge case.
Cql/Cql.Runtime/Operators/CqlOperators.DateTimeOperators.csleaves the offset components null whenever CQL'sDateTime(...)constructor is given no offset, so the SDK never applies the spec's default at construction either — every offset-less CQL literal reaches the converter unset.Observed Behavior
With an evaluation request at
+02:00and the CQL value@2014-02-01T10:30:00:2014-02-01T10:30:00+02:00, i.e.08:30Z.2014-02-01T10:30:00Z— two hours later than the value the engine computed with.Root Cause
Two independent gaps:
CqlOperators.DateTimeOperators.cs(DateTime(int? year, …, decimal? offset)) only populatesosHours/osMinuteswhenoffset.HasValue, so an absent offset stays absent instead of being defaulted to the evaluation request's offset as the spec requires.Cql/Cql.Firely/FhirTypeConverter.cshas to materialise some offset to satisfy FHIR, and cannot see the evaluation request:FhirCqlContext.CreateOperatorsbuildsnowIso8601from itsnowargument and hands it toCqlOperators, while the converter is created independently viaFhirTypeConverter.Create(modelInspector, cacheSize). UTC is therefore a stand-in chosen for lack of the real value.Fixing (1) alone would make (2) moot for CQL-authored values, since the offset would already be populated by the time the converter sees it — but it is not a free change, because of a third gap found while reviewing #1458:
Populating the offset at construction silently flips an existing comparison branch.
Cql/Cql.Abstractions/Primitives/CqlDateTime.csskips normalization when exactly one side has an offset (if (self.RationalOffset.HasValue ^ other.RationalOffset.HasValue)), under a comment stating that "when one datetime has a timezone specified and the other does not, the timezone should be ignored. Functionally, this means don't normalize" — and it does so at four arms::340,:369,:401,:440, which areHour,Minute,SecondandMillisecond. Those are exactly the four precisions the spec names in "normalize … but only when the comparison precision is hours, minutes, seconds, or milliseconds", so the branch is the deliberate inverse of the rule at every precision the rule covers, with tests pinning it. Default the offset at construction and the XOR stops firing for CQL-authored values, so those comparisons switch to the normalized path — a change in results, arriving without anyone touching the comparison code. That is also what rules out a partial fix: all four arms get re-derived, or none does.Nothing outside
Now()/TimeOfDay()/Today()can currently see the evaluation request, so none of the normalization rules above is implemented today; they need the request timestamp threaded into the comparison, duration and extraction paths, not only into construction.Expected Behavior
An offset-less CQL
DateTimeconverts to FHIR carrying the evaluation request's timezone offset. When no evaluation timestamp was supplied to the context, the fallback must be a deliberate, documented choice rather than an implicit one.Acceptance Criteria
DateTime/Timeconstruction inCqlOperators(spec-faithful, fixes every downstream consumer at once) or at conversion inFhirTypeConverter(narrower, but leaves CQL-internal semantics still deviating).DateTimeconverted with an evaluation request at a non-UTC offset emits that offset, notZ, on the padded path, the second-or-finer path, and bothPeriodboundary paths.timezoneoffset from Xon a value whose offset was defaulted returns the evaluation request's offset rather than null, per02-authorsguide.md.docs/releases/vnext/1458-time-precision-extension.md(or its consolidated successor) is removed or updated.duration betweenanddifference betweennormalize to the evaluation request's offset at hour-or-finer precision, and the existing "one side has no offset ⇒ do not normalize" branch is re-derived at all four arms against the defaulted-offset world rather than left to flip implicitly.time from Xon aDateTimenormalizes to the evaluation request's offset.…_AssumesUtc…,RoundTripCqlDateTime_FhirDateTime_RestoresPrecisionAndAcquiresUtcOffset) are renamed or re-pinned to the new behaviour.