Skip to content

Absent CQL timezone offset is emitted as UTC, not the evaluation-request offset #1507

Description

@baseTwo

Summary

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.

From spec/cql/condensed/02-authorsguide.md:

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:

  1. ConstructionCqlOperators.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.
  2. ConversionCql/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:

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

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions