Skip to content

docs(emitter-design): settle how an SDK reads a response - #1167

Merged
OmarAlJarrah merged 10 commits into
mainfrom
docs/emitter-body-contract
Oct 30, 2026
Merged

OmarAlJarrah merged 10 commits into
mainfrom
docs/emitter-body-contract

Conversation

@OmarAlJarrah

@OmarAlJarrah OmarAlJarrah commented Oct 30, 2026 •

Copy link
Copy Markdown
Member

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. StreamPlan
could 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 aligns docs/ir-design.md §7.2–§7.3, docs/architecture.md and two
ir field comments with it. No code under emitters/ exists yet, so the first one is written
against this rather than the reverse.

  1. Every response is read one way. ResponsePlan holds one election, one set of shapes, one type
    of any kind and the declared headers, and the primary, a failure, any other declared response and
    each side of a stream share it.
  2. Every type a body names decodes itself. A Go union is a struct holding a sealed interface, so
    a caller's own json.Unmarshal works wherever the value sits.
  3. What the SDK does not recognize is kept, not fatal. A new variant lands in a fallback that
    re-encodes as it came, and an error body that does not decode never hides the status.
  4. What the plan decides reaches the output intact. §5.6 states the emit layer's rules, each with
    a test in §13.
Issue Where What it settles
#1022 §4.4, §4.8, §4.12, §4.16, §8.2, §9, T-2 a struct declares a union's codec; a wrapper per variant; a polymorphic base lowers the same way
#700 §3.2 Open, §3.5, §4.4, §10, T-22 a union a client reads is open, as a plan fact docs and mocks share; one decode order for all four wire shapes
#960 §3.1 Errors, §4.2, §4.3, §8.5, §9, T-23 a failure is one runtime value keyed by its status, with a typed body of any kind; how it reaches a caller is the target's
#447 §3.1, §8.5 an error gets its headers and the same content election as a success
#1163 §3.1, §4.2, §8.9, T-26 a response that declares headers adds a WithResponse method; the plain signature never changes
#955 §3.1 StreamPlan, §3.5, §8.4, §9, ir-design §7.3, T-24 a stream belongs to a content, per direction; the element is of any kind; framing is a plan fact
#709 §5.6, T-25 the rules the emit layer holds to, the engine's write included

OpPlan.Return is now a ResponsePlan, replacing Return, ReturnType and PrimaryContent,
ReturnShape is BodyShape, since a failure reads through it too, and OpPlan.Others holds every
other declared response.

Decisions, and where this departs from the issues

