Bring GetPersonProgress's wrapper decode inside the request primitive
#604 isolated the response decoder in every Kotlin and Swift request primitive:
a body that does not decode becomes SPEC §6's statusless, non-retryable
api_error. GetPersonProgress was the one operation that escaped it, because
the half of its envelope that is not the items array was decoded by generated
code running after the primitive returned. It is the SDK's only
wrapped-pagination operation (itemsKey: appears once in Swift generated code;
]!! appeared exactly twice in all of kotlin/**/generated/, both on that
method), and it was the last [manual] carve-out in §6.
It is two SDKs, not six: Go returns decode errors verbatim at its *WithResponse
sites, and TypeScript, Ruby and Python never typed-decode the
wrapper at all. The sibling GetUpcomingSchedule decodes typed inside the
primitive and was already safe — verified, untouched.
Closes #728.
The contract question: both keys are required
bc3's app/views/api/users/timelines/show.json.jbuilder, in full:
json.person @person, partial: "people/person", as: :person
json.events @events, partial: "timelines/events/event", as: :event
No conditional. Users::TimelinesController#set_person is a before_action
that finds or 404s, and #show assigns @events for every api_request?.
Both keys are always on the wire, so an absent one is malformed, not empty.
That settles both halves and makes the two SDKs agree:
- Swift's
guard let itemsArray = json[key] else { return [] }was wrong — and
so was theas? [String: Any] ?? [:]non-object fallback. Both are now
refusals. - Kotlin's
!!was right in intent, wrong in type. It is no longer emitted; NPE
is not caught (mapping it would swallow genuine programming errors).
The fix is generator-side
Both service emitters changed, then make generate re-emitted the services:
| before | after | |
|---|---|---|
| Kotlin items | parseToJsonElement(body).jsonObject["events"]!!.jsonArray.map { … } | decodeFromJsonElement<List<TimelineEvent>>(decodeFromString<JsonObject>(body).requiredMember("events")) |
| Kotlin wrapper | decodeFromJsonElement<Person>(wrapper["person"]!!) | decodeFromJsonElement<Person>(wrapper.requiredMember("person")) |
| Swift items | ?? [:] + else { return [] } | DecodingError.typeMismatch / .keyNotFound / .typeMismatch |
| Swift wrapper | try Self.decoder.decode(Wrapper.self, …) after the primitive returned | the same decode, inside the primitive's decoding(_:_:) |
requiredMember is a new internal extension in
com.basecamp.sdk.serialization — no public surface — whose only job is to make
absence speak SerializationException, the one type decodeOrApiError maps.
The Kotlin visibility decision, and why Swift got the same shape
decodeOrApiError is private and the generated ReportsService is a
subclass, so the person half could not be wrapped without either widening it
to protected or threading a decode lambda through requestPaginatedWrapped.
(The events half needed neither — the items lambda already runs inside the
isolation.)
Decision: keep decodeOrApiError private and thread the lambda. Reasoning,
taken fresh rather than inherited from #772:
- The two options cost the same surface.
protectedon a publicopen
class is public API; so is changing an existingprotectedsignature. There
is no cheap option, so cost does not decide it. - They buy different guarantees. A
protectedhelper is a guarantee the
generated code has to opt into every time someone adds a wrapped operation.
Threading the lambda means there is no way to reach the wrapper body at all
without passing through the mapping — the next wrapped operation inherits it. - Swift did not strictly need a visibility change (
decodingisinternal
and generated services are in-module), so symmetry alone would not have
justified it. What did:requestPaginatedWrappedispublicand was handing
rawDataback out of a decode-isolating primitive. Removing that from the
public surface is an improvement on its own terms. - Ordering fell out of it.
finishWrappedin both SDKs is the single exit,
because the wrapper decode must run beforeonOperationEndfires. Otherwise
a malformed wrapper is reported to hooks as a success and then thrown — which
is precisely what happened while the decode lived in generated code.
Source break for anyone subclassing BaseService directly (generated services
are its only callers here); documented in MIGRATING.
Red proof — per case, both SDKs, with the regenerate step
A mutation that only edits a generator proves nothing, so the harness reverts
the four hand-edited files to HEAD, regenerates, and only then runs.
Restore is cp + diff -q throughout — never git checkout --, never
git stash.
Regeneration under the pre-fix generators put the old accessors back, verified
before the run:
--- emitted Kotlin items/wrapper accessors:
156: json.parseToJsonElement(body).jsonObject["events"]!!
162: person = json.decodeFromJsonElement<Person>(wrapper["person"]!!)
--- emitted Swift wrapper decode:
70: let (wrapperData, items): (Data, ListResult<TimelineEvent>) = try await requestPaginatedWrapped(
81: let wrapper = try Self.decoder.decode(Wrapper.self, from: wrapperData)
RED — Kotlin (REAL_EXIT=1, 16 tests completed, 5 failed) — note the
exception class per case, all of them raw:
aNonObjectWrappedBodyIsAStatuslessApiError() FAILED
Caused by: java.lang.IllegalArgumentException
aNonArrayItemsKeyIsAStatuslessApiError() FAILED
Caused by: java.lang.IllegalArgumentException
aWrongTypedWrapperMemberIsAStatuslessApiError() FAILED
Caused by: kotlinx.serialization.json.JsonDecodingException
anAbsentWrapperMemberIsAStatuslessApiError() FAILED
Caused by: java.lang.NullPointerException
anAbsentItemsKeyIsAStatuslessApiError() FAILED
Caused by: java.lang.NullPointerException
RED — Swift (REAL_EXIT=1, 16 tests, 6 failures / 3 unexpected). The
events case is the sharp one: it did not throw the wrong error, it did not
throw at all.
testAnAbsentItemsKeyIsAStatuslessApiError : failed - expected a malformed wrapper to fail
testAnAbsentItemsKeyIsAStatuslessApiError : XCTAssertTrue failed - the absent member must be named, got:
testANonObjectWrappedBodyIsAStatuslessApiError : failed: caught error: "DecodingError.typeMismatch: Expected value of type Dictionary<String, Any> … but found an array instead."
testAnAbsentWrapperMemberIsAStatuslessApiError : failed: caught error: "DecodingError.keyNotFound: Key 'person' not found …"
testAWrongTypedWrapperMemberIsAStatuslessApiError : failed: caught error: "DecodingError.typeMismatch: … Path: person … but found number instead."
testANonArrayItemsKeyIsAStatuslessApiError : XCTAssertTrue failed - the wrong-typed member must be named
(testANonObjectWrappedBodyIsAStatuslessApiError and
testAnAbsentItemsKeyIsAStatuslessApiError are the two that used to return
events: [] silently; the first surfaces here only because the wrapper decode,
still outside the isolation, then threw on person.)
Restore, regenerate with the fixed generators, and the regenerated output was
byte-identical to the committed tree (diff -q clean on both emitted files).
GREEN — Kotlin BUILD SUCCESSFUL, REAL_EXIT=0.
GREEN — Swift Executed 16 tests, with 0 failures, REAL_EXIT=0.
Cases covered, in both SDKs: wrapper missing events; wrapper missing person;
person of the wrong type; events non-array; non-object top-level body; plus
a well-formed control that still decodes.
Carve-out deleted
SPEC.md— the paragraph beginning "The primitive is the boundary, and
one decode currently falls outside it." is gone. One sentence was added to
the paragraph above it, stating the contract the spec never carried (an
absent envelope member is a malformed body, not an empty result) rather than
the mechanism, which lives in the two SDKs' own comments.MIGRATING.md— "One operation is exempt, and it isGetPersonProgress"
and the forward reference to it are gone, replaced by what a caller has to do:
Swift's silent empty list is a behaviour change, and
requestPaginatedWrappedchanged shape for subclassers.
The section keeps its [manual] tag, deliberately. [conformance] claims
the conformance suite verifies it, and the suite is cross-SDK by construction.
This shape exists only where a typed decoder does; Go, TypeScript, Ruby and
Python would each need a skip for a malformed-wrapper case, which is exactly
what SPEC §19's zero-skip roster exists to stop. The evidence stays in per-SDK
unit tests, where #604 put it. Nothing else keys off the tag — §19's roster is
generated from spec/zero-skip-roster.yml and was not touched.
make check-fixture-coverage wanted nothing: 33 covered schemas, 51 manifest
targets, all accounted for. No new conformance suite was needed —
GetPersonProgress already has dispatch in all six runners via paths.json.
CI-only failure, fixed in 82914765d
Two of the new Swift assertions matched on 'events', apostrophes included.
That passed on my Swift 6.4 and failed on CI with both SDKs behaving
identically: 6.4 has a CustomStringConvertible for DecodingError and prints
the nested debugDescription verbatim, while the CI toolchain reflects the enum
and escapes the inner string's apostrophes to \'. The assertion was pinned to
the renderer, not the behaviour. It now checks the member name and the complaint
separately, which survive either rendering — and mutating the two
debugDescriptions in BaseService to "vanished" / "is wrong" fails both
tests, so the replacement is not vacuous.
The Conformance Tests job failed only as the downstream aggregator of that job.
Review round 1 — four findings, all taken (f3d1b6419)
- The Smithy model contradicted the SPEC sentence this PR adds.
GetPersonProgressOutput.person/.eventswere still optional, so
openapi.jsonhad norequiredarray and the four SDKs that never
typed-decode this envelope described always-present members as optional. Both
are@requirednow; 7 files regenerated. Two hand-written sites followed the
generated types rather than being patched around:timeline.godropped its
!= nilbranch (and still returns*Person, so no Go caller breaks), and
PublicAPIConsumermoved off the zero-argument init the model no longer has.
The one real cost, named rather than hidden: Go stops distinguishing an absent
personfrom a zero-valued one, which is a choice between two wrong answers on
a body BC3 cannot produce, and Go never errored on it either way. finishWrapped's ordering was documented and untested — hoisting the hook
above the decode would have passed the whole file. Both SDKs now assert exactly
oneonOperationEndcarrying the mapped error for a malformed wrapper and
exactly one success for a well-formed one. Verified by mutation: the hoist
fails that test and only that test, in each SDK.- Swift's
requestPaginatedWrappeddoc still described the removed contract
on apublicmethod. Rewritten.
Review round 2 — three suppressed findings, all taken (e1895b6e6)
Copilot suppressed three comments on the second pass. All three were correct
about the code, and all three were about my prose rather than the change:
- The Kotlin test comment said both halves of the envelope decoded after the
primitive returned. Onlypersondid —parseItemshas always run inside
decodeOrApiError, andeventssurfaced raw for the other reason, the
unmappedNullPointerException/IllegalArgumentException. A boundary problem
and an exception-type problem; that distinction is why the fix has two halves. - The Swift comment on the non-array case had the mechanism backwards.
{}is
valid input todata(withJSONObject:), so that case was already an
api_errorand the guard adds a message naming the member. What the guard
genuinely prevents is a string or number at that key, which
data(withJSONObject:)answers with an uncatchable
NSInvalidArgumentException. GetPersonProgressResponseContentwas still sitting in the all-optional
roster with a comment saying it had left it. Moved to
requiredMemberModelsKeepTheirInitShape; roster count 35 → 34.
One correction that lands on the red proof above: of the five malformed
shapes, the non-array events case is a genuine before/after in Kotlin only
(raw IllegalArgumentException → statusless api_error). In Swift it was
already an api_error; what changed there is the message. The other four are
genuine before/after in both SDKs, and the two silent-empty-list cases are
Swift-only by construction.
Review round 3 — five suppressed findings, all taken (76c9c93a1)
- "The four SDKs that never typed-decode this envelope" is wrong about Go.
ParseGetPersonProgressResponseunmarshals straight into
GetPersonProgressResponseContent. Go typed-decodes; what it lacks is any
notion of a required member, becauseencoding/jsonhas none. Only
TypeScript, Ruby and Python skip the typed decode. Fixed in the Smithy comment
and MIGRATING, which both used the wrong grouping to justify@required. - MIGRATING repeated the boundary-vs-exception-type conflation and said a
wrong-typedpersonleaked an NPE — it leaked aSerializationException; the
NPE was the!!on an absent one. Split into the two mechanisms. - MIGRATING understated the
@requiredbreak. Not only construction and not
only Go/Swift: TypeScript object literals, PythonTypedDictliterals, and
Go/Swift!= nil/if let/ reassignment all break. Now a table, one row per
SDK, including Ruby's "nothing". - The non-array test did not exercise the crash it was documented against.
{}is valid input todata(withJSONObject:); a scalar is not, and reaches an
NSInvalidArgumentExceptionthat is not a Swift error and cannot be caught.
AddedtestAScalarItemsKeyIsRefusedRatherThanCrashing, verified the only way
it can be: deleting the type guard does not fail the test, it terminates the
runner with an uncaught NSException.
Review round 4 — one overstatement, in four places (223ae6783)
Copilot's third pass found the same error four times, and it is right. Only
one malformed wrapper used to come back as a success: an absent events
alongside a valid person. A non-object top-level body, and a non-array
events, reached the same ?? [:] / return [] fallback — but the wrapper
decode rejected the body a line later, so those operations did fail, with a
raw DecodingError about person, naming the wrong member.
That distinction was already visible in the red proof above (the non-object case
threw before the fix) and in the fixtures (the absent-events case carries a
well-formed person precisely so the silence is attributable to the items key),
but MIGRATING, decodeWrappedItems' doc and two test comments all said "silent
empty list" for all of them. Corrected. The earlier Swift commit message carries
the overstatement too, and 223ae6783 is the correction of record.
Review round 5 — and a change of instrument (7c644129f)
Two more, both true: Kotlin's absent person raised an NPE from
wrapper["person"]!!, not the SerializationException a wrong-typed one
raised; and a non-array events never reached Swift's empty-list fallback —
{} serialized fine and the array decoder already produced a mapped
api_error, while a scalar aborted the process.
That is four rounds correcting the same paragraph, which is a signal about the
instrument rather than a queue of fixes: prose was flattening six bodies × two
SDKs into one sentence and losing a distinction every time. It is a table
now, one row per body, two columns of prior behaviour — and every cell was
produced by running the tests against the pre-change generators and primitives,
not reasoned about. That turned up one cell no test covered, so the Kotlin suite
gains aScalarItemsKeyIsAStatuslessApiError; against the base commit it fails
with the IllegalArgumentException the table claims.
Net for the red proof: 18 Kotlin tests, 7 failing against base (was 5 —
the scalar case and the hook-ordering case joined), and 439 Swift tests green,
with testAScalarItemsKeyIsRefusedRatherThanCrashing provably crashing the
runner if its guard is deleted.
Gates
| gate | locale | exit |
|---|---|---|
make kt-check | LC_ALL=C | 0 |
make kt-check-drift | LC_ALL=C | 0 (no drift) |
make swift-check | LC_ALL=C | 0 (431 → 437 tests) |
make doc-constants-check | LC_ALL=C | 0 (27 marked spans across 7 files) |
make check-fixture-coverage | LC_ALL=C | 0 |
make check (full, incl. conformance) | LC_ALL=C.UTF-8 | 0 (re-run green after the review round) |
Locale note: the full make check ran under LC_ALL=C.UTF-8, not LC_ALL=C,
because the conformance runner is broken under LC_ALL=C — that is #774 and is
unrelated to this change. Every gate that does not go through the runner was run
under LC_ALL=C as well, as the table shows.