Repository navigation
fix!: type the local time formats and keep time's offset - #1325
Merged
Merged
Conversation
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.
This was referenced Nov 2, 2026
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.
This was referenced Nov 2, 2026
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.
This was referenced Nov 2, 2026
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
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.
5 of 6 tasks
OmarAlJarrah
added a commit
that referenced
this pull request
Nov 2, 2026
5 of 6 tasks
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.
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
Summary
format: date-time-localandformat: time-localeach hoisted aScalaroverstringthat kept the format as its encoding, although the IR has a primitive for each:datetime, a date and time without offset, andtime.format: timeis RFC 3339'sfull-time, which carries an offset, and it lowered totime. The doc ontimesaid only "a time of day without date", and the emitter design typestimeas a local time (LocalTime, the Go runtime'sTimeOfDay), so the offset had nowhere to go.maindtlScalaroverstring,Encoding.Name: date-time-localt/prim/datetimetlScalaroverstring,Encoding.Name: time-localt/prim/timett/prim/timeScalaroverstring,Encoding.Name: timetimeis documented as holding no offset, asdatetimedoes besidedatetime_offset(ir/types.go, and a newdocs/ir/primitives.md#offsets, which states each time kind in RFC 3339's terms).time-localtotimeanddate-time-localtodatetime, and has notimerow, sotimekeeps its format on a hoistedScalarthe way any format without a primitive does (docs/compilers/openapi.md#string-formats).timenow admits an RFC 3339partial-time, where before it admitted afull-time.datetimehad 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"]}getsValueType: time, wheremainkept the format verbatim underUnmodeledwith an info.allOf, a broader declaration'sdefault: "2024-01-01T00:00:00"is adopted besideformat: date-time-local, and"2024-01-01T00:00:00Z"is refused.mainrefused both.allOf, theScalarthatformat: timenow lowers to is still judged by RFC 3339'sfull-timegrammar, so the fold judges it asmaindid whiletimewas the kind, but for the two cases "allOfunderformat: time" below names.Design
The alternative was a
time_offsetkind besidedatetime_offset. It was weighed and not taken, anddocs/ir/primitives.md#offsetsrecords why, next to the chosen design:timeis the only way to write a time of day with an offset in any format the IR is designed against. TypeSpec'splainTime, Protobuf'sgoogle.type.TimeOfDayand Erlang/OTP'scalendar:time()hold no offset, and Smithy, GraphQL and Swagger 2.0 have no time of day at all.time. I tested this on a local corpus of about 1,800 real-world OpenAPI documents withgrep -rnE -A4 'format"?\s*:\s*"?time"?\s*,?\s*$' <corpus>. Each value written next toformat: time(enum members and a default) has no offset. A kind promising an offset would make a target refuse those values.Scalargives 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-localandtime-localreached no kind before, andformat: timereached 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'returns0atcloudflare/api-schemas@319dc756.What reads these kinds
Each place that reads
timeordatetime, and what it does now:merge.AdmitsandliteralAgainst. Changed as described above.spelling.Relate. It reads the table.datetimenow refinesstringand is disjoint from every other format butbinary, which overlaps each of them, andrelationRootsin its test no longer lists it. So underallOf,format: date-timeredeclared asformat: date-time-localnow draws anopenapi/conflicting-redeclarationwarning (incompatible types), asuuidagainstdate-timedoes, wheremainreported an info overlap against the hoistedScalar. No string is a date-time both with and without an offset.schema.enumValueType. It goes throughAdmits.datetimecan now be anEnum.ValueType, which puts it in the same position asdate,timeanduuid: it has no Go constant form, which docs/emitters: a closed enum whose value type has no Go constant has no lowering #1202 tracks.format: time. It now keeps the format verbatim underUnmodeledwith an info, as anemailenum does. Before, members carrying an offset gave itValueType: time.allOfunderformat: time.mergeholds the grammar oftime, RFC 3339'sfull-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 aScalarhoisted fortimeas a string format of its own. ThatScalarsits withinstring, overlapsbinary, 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 onmain, afull-timedefaultbeside it is adopted, aconstthat is one narrows the field, andtimeagainstdate-timeis anopenapi/conflicting-redeclarationwarning.timeagainsttime-localis now that warning too, wheremainreported an info overlap, since no string is both. Aconstnarrows theScalaronly where the node says nothing but its base, the format and bounds. A description, example or extension written besideformat: timelands on that node, so with one the pair stays an overlap, and both declarations are kept and reported.mainnarrowed that case, since it put the description on the property beside a baretime. 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-propertystates each verdict.PrimKind "datetime"comes out oftestdata/unwitnessed.golden.txt, sincescalar-formatnow sets it.time(TimeOfDay,LocalTime) already read it as offset-free, anddatetimealready has rows. No{string, "time"}row exists, so a target falls back tostringwith theunmapped-encodinginfo, perdocs/emitters/contract/type-table.md.docs/fortimeanddatetimestatements:git grep -nE 'PrimTime|PrimDatetime|`time`|`datetime`' -- docs. The one that assumed the old set was the list of string-spelled kinds inprimitives.md, which now includesdatetime.A
defaultthat its own format refuses, such as{format: time-local, default: "10:15:30Z"}, is kept with no diagnostic.mainbehaves the same for every format ({format: uuid, default: "hello"}), and #643 tracks it.Test plan
allOfdefaults under each format, withmorphic compile --verifyonmainand on this branch. The table and bullets above are what that run printed. Exit code was 0 on both.datetimeadmitting nothing:TestAdmits_JudgesEachKindExactly.timejudged as adate-time:TestAdmits_JudgesEachKindExactly.TestAdmits_JudgesEachKindExactly.TestAdmits_JudgesEachKindExactlyandTestFold_AnInadmissibleValueIsKeptNotAdopted.datetimeoverlap restored inliteralAgainst:TestTypeJudge_RelateArms, through the two rows this PR adds. Without them, nothing went red.time-localordate-time-localrow removed, or atimerow restored:TestFormatTable_EveryStringRowLowersToItsPrimitiveand thescalar-formatconformance case.mainagainst 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 underformat: 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).allOfjudge fortimeis pinned bymerge.TestAdmitsFormat_JudgesTimeAsAFullTime,merge.TestAdmitsFormat_SharesNoStringWithAKind,merge.TestRelateKinds_ReadsAHeldFormatAsAStringFormat,merge.TestKindOf_TheFirstEncodingDecidesTheFormat, new rows inmerge.TestTypeJudge_RelateArmsandmerge.TestTypeJudge_AdmitsPerNode, andschema.TestAllOf_ATimeIsJudgedByItsGrammar, which compiles each case in both branch orders. I planted 31 defects in it: in the grammar, in whichEncodingcounts as a format, in each relationformatAgainstreturns, 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.allOfjudge change gives identical IR and diagnostics on all 2,201 specs I swept (every file in the corpus above with anopenapi,swaggerorasyncapikey, plustestdata/), and on the golden spec atcloudflare/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 -updatechanged onlyscalar-format.golden.json, by the three properties.go test ./cmd/morphic-census -updateremoved only thedatetimeline.Breaking
IRVersionmoves from 0.27.0 to 0.28.0, with no change of shape. Thegenerationsrow says what meaning changed. A consumer pinned to 0.27.0 refuses the document. If it read the new document anyway, it would see:timeto carry one;format: timearriving as aScalarwithEncoding.Name: time;time-localordate-time-localencodings where it used to find them.#1229 took 0.27.0 first, so this branch merged
mainand takes 0.28.0. #1269, #1333, #1340 and #1361 also moveIRVersion; whichever of these merges after another takes the next free version.Closes #869
Closes #1353