Kotlin: optional model arrays become List<T>? = null (breaking)#433
Kotlin: optional model arrays become List<T>? = null (breaking)#433jeremy wants to merge 4 commits into
Conversation
BREAKING (Kotlin). The model generator previously special-cased only RichTextAttachment optional arrays as nullable, defaulting every other optional array to `List<T> = emptyList()`. That sentinel violates SPEC.md's Optional Fields rule (an empty list is not a substitute for absence) and left Kotlin the lone SDK that couldn't distinguish an absent array from a present-but-empty one — Go (nil), Swift/TS (optional), Python (NotRequired), Ruby (nil) all preserve it. ModelEmitter.resolvePropertyType's array branch now drops the `itemType == "RichTextAttachment"` condition, so EVERY optional array emits `List<T>? = null`; required arrays stay a plain `List<T>`. Regenerated: 15 models, ~23 fields flip to nullable (assignees, steps, completionSubscribers, participants, subscribers, lists, wormholes, responses, types, recentDeliveries, dock, dots, days, added/removed/notified person id lists, attachments). Cascade (the only non-null derefs the compiler flagged): - TodosService.fieldsFromTodo: `todo.assignees.orEmpty().map` / `todo.completionSubscribers.orEmpty().map` (preserves the always-send-empties semantics of the writable ID lists). - WormholesServiceTest: `assertNotNull(cardTable.wormholes)` before use. D-invariant: scripts/check-kotlin-optional-arrays.rb (wired into `make check`) pins the contract — every optional generated array is `List<T>? = null`, every required array stays `List<T>`, and none default to `= emptyList()`. Updates the rich-text-attachments-coverage note now that the nullable-array behavior is general, not a RichTextAttachment special case.
…ly edge Go already meets the SPEC decode bar (nil vs non-nil-empty slice), so a blanket *[]T sweep would be an ergonomic regression for a re-encode-only edge case. Rewrite the 1B.4 note to name each SDK's optional-field representation (Go pointers, TS optional chaining, Ruby nilable, Python NotRequired, Swift optionals, Kotlin now-nullable) and to record Go's accepted omitempty encode-only collapse; *[]T stays only where rich-text projections need the two-way wire distinction.
Spec Change Impact
Breaking API Change?Yes, this is a breaking change as it modifies an existing API behavior for model arrays in Kotlin. SDKs to Update
|
There was a problem hiding this comment.
Pull request overview
This PR updates the Kotlin generator and regenerated Kotlin models to represent all optional array fields as nullable (List<T>? = null) rather than using emptyList() as an absence sentinel, aligning Kotlin with the other SDKs’ optional-field presence semantics and the SPEC.md Optional Fields rule. It also adds a dedicated invariant check to prevent regressions back to = emptyList() defaults for optional arrays.
Tip
If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.
Changes:
- Kotlin generator: emit
List<T>?for every optional array; required arrays remainList<T>. - Regenerate Kotlin models and update Kotlin call sites/tests to handle nullable arrays (
.orEmpty(),assertNotNull). - Add
kt-check-optional-arraysinvariant (script + Makefile wiring) to pin the contract in CI.
Reviewed changes
Copilot reviewed 8 out of 23 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| spec/api-gaps/rich-text-attachments-coverage.md | Updates documentation to reflect the new Kotlin optional-array representation across all optional arrays. |
| scripts/check-kotlin-optional-arrays.sh | Adds a shell entrypoint for the Kotlin optional-array invariant check (Ruby-backed). |
| scripts/check-kotlin-optional-arrays.rb | Implements an invariant scan across generated Kotlin to forbid = emptyList() for optional arrays and require nullable + = null when defaults exist. |
| rubric-audit.json | Updates the optional-field rubric note to reflect the cross-SDK absent-vs-present representation and Kotlin’s new nullable arrays. |
| Makefile | Adds kt-check-optional-arrays target and wires it into make check. |
| kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/WormholesServiceTest.kt | Updates test to handle wormholes becoming nullable by asserting non-null before indexing. |
| kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/services/TodosService.kt | Updates composite logic to tolerate nullable assignees/completionSubscribers via .orEmpty() when building PUT fields. |
| kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/Webhook.kt | Regenerated: optional arrays (types, recentDeliveries) now nullable with = null. |
| kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/Todo.kt | Regenerated: optional arrays (assignees, completionSubscribers, steps) now nullable with = null. |
| kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/Template.kt | Regenerated: dock now nullable with = null. |
| kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/Subscription.kt | Regenerated: subscribers now nullable with = null. |
| kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/ScheduleEntry.kt | Regenerated: participants now nullable with = null. |
| kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/QuestionSchedule.kt | Regenerated: days now nullable with = null. |
| kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/Project.kt | Regenerated: dock now nullable with = null. |
| kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/HillChart.kt | Regenerated: dots now nullable with = null. |
| kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/EventDetails.kt | Regenerated: person-id arrays now nullable with = null. |
| kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/ClientApproval.kt | Regenerated: responses now nullable with = null. |
| kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/CardTable.kt | Regenerated: subscribers, lists, wormholes now nullable with = null. |
| kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/CardStep.kt | Regenerated: assignees now nullable with = null. |
| kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/CardColumn.kt | Regenerated: subscribers now nullable with = null. |
| kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/Card.kt | Regenerated: assignees, completionSubscribers, steps now nullable with = null. |
| kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/CampfireLine.kt | Regenerated: attachments now nullable with = null. |
| kotlin/generator/src/main/kotlin/com/basecamp/sdk/generator/ModelEmitter.kt | Generator change: removes the RichTextAttachment-only exception; optional arrays resolve to nullable list types. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 71d20d937f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
All reported issues were addressed across 23 files
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
…uard into CI Addresses the external review on #433: - Invariant now proves both halves (blocker): check-kotlin-optional-arrays.rb cross-checks each generated model array against its OpenAPI component's `required` set. A REQUIRED array must be non-null `List<T>` (no `?`, no default); an OPTIONAL array must be `List<T>? = null`. So a required array mistakenly emitted as `List<T>?` is now caught, not silently accepted. Request/param and non-component supporting types keep the structural check (no `= emptyList()`; defaulted arrays must be `?= null`). - rubric-audit 1B.4 is honest (blocker): set to pass:false + issue #436. The earlier note falsely claimed every optional field preserves absence across all six SDKs, but Kotlin optional non-array scalars still use zero-value sentinels (Boolean = false, Int = 0, Long = 0L), which SPEC.md's Optional Fields rule explicitly forbids. #436 tracks that broader (array-scoped-fix-excluded) debt. - Wire the optional-array guard into CI (blocker): a step in the Kotlin test job runs the invariant; it was only in `make check`, which CI never invoked.
Sensitive Change Detection (shadow mode)This PR modifies control-plane files:
|
Review carefully before merging. Consider a major version bump. |
There was a problem hiding this comment.
All reported issues were addressed across 3 files (changes from recent commits).
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
…e claim #433 second round: - Locale robustness: read openapi.json and the generated .kt files as explicit UTF-8 in check-kotlin-optional-arrays.rb (both File.read and File.foreach) — LC_ALL=C otherwise reads US-ASCII and chokes on UTF-8 bytes. - rubric-audit 1B.4 + #436 corrected: Go optional NON-POINTER scalars collapse absence on DECODE (an absent field and an explicit zero both unmarshal to the zero value); only pointer fields (*T) preserve it. The earlier note wrongly called Go decode uniformly faithful. Ruby/Swift decode faithfully but drop nil on re-encode. Still pass:false, tracked in #436.
Track D of the #408 follow-up. Aligns Kotlin with the other five SDKs on the
optional-array presence contract and clears the SPEC.md "Optional Fields"
sentinel violation the generator comment already named.
Why
Every rich-text attribute already round-trips absent-vs-present-empty in Go
(nil), Swift/TS (optional), Python (NotRequired), and Ruby (nil). Kotlin was the
lone holdout: the model generator special-cased only
RichTextAttachmentoptional arrays as nullable and defaulted every other optional array to
List<T> = emptyList()— a sentinel that SPEC.md's Optional Fields rule forbids(an empty list is not a substitute for absence).
What
ModelEmitter.resolvePropertyType'sarraybranch drops theitemType == "RichTextAttachment"condition, so every optional array emitsList<T>? = null. Required arrays stayList<T>.TodosService.fieldsFromTodo(.orEmpty().map, preserving the always-send-empties semantics) and
WormholesServiceTest(assertNotNull).scripts/check-kotlin-optional-arrays.rb(inmake check)asserts every optional generated array is
List<T>? = null, every requiredarray stays
List<T>, and none default to= emptyList()— pinning the fix.non-nil-empty); a blanket
*[]Tchange is an ergonomic regression for are-encode-only edge.
rubric-audit.json1B.4 now names each SDK'srepresentation and records Go's accepted
omitemptyencode-only collapse.Kotlin migration
Each field below is now
T? = null. Access with?.,.orEmpty(),?: emptyList(), or!!where presence is guaranteed by the response type:attachmentsassignees,completionSubscribers,stepssubscribersassigneessubscribers,lists,wormholesresponsesaddedPersonIds,removedPersonIds,notifiedRecipientIdsdotsdockdaysparticipantssubscribersdockassignees,completionSubscribers,stepstypes,recentDeliveriesExample:
The
@requiredarrays (rich-text*_attachmentson concrete resources, etc.)are unchanged — always present, still non-null.
Verify
make kt-check(compile + tests),make kt-check-drift(208/208, no drift),make kt-check-optional-arrays(112 files, invariant clean), andmake validate-api-gapsall green.= emptyList()default failsthe check.
Summary by cubic
Make all optional Kotlin model arrays nullable (
List<T>? = null) instead of defaulting toemptyList(), aligning with the Optional Fields rule and the other SDKs. Breaking change (Kotlin only): 24 fields across 15 models now distinguish absent vs present-empty.Refactors
List<T>? = null; required arrays stayList<T>.TodosService.fieldsFromTodouses.orEmpty(), wormholes test asserts non-null before use).scripts/check-kotlin-optional-arrays.*; proves optional arrays areList<T>? = nulland required arrays non-null; reads files as UTF-8; wired intomake checkand CI.rubric-audit.json1B.4 set to pass:false (tracked in Optional scalar fields still use zero-value sentinels (SPEC Optional Fields debt) #436); notes per-SDK handling and Go non-pointer scalar decode collapse.Migration
?.,.orEmpty(),?: emptyList(), or!!where presence is guaranteed.assignees,steps,completionSubscribers,subscribers,participants,lists,wormholes,responses,types,recentDeliveries,dock,dots,days, and change/event person-id lists.Written for commit b2d031d. Summary will update on new commits.