Numbers are from the golden spec at ef1d30e8 compiled at morphic 0958eaf8, unless a line names
another revision.

  • A wrapper for every variant, which docs/emitter-design: a Go union's codec has no type to be declared on #1022 did not ask for. The struct alone is not enough. The
    marker is an unexported method, so go build refuses 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.
  • A polymorphic base lowers as a union. §8.8 made it a sealed interface, which has the same
    decoding defect. The golden spec has one, and the corpus below has 462.
  • Open wherever a client reads it, not "only in output" as docs/emitter-design: an unknown union variant is always a decode error #700 proposed. Of the 972 unions and
    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 failure is a value keyed by its status, which departs from §8.5 and docs/emitter-design: lower error payloads that are not error-flagged models #960. The premise under
    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 LowerErrors generates no error type where a leaf per body would have
    generated 1,839: a body is a typed value on one runtime type, read through sdk.Body[T] on Go and
    e.body<T>() on Kotlin. It keeps every kind of body, including the 85 error models with a member
    error, where Error() beside it does not compile.
  • Kinds nest under faults as a true tree, which a body could not. A status has one class, so
    catch (e: NotFoundException) and catch (e: ClientException) both work, and on Go
    errors.Is(err, sdk.ErrNotFound) and errors.Is(err, sdk.ErrClient). 43 of 1,839 body types
    arrive under both faults and a default under whichever status comes, so no tree of body types
    could do this. The nine kinds of Policy.Errors' default table cover 2,936 golden failures with
    an 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.
  • The exception is a presentation of that value. It is a runtime template, since nothing about
    it varies with a spec, and invariant 10 already puts the error taxonomy in policy. errorStyle is
    Kotlin's setting, defaulting to exception, the only style M3 builds. A Result return is then a
    template and a signature over the same value, with no plan or IR change, so that flexibility costs
    nothing now.
  • A bare string under 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 8
    server streams have one under application/x-ndjson. OpenAPI applies schema to 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 itemSchema stays decoded.
  • A list under a frame format is the body, so a frame is its element. OpenAPI maps a sequential
    body to an array of its values (3.2 §4.14.3.1), which schema: {type: array, items: …} then
    describes, where the compiler names the list as Events (openapi: sequential media types derive the wrong item type #630). The plan reads the list's Elem,
    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.
  • A declared response beside the primary is never a failure, which no issue named. §8.5 read a
    status with no Errors entry as a failure, §8.9 kept a declared one out of that, and neither said
    what a method returns. The golden spec has 35: 8 read as the primary does, 12 have no body (five
    204s, seven 304s) and 15 are another type, such as a 202 ApprovalPendingResponse beside a
    201 Rule. 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 (DELETE 200/204, PUT 201/204, or an empty list's 204), and an error
    there would report a successful delete as a failure. The
    third reaches the caller as an OtherResponse on the error path, which is no APIError: it takes
    no fault, kind or retry, and catch (e: ApiException) does not catch it. A zero value cannot do
    that job: Kotlin's Rule has none, a pager has no value to zero, and a nil *Rule beside a nil
    error 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.
  • A 2xx the document does not list is a success, which no issue named. §8.5 and §8.9 read one as a
    failure, on a spec where 3,731 of 3,769 operations declare a single 2xx, so a server that started
    sending a 201 would raise APIError for a call that worked. A default catch-all would claim it
    with its error body, on 11 operations at 911ad78 and 747 in the corpus. It now reads as Return
    does, and the catch-all holds for no 2xx, as a primary Default holds for nothing from 400.
  • A body that does not decode is an OtherResponse, not a failure. Only a failure's body had a
    rule. A 200 holding a proxy's HTML, or an unlisted 204 under a value, leaves a method nothing
    to return, and Kotlin's non-null return again rules out a zero value. BodyErr says why, the status
    and bytes survive, and nothing retries a call that may have acted. A stream frame that does not
    decode ends the stream the same way.
  • A sibling method for headers, of docs/emitter-design: say how a client reads a success response's headers #1163's options. 40 of 3,769 operations declare success headers
    (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 Location 4 golden operations declare on a 202 and reaches the 12 corpus operations whose
    declared headers would have reached no caller.
  • Errors carry typed headers too, so the two sides read alike. Response and APIError both
    have StatusCode, RawHeader, Body and Headers. A failure's Headers is the struct its entry
    declares, 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.
  • Reading SSE's data, event, id and retry is a heuristic only below 3.2. 3.2 defines an
    item as the event (§4.14.4), so a declared Item needs no policy. Below it,
    plan.Policy.FrameMembers defaults on, because 22 of the corpus's 25 such schemas describe the
    frame.
  • os.Root, not the separator-aware comparison docs/emitter-design: rules the emit layer must hold to #709 proposed. A string prefix passes out-evil
    beside out, and a lexical check passes a symlink leaving the tree; os.Root refuses both, and
    its Rename keeps a replace atomic.
  • docs/emitter-design: rules the emit layer must hold to #709's read-only and hermetic rules needed a shape. text/template calls a pointer-receiver
    method on its data, which writes through it, and {{call}} runs any function a value holds. So
    the rule is on the context's type, and the lint refuses call. "No re-rendering" reaches target
    syntax too: a Kotlin $ in a string, and a */ in a KDoc comment.

Left out

With #1144

#1144 landed first and is merged in. ResponsePlan.Headers is []ir.Header, so Style and Explode
reach the reader, and the Known gap (#1163) sentence it added is gone, since this closes that gap.

Small fixes on the way

  • a emitter becomes an emitter across docs/, in its own commit.
  • No rule elected a request's media type, though 19 operations offer several, 11 with different
    types, so two targets could send different bodies. RequestContent takes the same election, which
    is what makes "one election" true. No issue had named this.
  • ContentRanking's json matched nothing stated, and Framings compared Content.MediaType
    exactly, while the compiler classifies a lowercased copy cut at its first ;. §3.1 now states the
    four classes and the comparison.
  • A HEAD that declares a body is void, since its response has none (RFC 9110 §9.3.2). 6 corpus
    operations declare one, and every call to them would have failed to decode.
  • The default primary is an exact status before a range that starts there, since 28 corpus operations
    write 2XX before 200, and after the 2xx an RPC's unconditional response. A primary Default
    holds only below 400, so a failure status is never read as a success.
  • Nothing said an operation declares @Throws, without which a Kotlin exception ends a Swift caller's
    app, so any API failure would have crashed an iOS SDK. §8.8 now does, and the root covers every
    class of the tree.
  • §8.1 put a model in models/user.go, a package nothing else in the design has.
  • §16 Q3 still asked how a content tie breaks, which §3.1 now settles, and ir-design's Q3 is settled
    with it.
  • §9's any row claimed every byte, which a re-encode does not keep.
  • The streaming-media rule 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 it
once 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:

    golden ef1d30e8 golden 911ad78 1,859 corpus documents
    operations given a body shape 3,769 of 3,769 3,761 of 3,761 19,563 of 19,563
    failures placed 5,736 of 5,736 5,474 of 5,474 13,079 of 13,079
    streams given a side 13 of 13 13 of 13 55 of 55
    operations offering a value and a stream 7 7 12
    other declared responses placed 35 of 35 35 of 35 1,128 of 1,128
    declared statuses answered on the side they declare 9,530 of 9,530 9,217 of 9,217 29,717 of 29,717
    swept statuses placed, no 2xx a failure and none from 400 a success 120,608 of 120,608 120,352 of 120,352 626,016 of 626,016
    operations with a WithResponse method 44 44 1,246

    The 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 primary Default that holds from
    400 puts 512 and 21,416, so the zeros can fail.

    911ad78 is 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 with default responses: it has 43,
    and ef1d30e8 none. 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 vet and run: one model across statuses, a text body, no body, a
    body that does not decode, an OtherResponse that no fault, kind or failure accessor answers, an
    undecodable primary body keeping its status and bytes, and a wrapper coding as its variant. vet refused 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.Unmarshal into itself, a struct, a slice, a map, a box
    and 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 Value refusing to encode;
    recursion through the struct; errors.Is by fault and kind through a wrapped chain; the two compile
    errors;
    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 WithResponse struct
    would gather them. None does, across either golden revision and the corpus.

  • irfacts.Usage on a fixture and the golden spec: a union nested in a response, in an error, on a
    polymorphic 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.serialization sources, 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-N reference, cited issue and IR identifier the change adds was checked
    against 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 protocol rather than the bytes fallback, a declared union of
    SSE 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 2XX before 200 showed the primary needed an exact
    status 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 67bb33b3 the branch has changed Markdown
    and merged main, and the ir, internal/archtest, internal/keydoc, internal/conformance and
    OpenAPI 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

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
OmarAlJarrah merged commit af1aae9 into main Oct 30, 2026
2 checks passed
@OmarAlJarrah
OmarAlJarrah deleted the docs/emitter-body-contract branch October 30, 2026 17:13
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.
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment