Repository navigation
docs(emitter-design): settle how an SDK reads a response - #1167
Merged
Merged
Conversation
The contract answered what happens to a response separately for each kind of response, and the answers had drifted from the compiler and from each other. Settle them as one contract, from the wire to the bytes on disk. A ResponsePlan holds one election, one set of shapes, one type of any kind and the declared headers, and a success, a failure and each side of a stream read through it. A Go union is a struct holding a sealed interface with a wrapper per variant, so a caller's own json.Unmarshal decodes it wherever it sits, and a polymorphic base lowers the same way. A union a client reads keeps a variant it does not know. An error is a root plus a leaf per distinct body type of any kind, with its fault a value, since one body can arrive under either fault and a default has no class until a status arrives. A stream belongs to one content, per direction, with an element of any kind and a framing the plan derives. A response that declares headers adds a method, so the plain signature never changes. Section 5.6 states the rules the emit layer holds to. Align ir-design's streams and error payloads, and two ir field comments, with what the compiler emits. No lowering or encoded shape changes.
The generator-to-emitter rename replaced the noun and left the article, including at one site that wraps across a line.
Reading the rules against each other showed the fallback for a stream no content frames overrode the protocol framing a gRPC stream takes, and the comparison that gives an operation a second method missed a file or an empty primary. An SSE item is the event whatever its kind, so a union of event shapes reads the frame too. Runtime types a target declares beside spec names are reserved there, derived declarations key under their entity in the manifest, and a stream a caller abandons must be closed.
A Kotlin exception no @throws declares terminates a program that calls it from Swift, and a suspend function with no @throws propagates only cancellation. Operations are suspend functions that throw, so as written any API failure would end an iOS app. Declare the root on every operation, which covers each leaf.
…tract # Conflicts: # docs/emitter-design.md # docs/ir-design.md
A generated error type per body distinguishes what a spec rarely does: most operations with several error bodies use one type for all of them, most specs with a body use one type throughout, and many failures have no body. Status is what tells failures apart, and a status has one class, so kinds nest under faults as a true tree where a body could not. Make a failure one runtime value carrying its status, fault, kind, typed body and headers, with no error type generated per spec. Go answers errors.Is by fault and kind and reads the body through a generic accessor. Kotlin carries the value in an exception by default, through a tree a template renders from the error taxonomy, and a later style is one more value of errorStyle with no plan or IR change.
The contract said how the primary response and every failure are read, and nothing about a declared response that is neither. A 202 beside a 201, a 204 beside a 200 and a 304 had no plan field and two conflicting sentences, one making such a status a failure. OpPlan.Others now holds them. A declared response is never a failure: one that reads as Return is returned as the primary, one with no body gives a nullable return, and a body the method cannot return reaches the caller as the runtime's OtherResponse. A zero value cannot do that last job: Kotlin's Rule has none, and a nil *Rule beside a nil error reads as a created rule. Writing the dispatch down showed two gaps in the default primary. An exact status now wins over a range that starts at it, and an RPC's unconditional response is primary. A primary Default holds only below 400, so a failure status is never read as a success. Reading a sequential body: OpenAPI maps it to an array of its values (3.2 section 4.14.3.1), so a list schema yields its element, whatever the compiler names as Events (#630). The rules that tell a declared Item from a media-type reading are stated against #601, whose proposal would otherwise reverse them. A content a compiler option alone names now streams as bytes rather than decoding as one value, and a primary Default that streams beside a Send is bidi. Also correct what the contract left inconsistent: section 5.2 listed error types as both typed AST and templates, a declared Fault that contradicts its status had two answers, no refine step lowered a polymorphic base, a Go wrapper did not code as its variant, and a ContentRanking entry matched nothing stated, while Framings compared a media type the compiler classifies on a normalized copy.
A response with no body sent a pager down the path meant for a body it cannot return, so a list whose empty page is a 204 raised an error on its last fetch. A pager and a stream now end there, as an empty one does, and a poller reads each response of its start by its LROPlan. A header declared on a response the method reads had no field unless the primary declared it too, and with no header on the primary it reached no caller at all. The WithResponse struct now has a field for every such header, which is safe because every field is optional and no header carries two types across the golden spec and the corpus. Also state that a stream method reads Recv in Return's place, that Kotlin's body accessor checks a class and not its erased arguments, that a Default joins Errors by policy, that the bound on a buffered body covers an OtherResponse, and what Headers is where none is declared.
Nothing said what a client does with a 2xx the document does not list. The text read one as a failure, so a server that started sending a 201 raised an error for a call that worked, and a default catch-all claimed it with its error body. A 2xx no entry answers now reads as Return, and the catch-all of Errors holds for no 2xx, as a primary Default holds for nothing from 400. Nor did anything say what happens when a body a method reads does not decode: only a failure's body had a rule. Such a response has no value to return and is no failure, so it reaches the caller as an OtherResponse with BodyErr set, keeping the status and the bytes and leaving a call that may have acted unretried. A stream frame that does not decode ends the stream the same way. A HEAD response has no body, so a HEAD that declares one is void.
OmarAlJarrah
added a commit
that referenced
this pull request
Oct 30, 2026
tracker-on-what-a-rule-settles said #601 and #630 never appeared in #1167. That held at edf4cf9, which #1170 quotes, and not at the merge, whose body and emitter design name both. The Why now says the PR named neither until a later revision. The lint's docstring gains the form the name-in-file check reads, as it fires on that spelling alone, and loses a broken wrap. A stray blank line inside MainTest goes too.
This was referenced Oct 30, 2026
OmarAlJarrah
added a commit
that referenced
this pull request
Oct 30, 2026
A merge can leave a sentence true of the PR it cites only as that PR once stood. A rule's Why here went stale that way: it said #1167 left two issues unnamed, and #1167 named both before it landed. Nothing in the merging rules asked for such a sentence to be read again, so this adds a rule whose Probe finds each one.
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
The emitter contract answered "what happens to a response" separately for each kind of response, and
the answers had drifted from the compiler and from each other. A Go union had no type to declare its
decoder on. A new variant broke every deployed SDK. An error body had to be a model.
StreamPlancould not be built for any of the golden spec's 13 streaming operations. A success's headers reached
no caller at all. A declared response that was neither the primary nor a failure had no rule, nor
did a success the document does not list or a body that does not decode. The emit layer's own rules
were unwritten.
These are one contract, from the wire to the bytes on disk, so this settles them together in
docs/emitter-design.md, and alignsdocs/ir-design.md§7.2–§7.3,docs/architecture.mdand twoirfield comments with it. No code underemitters/exists yet, so the first one is writtenagainst this rather than the reverse.
ResponsePlanholds one election, one set of shapes, one typeof any kind and the declared headers, and the primary, a failure, any other declared response and
each side of a stream share it.
a caller's own
json.Unmarshalworks wherever the value sits.re-encodes as it came, and an error body that does not decode never hides the status.
a test in §13.
Open, §3.5, §4.4, §10, T-22Errors, §4.2, §4.3, §8.5, §9, T-23WithResponsemethod; the plain signature never changesStreamPlan, §3.5, §8.4, §9, ir-design §7.3, T-24OpPlan.Returnis now aResponsePlan, replacingReturn,ReturnTypeandPrimaryContent,ReturnShapeisBodyShape, since a failure reads through it too, andOpPlan.Othersholds everyother declared response.
Decisions, and where this departs from the issues
Numbers are from the golden spec at
ef1d30e8compiled at morphic0958eaf8, unless a line namesanother revision.
marker is an unexported method, so
go buildrefuses a variant declared in another output unit,which §4.17's split produces. 2,230 of the 3,630 variants are not models, including 456 primitives
and 108 lists and maps, which declare no method at all, and 170 types sit in several unions, one
in 195. Kotlin's variant classes are already this wrapper.
decoding defect. The golden spec has one, and the corpus below has 462.
polymorphic bases, 81 are both read and written. Under the proposal they stay closed, and a new
variant still breaks a deployed SDK on those paths. The fallback re-encodes as it came, so a
read-modify-write is safe, and 618 are open rather than 537.
a generated error type is that a body type tells failures apart, and it does not hold: 453 of the
746 operations with several error bodies use one type for all of them, 172 of the corpus's 205
specs with an error body use a single type, and 8,538 of its 13,079 failures have none. Status is
what tells them apart. So
LowerErrorsgenerates no error type where a leaf per body would havegenerated 1,839: a body is a typed value on one runtime type, read through
sdk.Body[T]on Go ande.body<T>()on Kotlin. It keeps every kind of body, including the 85 error models with a membererror, whereError()beside it does not compile.catch (e: NotFoundException)andcatch (e: ClientException)both work, and on Goerrors.Is(err, sdk.ErrNotFound)anderrors.Is(err, sdk.ErrClient). 43 of 1,839 body typesarrive under both faults and a
defaultunder whichever status comes, so no tree of body typescould do this. The nine kinds of
Policy.Errors' default table cover 2,936 golden failures withan exact status, 93 fall outside and raise their fault's class, and the 2,707 ranges and defaults
take a kind from the status that arrives.
it varies with a spec, and invariant 10 already puts the error taxonomy in policy.
errorStyleisKotlin's setting, defaulting to
exception, the only style M3 builds. AResultreturn is then atemplate and a signature over the same value, with no plan or IR change, so that flexibility costs
nothing now.
stringunder a frame format is not a JSON string, which docs/emitter-design: describe streams the way the IR carries them, not as an event union with framing on the binding #955 did not cover. 7 of the 8server streams have one under
application/x-ndjson. OpenAPI appliesschemato the whole body(3.2 §4.14.3), so it declares no item, and decoding each line as a JSON string would fail on
every real line. Those frames reach the caller as text. Four of the five client streams are files
and have no frames at all. A declared
itemSchemastays decoded.body to an array of its values (3.2 §4.14.3.1), which
schema: {type: array, items: …}thendescribes, where the compiler names the list as
Events(openapi: sequential media types derive the wrong item type #630). The plan reads the list'sElem,so a frame is not decoded as a list. The golden spec has none, and the corpus's two are
type: array, format: event-stream.status with no
Errorsentry as a failure, §8.9 kept a declared one out of that, and neither saidwhat a method returns. The golden spec has 35: 8 read as the primary does, 12 have no body (five
204s, seven304s) and 15 are another type, such as a202ApprovalPendingResponsebeside a201Rule. The first is returned as the primary.The second gives a nullable return and no error, and a pager or a stream simply ends, because it
is usually a plain success (
DELETE200/204,PUT201/204, or an empty list's 204), and an errorthere would report a successful delete as a failure. The
third reaches the caller as an
OtherResponseon the error path, which is noAPIError: it takesno fault, kind or retry, and
catch (e: ApiException)does not catch it. A zero value cannot dothat job: Kotlin's
Rulehas none, a pager has no value to zero, and a nil*Rulebeside a nilerror reads as a created rule. The cost is that a spec's first empty response changes a Kotlin
return, which the surface differ reports as the break it is.
failure, on a spec where 3,731 of 3,769 operations declare a single 2xx, so a server that started
sending a
201would raiseAPIErrorfor a call that worked. Adefaultcatch-all would claim itwith its error body, on 11 operations at
911ad78and 747 in the corpus. It now reads asReturndoes, and the catch-all holds for no 2xx, as a primary
Defaultholds for nothing from 400.OtherResponse, not a failure. Only a failure's body had arule. A
200holding a proxy's HTML, or an unlisted204under avalue, leaves a method nothingto return, and Kotlin's non-null return again rules out a zero value.
BodyErrsays why, the statusand bytes survive, and nothing retries a call that may have acted. A stream frame that does not
decode ends the stream the same way.
(893 of the corpus's 19,563). A result type only when declared would change a method's return type
the moment a spec adds a first header, breaking every caller, against §4.13's rule that a spec's
addition is additive. One on every method would put a field in front of every call for the few that
need it. A sibling leaves the plain signature alone: a first header adds a method and the next a
field. It is the rule streams already take here, a method per shape an operation offers. A header
a second response declares is a field of the same struct, every field being optional, which types
the
Location4 golden operations declare on a202and reaches the 12 corpus operations whosedeclared headers would have reached no caller.
ResponseandAPIErrorbothhave
StatusCode,RawHeader,BodyandHeaders. A failure'sHeadersis the struct its entrydeclares, so two cases sharing a body may differ. A header that does not parse leaves its field
unset rather than failing a response that delivered its body, as a body that does not decode never
hides a failure.
data,event,idandretryis a heuristic only below 3.2. 3.2 defines anitem as the event (§4.14.4), so a declared
Itemneeds no policy. Below it,plan.Policy.FrameMembersdefaults on, because 22 of the corpus's 25 such schemas describe theframe.
os.Root, not the separator-aware comparison docs/emitter-design: rules the emit layer must hold to #709 proposed. A string prefix passesout-evilbeside
out, and a lexical check passes a symlink leaving the tree;os.Rootrefuses both, andits
Renamekeeps a replace atomic.text/templatecalls a pointer-receivermethod on its data, which writes through it, and
{{call}}runs any function a value holds. Sothe rule is on the context's type, and the lint refuses
call. "No re-rendering" reaches targetsyntax too: a Kotlin
$in a string, and a*/in a KDoc comment.Left out
Valueholds when a value satisfies severalanyOfvariants stays docs/emitters: give a union that admits more than one variant (anyOf) a lowering of its own #959's (§4.4).exists, and T-22 holds that.
open; §8.4 and §3.1 say so.
application/json-seqbelow 3.2 is not read as a stream: the plan's table frames it, and thecompiler's default list leaving it out is openapi: the default streaming policy misses application/json-seq #629.
no
WithResponsemethod (§8.9). A body a pager cannot read is anOtherResponse, and a pollertakes each response of its start as its
LROPlansays (§3.1).it from the media type. A compiler that records one on
Contentmust keep it apart from a declaredItem, whichElementandFrameMembersread differently: as openapi: a response offering a plain body and an event stream doesn't record which content streams #601 is written, the 7 goldenbare-string streams would decode as JSON strings, and
TypeRef.Inferredcannot mark the difference,since it speaks only of null.
Events, and §3.1 already reads itsElem, solanding openapi: sequential media types derive the wrong item type #630 changes no plan.
With #1144
#1144 landed first and is merged in.
ResponsePlan.Headersis[]ir.Header, soStyleandExplodereach the reader, and the
Known gap (#1163)sentence it added is gone, since this closes that gap.Small fixes on the way
a emitterbecomesan emitteracrossdocs/, in its own commit.types, so two targets could send different bodies.
RequestContenttakes the same election, whichis what makes "one election" true. No issue had named this.
ContentRanking'sjsonmatched nothing stated, andFramingscomparedContent.MediaTypeexactly, while the compiler classifies a lowercased copy cut at its first
;. §3.1 now states thefour classes and the comparison.
HEADthat declares a body isvoid, since its response has none (RFC 9110 §9.3.2). 6 corpusoperations declare one, and every call to them would have failed to decode.
write
2XXbefore200, and after the 2xx an RPC's unconditional response. A primaryDefaultholds only below 400, so a failure status is never read as a success.
@Throws, without which a Kotlin exception ends a Swift caller'sapp, so any API failure would have crashed an iOS SDK. §8.8 now does, and the root covers every
class of the tree.
models/user.go, a package nothing else in the design has.with it.
anyrow claimedevery byte, which a re-encode does not keep.streaming-mediarule and its code comment said a frame format's schema describes one frame.That is the compiler's reading, not what OpenAPI says, and the plan narrows it.
Test plan
Nothing under
emitters/exists, so no test can disagree with this text, and T-22 to T-27 hold itonce code does. What could disagree was run:
An executable reading of §3.1's rules, run over compiled IR. It places every case, with no
fallback reached:
ef1d30e8911ad78WithResponsemethodThe sweep tries 32 statuses on every operation. Planting a catch-all that holds for a 2xx puts 88
and 5,916 of them on the wrong side (
911ad78, corpus), and a primaryDefaultthat holds from400 puts 512 and 21,416, so the zeros can fail.
911ad78is the revision docs/emitter-design: lower error payloads that are not error-flagged models #960 and docs/emitter-design: describe streams the way the IR carries them, not as an event union with framing on the binding #955 measured, and the one withdefaultresponses: it has 43,and
ef1d30e8none. Its counts of 119 non-model error contents across 71 operations reproduce.§4.4's, §8.5's and §8.9's Go samples, extracted from the final text by script and given the
declarations they name, pass
go vetand run: one model across statuses, a text body, no body, abody that does not decode, an
OtherResponsethat no fault, kind or failure accessor answers, anundecodable primary body keeping its status and bytes, and a wrapper coding as its variant.
vetrefused an earlier design that compiled, so T-23 requires it.Each other Go statement was compiled or run under Go 1.27.2 before it was written down, in a
scratch module: the struct decoding through
json.Unmarshalinto itself, a struct, a slice, a map, a boxand an alias; the fallback's round trip, which keeps member order and number text and normalizes
whitespace and string escapes, so the text promises the value rather than the bytes; a nil
Valuerefusing to encode;recursion through the struct;
errors.Isby fault and kind through a wrapped chain; the two compileerrors;
the three containment checks; and both template holes.
Headers on the same inputs: which responses declare them, under which body shape, how many header
sets each error body has, and whether one header takes two types where a
WithResponsestructwould gather them. None does, across either golden revision and the corpus.
irfacts.Usageon a fixture and the golden spec: a union nested in a response, in an error, on apolymorphic base, written only, and unreferenced.
OpenAPI's text was read at 3.2.0 §4.14.3 and §4.14.4. Kotlin's claims were checked against the
kotlinx.serializationsources, Kotlin's documentation or the compiler's reported message, and none was compiled:no Kotlin toolchain was available. T-22 to T-27 are what will compile them.
Every section citation,
T-Nreference, cited issue and IR identifier the change adds was checkedagainst its target, and the documents were swept for sentences stating the old design.
The rules were read against each other and against protocols other than OpenAPI, which changed
three of them: a gRPC stream keeps
protocolrather than thebytesfallback, a declared union ofSSE event shapes reads the frame (4 of the corpus's 6 SSE unions are frames), and a file or empty
primary beside a stream still gets its second method. Running the rules for responses beside the
primary changed three more: the corpus's
2XXbefore200showed the primary needed an exactstatus first, an invalid status key showed an unconditional response must rank after the 2xx, and
Kotlin's non-null return and the pager ruled out a zero value. The table above is from the
corrected rules.
CI ran both gate jobs on each pushed Go change. Since
67bb33b3the branch has changed Markdownand merged
main, and their,internal/archtest,internal/keydoc,internal/conformanceandOpenAPI end-to-end tests, which read these documents, pass on the last commit's tree.
Closes #1022
Closes #960
Closes #955
Closes #709
Closes #700
Closes #447
Closes #1163
Closes #300