Skip to content

fix!: type the local time formats and keep time's offset - #1325

Merged
OmarAlJarrah merged 10 commits into
mainfrom
fix/openapi-local-time-formats
Nov 2, 2026
Merged

OmarAlJarrah merged 10 commits into
mainfrom
fix/openapi-local-time-formats

Conversation

@OmarAlJarrah

@OmarAlJarrah OmarAlJarrah commented Nov 2, 2026 •

Copy link
Copy Markdown
Member

Summary

format: date-time-local and format: time-local each hoisted a Scalar over string that kept the format as its encoding, although the IR has a primitive for each: datetime, a date and time without offset, and time. format: time is RFC 3339's full-time, which carries an offset, and it lowered to time. The doc on time said only "a time of day without date", and the emitter design types time as a local time (LocalTime, the Go runtime's TimeOfDay), so the offset had nowhere to go.

dtl: {type: string, format: date-time-local}
tl:  {type: string, format: time-local}
t:   {type: string, format: time}
Property main This branch
dtl Scalar over string, Encoding.Name: date-time-local t/prim/datetime
tl Scalar over string, Encoding.Name: time-local t/prim/time
t t/prim/time Scalar over string, Encoding.Name: time
  • time is documented as holding no offset, as datetime does beside datetime_offset (ir/types.go, and a new docs/ir/primitives.md#offsets, which states each time kind in RFC 3339's terms).
  • The format table maps time-local to time and date-time-local to datetime, and has no time row, so time keeps its format on a hoisted Scalar the way any format without a primitive does (docs/compilers/openapi.md#string-formats).
  • The value judges follow the kinds. time now admits an RFC 3339 partial-time, where before it admitted a full-time. datetime had no judge: it admitted no value, and a literal compared with it read as an overlap. It now admits a local date-time. A value written under these formats is therefore judged by the grammar its format names:
    • {type: string, format: time-local, enum: ["15:04:05", "16:04:05"]} gets ValueType: time, where main kept the format verbatim under Unmodeled with an info.
    • Under allOf, a broader declaration's default: "2024-01-01T00:00:00" is adopted beside format: date-time-local, and "2024-01-01T00:00:00Z" is refused. main refused both.
    • A leap second in a local form is admitted at any minute, since an offset could place UTC's 23:59 at any local minute. With an offset, it is still admitted only where the time in UTC is 23:59.
  • Under allOf, the Scalar that format: time now lowers to is still judged by RFC 3339's full-time grammar, so the fold judges it as main did while time was the kind, but for the two cases "allOf under format: time" below names.

Design

The alternative was a time_offset kind beside datetime_offset. It was weighed and not taken, and docs/ir/primitives.md#offsets records why, next to the chosen design:

  • Only one spelling. JSON Schema's time is the only way to write a time of day with an offset in any format the IR is designed against. TypeSpec's plainTime, Protobuf's google.type.TimeOfDay and Erlang/OTP's calendar:time() hold no offset, and Smithy, GraphQL and Swagger 2.0 have no time of day at all.
  • Premise: specs write values with no offset under time. I tested this on a local corpus of about 1,800 real-world OpenAPI documents with grep -rnE -A4 'format"?\s*:\s*"?time"?\s*,?\s*$' <corpus>. Each value written next to format: time (enum members and a default) has no offset. A kind promising an offset would make a target refuse those values.
  • What the Scalar gives instead. It keeps the format as written and leaves the reading to the target's type table, where a {string, "time"} row can make the choice.

date-time-local and time-local reached no kind before, and format: time reached the wrong one, so this is a fix. The golden spec writes none of the three formats: jq '[.. | objects | select(has("format")) | .format | strings] | map(select(. == "time" or . == "time-local" or . == "date-time-local")) | length' returns 0 at cloudflare/api-schemas@319dc756.

What reads these kinds

Each place that reads time or datetime, and what it does now:

  • merge.Admits and literalAgainst. Changed as described above.
  • spelling.Relate. It reads the table. datetime now refines string and is disjoint from every other format but binary, which overlaps each of them, and relationRoots in its test no longer lists it. So under allOf, format: date-time redeclared as format: date-time-local now draws an openapi/conflicting-redeclaration warning (incompatible types), as uuid against date-time does, where main reported an info overlap against the hoisted Scalar. No string is a date-time both with and without an offset.
  • schema.enumValueType. It goes through Admits. datetime can now be an Enum.ValueType, which puts it in the same position as date, time and uuid: it has no Go constant form, which docs/emitters: a closed enum whose value type has no Go constant has no lowering #1202 tracks.
  • An enum declaring format: time. It now keeps the format verbatim under Unmodeled with an info, as an email enum does. Before, members carrying an offset gave it ValueType: time.
  • allOf under format: time. merge holds the grammar of time, RFC 3339's full-time, the one format the table pairs with no kind that it can still judge (admitsFormat). Wherever the fold's judge reads a kind, it reads a Scalar hoisted for time as a string format of its own. That Scalar sits within string, overlaps binary, and is disjoint from every other kind and from an enum none of whose members it admits, and it admits a value its grammar admits. So, as on main, a full-time default beside it is adopted, a const that is one narrows the field, and time against date-time is an openapi/conflicting-redeclaration warning. time against time-local is now that warning too, where main reported an info overlap, since no string is both. A const narrows the Scalar only where the node says nothing but its base, the format and bounds. A description, example or extension written beside format: time lands on that node, so with one the pair stays an overlap, and both declarations are kept and reported. main narrowed that case, since it put the description on the property beside a bare time. Narrowing it again needs the merge to build a node that adopts the broader side's detail, which openapi: an allOf narrowing between two enums or two lists is resolved by branch order #1042 tracks. docs/compilers/openapi.md#allof-redeclared-property states each verdict.
  • Census. PrimKind "datetime" comes out of testdata/unwitnessed.golden.txt, since scalar-format now sets it.
  • Emitter design. The Go and Kotlin rows for time (TimeOfDay, LocalTime) already read it as offset-free, and datetime already has rows. No {string, "time"} row exists, so a target falls back to string with the unmapped-encoding info, per docs/emitters/contract/type-table.md.
  • Docs. I grepped docs/ for time and datetime statements: git grep -nE 'PrimTime|PrimDatetime|`time`|`datetime`' -- docs. The one that assumed the old set was the list of string-spelled kinds in primitives.md, which now includes datetime.

A default that its own format refuses, such as {format: time-local, default: "10:15:30Z"}, is kept with no diagnostic. main behaves the same for every format ({format: uuid, default: "hello"}), and #643 tracks it.

Test plan

  • I compiled the spec above, plus enums and allOf defaults under each format, with morphic compile --verify on main and on this branch. The table and bullets above are what that run printed. Exit code was 0 on both.
  • Each piece of the fix was planted back and went red, each time in the test named here:
    • datetime admitting nothing: TestAdmits_JudgesEachKindExactly.
    • time judged as a date-time: TestAdmits_JudgesEachKindExactly.
    • A local leap second checked against UTC: TestAdmits_JudgesEachKindExactly.
    • A local clock accepting any tail: TestAdmits_JudgesEachKindExactly and TestFold_AnInadmissibleValueIsKeptNotAdopted.
    • The datetime overlap restored in literalAgainst: TestTypeJudge_RelateArms, through the two rows this PR adds. Without them, nothing went red.
    • The time-local or date-time-local row removed, or a time row restored: TestFormatTable_EveryStringRowLowersToItsPrimitive and the scalar-format conformance case.
  • Corpus differential, main against this branch, over every spec in the corpus above that writes one of the three formats, rerun after merging feat(ir)!: type the secrecy and stability a document states #1229 with the same result. Exit codes and error diagnostics are identical. The other diagnostics are identical except one info at an enum under format: time, which now gives the generic reason for keeping the format verbatim; both versions keep it. In each spec, the primitives reached and the format scalars hoisted move as the table above says (counted per spec, not diffed node by node).
  • The allOf judge for time is pinned by merge.TestAdmitsFormat_JudgesTimeAsAFullTime, merge.TestAdmitsFormat_SharesNoStringWithAKind, merge.TestRelateKinds_ReadsAHeldFormatAsAStringFormat, merge.TestKindOf_TheFirstEncodingDecidesTheFormat, new rows in merge.TestTypeJudge_RelateArms and merge.TestTypeJudge_AdmitsPerNode, and schema.TestAllOf_ATimeIsJudgedByItsGrammar, which compiles each case in both branch orders. I planted 31 defects in it: in the grammar, in which Encoding counts as a format, in each relation formatAgainst returns, in the literal and enum verdicts, and in which detail blocks a narrowing. Each went red in one of those tests, and a clean control passed.
  • The same branch compiled before and after the allOf judge change gives identical IR and diagnostics on all 2,201 specs I swept (every file in the corpus above with an openapi, swagger or asyncapi key, plus testdata/), and on the golden spec at cloudflare/api-schemas@f37e206d. Run on my 22 probe specs for these cases, the same instrument flags the five whose verdicts the change moves, and no other. The harness oracles, order invariance among them, pass on those specs.
  • go test ./compilers/openapi/e2e -run TestConformance -update changed only scalar-format.golden.json, by the three properties. go test ./cmd/morphic-census -update removed only the datetime line.
  • The gate passes on CI.

Breaking

IRVersion moves from 0.27.0 to 0.28.0, with no change of shape. The generations row says what meaning changed. A consumer pinned to 0.27.0 refuses the document. If it read the new document anyway, it would see:

  • values with no offset where it expected time to carry one;
  • format: time arriving as a Scalar with Encoding.Name: time;
  • no time-local or date-time-local encodings where it used to find them.

#1229 took 0.27.0 first, so this branch merged main and takes 0.28.0. #1269, #1333, #1340 and #1361 also move IRVersion; whichever of these merges after another takes the next free version.

Closes #869
Closes #1353

OpenAPI's format registry names two offset-free spellings beside the
RFC 3339 ones: date-time-local, a date-time with no offset, and
time-local, a partial-time. Both hoisted a Scalar over string, though
the IR has datetime and time for exactly those values. format: time, a
full-time, which carries an offset, lowered to time, whose doc said only
"a time of day without date" while the emitter design types it as a
local time, so the offset had nowhere to go.

time is now documented as holding no offset, as datetime does beside
datetime_offset. time-local lowers to time, date-time-local to datetime,
and time keeps its format on a Scalar over string, as any format with no
primitive does. The value judges follow: time admits a partial-time, and
datetime, which had no judge, admits a local date-time, so a default or
enum member written under date-time-local is adopted where it fits and
refused where it carries an offset.

A time-of-day kind carrying an offset was weighed and not taken. JSON
Schema's time is its only spelling among the formats the IR is designed
against, and specs in use write values with no offset under it, so a
kind promising one would make a target refuse them.

BREAKING CHANGE: IRVersion moves to 0.27.0 with no change of shape. A
consumer pinned to 0.26.0 that reads time as carrying an offset finds
values with none, and one that reads the time-local or date-time-local
encodings no longer finds them.
The datetime row beside bytes in TestAdmits_JudgesEachKindExactly
recorded that datetime had no judge. It now repeats the "datetime with
a Z offset" row under a name another row already has, so a failure
there would not say which row failed.

rfc3339Form's doc read as four productions, a partial-time alone or
after a date, each with or without an offset, while the type holds
three: no form reads a partial-time with an offset.

The note in docs/ir/primitives.md on why no time_offset kind exists
named seven of the eight formats the IR is designed against.
Erlang/OTP's calendar:time() holds no offset either.
The string-formats rule gave one reason for every format the table
leaves out, that the compiler knows no registry of formats beyond it,
and listed time among them. That reason does not hold for time: the
table leaves it out because it carries an offset the time primitive
does not hold. The sentence now gives each reason for its own formats.

Under allOf, a Scalar hoisted for time is judged as its base string, as
any format the table leaves out is, though the compiler holds the
full-time grammar. A full-time default or const beside it is no longer
adopted, and time against date-time is reported as an overlap where it
had been an incompatible-types warning. Each losing declaration stays
verbatim. The allof-redeclared-property Status now says so and names
the issue that tracks judging it by its grammar.
"No row maps to its bare type's kind, which leaves the format nowhere"
read as saying the rule leaves the format nowhere. It is a row mapping
to that kind that would, so the sentence says "would" again.
format: time is RFC 3339's full-time, which carries an offset the time
kind does not hold, so it lowers to a Scalar over string whose Encoding
names the format. The allOf fold judged that Scalar as its base string,
as it judges any format the table pairs with no kind. Three verdicts
that had read the full-time grammar became loose: a full-time default
beside it was not adopted, a const that is one did not narrow the
field, and time against date-time was reported as an info overlap,
though no string is both.

The judge now holds a format by name where the table pairs it with no
kind and the compiler knows its grammar, time alone. A Scalar naming a
held format, and nothing else in its Encoding, is read wherever a kind
is: it admits what the full-time grammar admits, sits within string,
overlaps bytes, and is disjoint from every other kind and from an enum
none of whose members it admits. A const it admits narrows it only
where the node says nothing but its base, the format and bounds, so a
description or example the node holds is never lost to the field; one
saying more stays an overlap.

time against time-local, which had been an overlap, is now reported as
incompatible types, since a full-time always carries an offset and a
partial-time never does.
# Conflicts:
#	docs/ir/version-history.md
#	ir/irversion_test.go
A description, example or extension written beside format: time lands
on the Scalar hoisted for the format, not on the property. A const the
Scalar admits therefore cannot narrow it without the field losing that
detail, so the pair is reported as an overlap and both declarations are
kept. Narrowing it needs the merge to build a node that adopts the
broader side's detail, which is what #1042 tracks for lists and maps.
The allof-redeclared-property Status now lists this shape there.
valueKind holds unexported fields, and assert.Equal stands only on a
value with none (assert-equal-on-plain-values). The tests reading
kindOf compared whole valueKinds; each now compares the primitive kind
and the held format, both plain values, so a failure names which of the
two differs.
@OmarAlJarrah
OmarAlJarrah merged commit 02c972b into main Nov 2, 2026
4 checks passed
@OmarAlJarrah
OmarAlJarrah deleted the fix/openapi-local-time-formats branch November 2, 2026 12:50
OmarAlJarrah added a commit that referenced this pull request Nov 2, 2026
#1325 took IRVersion 0.28.0, a change of meaning over 0.27.0's shape,
so this change moves to 0.29.0: main's rows and history entries stand,
and this change's follow under the new number with the same shape hash
as before, since #1325 changed no shape.
OmarAlJarrah added a commit that referenced this pull request Nov 2, 2026
main took IRVersion 0.28.0 for the local time formats (#1325), so the
renamed path item doc keys move to 0.29.0: a row after 0.28.0's with the
same shape and names, and a history entry naming what a consumer pinned
to 0.28.0 sees.
OmarAlJarrah added a commit that referenced this pull request Nov 2, 2026
#1325 took 0.28.0 on main and #1361 moved to 0.29.0, so this branch's
generation is 0.30.0: the constant, its generations row with the shape
recomputed, and its version-history entry.
OmarAlJarrah added a commit that referenced this pull request Nov 2, 2026
Brings in #1229 and #1325.

- Both bump IRVersion, to 0.27.0 and 0.28.0, so this branch's bump
  moves to 0.29.0. Its row takes main's new shape and keeps this
  branch's names and meaning; the version history entry moves with it.
- #1229 gives ir.Parameter a Secret, so a parameter's redaction has a
  home and xml is the one keyword left with none. fillParamSchema keeps
  xml for each voice and the schema, and the wrapper that also kept
  format: password goes. The secret-format golden is regenerated: it
  differs from main's by this branch's cases, and from this branch's by
  the parameter's Secret.
- #1229 reads a target's mapped keys onto a carrier at the end of a
  $ref only. A refinement is a use of what it names, so
  {allOf: [{$ref: T}], description: d} read T's deprecated keyword
  but not its x-stability, where {$ref: T, description: d} read both.
  KeysLentTo now reads the keys along the chain the keywords are read
  along (Use.Referents), nearest first: the nearest link stating a
  deprecation, by a keyword or a key, decides it and lends its members,
  and any link asking for redaction asks it of the use. A position
  owning a node lends its carrier none, as it lends no keyword.
  Promoter.Referent keys what it remembers by the schema's identity, as
  an inline link has no binding position.
  TestPromotion_ARefinementLendsWhatItRefines pins it.
- TestPromotion_ADecoratorMatchesTheRefItStandsFor assumed an allOf of
  one $ref over a string is a decorator. It is a refinement here, so
  the test now holds its carriers to the $ref spelling's, and keeps the
  fold comparison over object targets, where it is still a decorator.
- #1258's example, deprecated: false on such an allOf over a string,
  now reads as beside the $ref, so its status names the object case
  that still parts, and extension-promotion's #1220 entry names what is
  left of it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant