Skip to content

Kotlin: optional model arrays become List<T>? = null (breaking)#433

Open
jeremy wants to merge 4 commits into
mainfrom
track-d-kotlin-optional-arrays
Open

Kotlin: optional model arrays become List<T>? = null (breaking)#433
jeremy wants to merge 4 commits into
mainfrom
track-d-kotlin-optional-arrays

Conversation

@jeremy

@jeremy jeremy commented Jul 25, 2026

Copy link
Copy Markdown
Member

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.

⚠️ Breaking change (Kotlin only). 24 generated model array fields across
15 models change from non-null List<T> to nullable List<T>? = null.
Migration below. This PR does not bump versions or release — the
coordinated cross-SDK version bump is scheduled separately, after this lands.

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 RichTextAttachment
optional 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

  • Generator: ModelEmitter.resolvePropertyType's array branch drops the
    itemType == "RichTextAttachment" condition, so every optional array emits
    List<T>? = null. Required arrays stay List<T>.
  • Regenerated: 15 models (see migration table).
  • Cascade: the only non-null dereferences the compiler flagged —
    TodosService.fieldsFromTodo (.orEmpty().map, preserving the always-send-
    empties semantics) and WormholesServiceTest (assertNotNull).
  • D-invariant: scripts/check-kotlin-optional-arrays.rb (in make check)
    asserts every optional generated array is List<T>? = null, every required
    array stays List<T>, and none default to = emptyList() — pinning the fix.
  • Go: documented, not swept. Go already meets the decode bar (nil vs
    non-nil-empty); a blanket *[]T change is an ergonomic regression for a
    re-encode-only edge. rubric-audit.json 1B.4 now names each SDK's
    representation and records Go's accepted omitempty encode-only collapse.

Kotlin migration

Each field below is now T? = null. Access with ?., .orEmpty(),
?: emptyList(), or !! where presence is guaranteed by the response type:

Model Now-nullable array fields
CampfireLine attachments
Card assignees, completionSubscribers, steps
CardColumn subscribers
CardStep assignees
CardTable subscribers, lists, wormholes
ClientApproval responses
EventDetails addedPersonIds, removedPersonIds, notifiedRecipientIds
HillChart dots
Project dock
QuestionSchedule days
ScheduleEntry participants
Subscription subscribers
Template dock
Todo assignees, completionSubscribers, steps
Webhook types, recentDeliveries

Example:

// before
val ids = todo.assignees.map { it.id }
// after
val ids = todo.assignees.orEmpty().map { it.id }

The @required arrays (rich-text *_attachments on 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), and
    make validate-api-gaps all green.
  • Invariant regression-tested: re-introducing a = emptyList() default fails
    the check.

Summary by cubic

Make all optional Kotlin model arrays nullable (List<T>? = null) instead of defaulting to emptyList(), 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

    • Generator: all optional arrays emit List<T>? = null; required arrays stay List<T>.
    • Regenerated models and fixed call sites (TodosService.fieldsFromTodo uses .orEmpty(), wormholes test asserts non-null before use).
    • Invariant check: added scripts/check-kotlin-optional-arrays.*; proves optional arrays are List<T>? = null and required arrays non-null; reads files as UTF-8; wired into make check and CI.
    • Rubric: rubric-audit.json 1B.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

    • Access optional arrays with ?., .orEmpty(), ?: emptyList(), or !! where presence is guaranteed.
    • Notable fields now nullable: 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.

Review in cubic

jeremy added 2 commits July 24, 2026 22:44
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.
Copilot AI review requested due to automatic review settings July 25, 2026 05:45
@jeremy jeremy added kotlin breaking Breaking change to public API labels Jul 25, 2026
@github-actions github-actions Bot added the spec Changes to the Smithy spec or OpenAPI label Jul 25, 2026
@github-actions

github-actions Bot commented Jul 25, 2026

Copy link
Copy Markdown

Spec Change Impact

  • Breaking Change: Updated model arrays in Kotlin to be List<T>? = null, affecting compatibility.
  • No operations, types, or resources were explicitly added or removed in the spec, but the change in array handling might cause breaking behavior.
  • SDKs requiring regeneration: All SDKs need regeneration due to the spec change.

Breaking API Change?

Yes, this is a breaking change as it modifies an existing API behavior for model arrays in Kotlin.

SDKs to Update

  • Go
  • TypeScript
  • Ruby
  • Kotlin
  • Swift

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 remain List<T>.
  • Regenerate Kotlin models and update Kotlin call sites/tests to handle nullable arrays (.orEmpty(), assertNotNull).
  • Add kt-check-optional-arrays invariant (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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread rubric-audit.json Outdated

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 23 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread rubric-audit.json Outdated
…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.
Copilot AI review requested due to automatic review settings July 25, 2026 06:10
@github-actions

Copy link
Copy Markdown

Sensitive Change Detection (shadow mode)

This PR modifies control-plane files:

  • .github/workflows/test.yml

Shadow mode — this check is informational only. When activated, changes to these paths will require approval from a maintainer.

@github-actions github-actions Bot added github-actions Pull requests that update GitHub Actions enhancement New feature or request labels Jul 25, 2026
@github-actions

github-actions Bot commented Jul 25, 2026

Copy link
Copy Markdown

⚠️ Potential breaking changes detected:

  • The default values of several fields in the models were changed from empty lists (e.g., List<T> = emptyList()) to nullable lists (List<T>? = null). This is a breaking change because the new behavior is not backward-compatible with consumers expecting a non-null empty list as the default value.
  • Projects that deserialize or rely on fields such as attachments, assignees, completionSubscribers, or similar across various data classes will experience runtime failures if they are not prepared for null values.

Review carefully before merging. Consider a major version bump.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 24 changed files in this pull request and generated no new comments.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 3 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread scripts/check-kotlin-optional-arrays.rb
…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.
Copilot AI review requested due to automatic review settings July 25, 2026 06:49

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 24 changed files in this pull request and generated no new comments.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

breaking Breaking change to public API enhancement New feature or request github-actions Pull requests that update GitHub Actions kotlin spec Changes to the Smithy spec or OpenAPI

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants