Swift and Rust refuse person shapes the reference writes back - #916
Merged
Merged
Conversation
An absent person id and a null element of a person list decode to 0 and the zero person in Go, and a merge-safe write sends them back. Swift and Rust refused both at the generated model. Their generators now key a person on the required flexible id, default an absent id to 0 (an explicit null still fails), and read a null element of a person list as the zero person. Plain-int64 person types stay strict.
There was a problem hiding this comment.
🟢 Approved
Generator changes preserve strict boundaries while comprehensive regression tests cover the intended decoding and write-back behavior.
Pull request overview
Aligns Swift and Rust person decoding with Go for missing IDs and null list elements.
Changes:
- Updates generators and decoding helpers for zero-person semantics.
- Regenerates affected Swift and Rust models.
- Adds regression tests and documents remaining divergences.
[!TIP]
If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or rungh pr ready --undo.
Click "Ready for review" or rungh pr readyto reengage.
File summaries
| File | Description |
|---|---|
SPEC.md |
Documents decoding behavior and intentional divergences. |
swift/Tests/BasecampTests/PersonShapesTests.swift |
Tests Swift reads and merge-safe writes. |
swift/Sources/BasecampGenerator/ModelEmitter.swift |
Generates person-specific Codable implementations. |
swift/Sources/Basecamp/PersonList.swift |
Adds null-element person-list decoding. |
swift/Sources/Basecamp/FlexibleInt.swift |
Updates missing-ID and float-divergence documentation. |
swift/Sources/Basecamp/Generated/Models/Todo.swift |
Applies person-list decoding. |
swift/Sources/Basecamp/Generated/Models/Subscription.swift |
Applies person-list decoding. |
swift/Sources/Basecamp/Generated/Models/SearchResult.swift |
Applies person-list decoding. |
swift/Sources/Basecamp/Generated/Models/ScheduleEntry.swift |
Applies participant decoding. |
swift/Sources/Basecamp/Generated/Models/ProjectAccessResult.swift |
Applies access-list decoding. |
swift/Sources/Basecamp/Generated/Models/Person.swift |
Defaults missing IDs and defines the zero person. |
swift/Sources/Basecamp/Generated/Models/Notification.swift |
Applies participant decoding. |
swift/Sources/Basecamp/Generated/Models/CardTable.swift |
Applies subscriber decoding. |
swift/Sources/Basecamp/Generated/Models/CardStep.swift |
Applies assignee decoding. |
swift/Sources/Basecamp/Generated/Models/CardColumn.swift |
Applies subscriber decoding. |
swift/Sources/Basecamp/Generated/Models/Card.swift |
Applies assignee and subscriber decoding. |
rust/generator/tests/fixtures/mini/expected/types.rs |
Updates generator golden output. |
rust/generator/src/emit/types.rs |
Emits missing-ID defaults and person-list deserializers. |
rust/basecamp-sdk/tests/person_shapes.rs |
Tests Rust reads and merge-safe writes. |
rust/basecamp-sdk/src/types.rs |
Adds shared person-list deserialization helpers. |
rust/basecamp-sdk/src/generated/types.rs |
Regenerates affected Rust model attributes. |
Review details
- Files reviewed: 9/21 changed files
- Comments generated: 0
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
A null person list is checked as None rather than through a default, and the Swift strict-person test now reaches UpcomingSchedulePerson and OutOfOfficePerson, so widening the marker to a name match fails it. SPEC names TemplateLibraryConfirmationPerson among the strict types.
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.
A merge-safe update in Swift or Rust failed on a to-do the reference updates without complaint. When a person in
assignees,completion_subscribersor a schedule entry'sparticipantshas noid, or the list holds anullelement, Go reads it as0or the zero person and sends that back. Swift and Rust threw at decode and the update never happened. The same refusal hit plain reads liketodos.get.Originally tracked in Kotlin, Swift and Rust decoders refuse person shapes the reference writes back.
Follows Merge-safe writes refuse assignee ids the API really sends, which closed the same two shapes in Python, Ruby and TypeScript at their readers, and Seven SDKs read a person id seven ways, and Go disagreed with itself, which settled the id grammar.
Why the generators
Swift and Rust have no reader of their own on the write path. The generated model is the reader, so both refusals come from how the model is generated: a non-optional
idwith no default, and a list whose element type cannot benull. The fix belongs in the model generators, the same way the card predicted.What changed
FlexibleInt64marker, not on the type's name. That reaches exactly what Go's flexible decoder reaches.UpcomingSchedulePerson,MyAssignmentAssignee,OutOfOfficePersonandTemplateLibraryConfirmationPersonhave a plainint64id in Go, so they are not persons here and stay strict. Tests in both SDKs check that.id, it reads as0. An explicit"id": nullstill fails the read, as it does in Go.nullelement in a member whose element type is a person reads as the zero person. A non-object element still fails, and anulllist is still no list.Rust makes this change in its serde attributes. Swift makes it in explicit coding for the 11 models involved. Every other model keeps its synthesized
Codable. SPEC §10 now describes the state of Swift and Rust.Divergence from the reference
A throwaway Go oracle ran the reference's own
Update,EditEntry,GetandPeople.Listover 56 wire shapes at five sites: todoassignee_ids,completion_subscriber_ids, schedule entryparticipant_ids(write),todos.getassignees andpeople.list(read). The corpus is card 42's 39 shapes plus rows for a missing id, a null element and float spellings. Swift, Rust and Kotlin were measured on the same corpus through their real services: Rust with cargo, Kotlin withgradlew --offlineon the JVM, and Swift withswift teston Linux in theswift:6.1container (the whole suite: the same failures asmainthere, plus the five new tests passing). The macOS job in CI is the authority for Darwin.(Kotlin is measured only. Its generator is left to be done together with card 43's quoted-number fix in the same generator, so the two changes don't collide.)
Closed in Swift and Rust: missing id (3 rows), and null elements at every generated member.
Still divergent, on purpose:
people.listand its siblings). The generic page reader decodes these, not a generated member. The only generator-level way to reach them is to make a person decode fromnulleverywhere. That would also read"creator": nullon 35 required members as the system actor. Go does read it that way, but that is a decision to make, not a side effect to slip in.nullname({"id":7},{}, and"name": null, which the corpus does not cover). Go reads both as"". It is a required member, not the id, so it falls under SPEC §10's required-member rule and was not loosened here.1024.0,1e3,7.00,1000e-3, and0.0/-0.0, which read as the system actor. Go fails the read. These are unreachable throughDecoder.Decimaldoesn't help either: measured on Swift 6.1,1024.0and1024decode to the sameDecimal. Closing this needs a pre-decode pass over the raw bytes.FlexibleInt's doc used to say the residual could never produce the system actor, and it now says0.0does.nullbody for a list (people.listwith the bodynull): Go returns an empty list. That is list-body handling, not person decoding.Row by row (a named site means only that site diverges)
[1049715914][1049715914][1049715914][7][-5][5][0][0][9223372036854775807][0][0][0][0][0][0][0][0][7,0][][-9223372036854775808][0][9007199254740993][0][9007199254740993][][][0][7,0][0][7][0,7][7,0][0,0]The Go oracle (throwaway, not committed)
Tests
Rust:
tests/person_shapes.rsand a unit test for the required-list helper. Swift:PersonShapesTests. Each covers an absent id, a null id, null elements, what stays strict, and the merge-safe update sending[0, 0, 7]. Every test was run red:main's generated models, the lenient-direction tests fail;null,only_the_element_is_lenientfails;nullid read as0, the null-id tests fail;*Personname match, the strict-person test fails in both (UpcomingSchedulePerson), and also in Swift for*Assignee.The generators' branch for a required-and-nullable person list has no model to exercise it today, so it has never been compiled. Rust also passes
make rs-check(exceptcargo-deny, which isn't installed locally), drift, and conformance (302/302). A Linux regeneration of the Swift models changed only the 11 intended files.