Go: pointerize the five optional timestamps that could not represent absence - #615
Conversation
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4abc5d71f6
ℹ️ 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.
Pull request overview
This PR fixes an inconsistency on the hand-written Go wrapper surface (go/pkg/basecamp) where five optional timestamps were typed as value time.Time. Because encoding/json never treats a struct as "empty", ,omitempty was inert for these fields: an absent timestamp was indistinguishable from a real value and re-marshaled as a fabricated 0001-01-01T00:00:00Z. Converting them to *time.Time lets absence round-trip as an omitted key and aligns the wrapper with the spec (all five are optional) and the generated client (all five already *time.Time). This is a deliberate, silent breaking change for consumers (t.IsZero() still compiles against a pointer and panics on nil), scoped to Go only with no spec/generated-artifact changes.
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:
- Pointerize
HillChart.UpdatedAt,Notification.ReadAt/UnreadAt,SearchResult.CreatedAt/UpdatedAt; dropderef(...)in the*FromGeneratedconverters and add,omitemptyto the twoSearchResulttags. - Add
optional_timestamps_test.go: round-trip proofs, a nil-pointer migration-hazard test, and an AST regression guard (TestNoValueTypedOptionalTimestamps) rejecting value-typedtime.Timefields carrying,omitempty. - Update existing tests to nil-check before dereferencing the now-pointer fields.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
go/pkg/basecamp/hill_charts.go |
UpdatedAt → *time.Time; converter drops deref; adds explanatory comment (contains an inaccurate TimelineEvent.UpdatedAt cross-reference). |
go/pkg/basecamp/my_notifications.go |
ReadAt/UnreadAt → *time.Time with rationale comment; decoded directly via json.Unmarshal, no converter. |
go/pkg/basecamp/search.go |
CreatedAt/UpdatedAt → *time.Time with ,omitempty; converter assigns generated pointers directly. |
go/pkg/basecamp/hill_charts_test.go |
Adds nil-check before IsZero() on UpdatedAt. |
go/pkg/basecamp/search_test.go |
Adds nil-checks before IsZero() on CreatedAt/UpdatedAt. |
go/pkg/basecamp/optional_timestamps_test.go |
New round-trip, migration-hazard, and AST-guard tests for optional timestamps. |
Note: the planning doc go/BRIEF-bc5-forward-compat-wrappers.md:148 still describes ReadAt/UnreadAt as bare time.Time, which is now stale — it is outside this PR's changed regions, so it is flagged here rather than as an inline comment.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
ExampleSearchService_Search_sorted called r.CreatedAt.Format(...) with no nil check. It compiles either way and panics on a result whose created_at the API omitted, so the SDK's own godoc was demonstrating precisely the unsafe pattern this change introduces. Reported by chatgpt-codex-connector on #615. The example now guards the dereference and says why, so the documentation teaches the migration instead of the hazard.
…edAt TimelineEvent has no UpdatedAt at all, and its CreatedAt is a required value-typed time.Time. The optional *time.Time pair at timeline.go:85-86 belongs to TimelineAttachment. EverythingFile.UpdatedAt (everything.go:64) is a real optional *time.Time, so it is the accurate cross-reference. The error came from the table in #562, which lists TimelineEvent as carrying *time.Time CreatedAt/UpdatedAt; that row is wrong. Reported by copilot-pull-request-reviewer on #615.
|
@codex review Re-review request at
|
|
Codex Review: Didn't find any major issues. 🎉 Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
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". |
…absence HillChart.UpdatedAt, Notification.ReadAt, Notification.UnreadAt, SearchResult.CreatedAt and SearchResult.UpdatedAt were value-typed time.Time on the hand-written wrapper surface while the schema marks every one of them optional and the generated client types all five as *time.Time. Two things went wrong at once. Absence collapsed to the zero time, so an omitted updated_at was indistinguishable from a server that genuinely sent 0001-01-01T00:00:00Z. And the `,omitempty` tags meant to paper over that were inert: encoding/json's empty set is false / 0 / nil pointer / nil interface / empty array, slice, map, string, and a struct is never empty, so the key was re-emitted on every marshal carrying the fabricated zero. SearchResult was worse still — its tags claimed the fields were required. This is a SILENT break for callers. `hc.UpdatedAt.IsZero()` compiles unchanged against the pointer, because Go inserts the dereference for a value-receiver method call, and then panics on nil at runtime. All five move together so consumers migrate once. TestNilOptionalTimestampPanicsOnValueReceiverCall pins that hazard as executable documentation, and TestNoValueTypedOptionalTimestamps is an AST guard against reintroducing the shape. scripts/check-go-optional-pointers enforces the same invariant but only over go/pkg/generated/client.gen.go — the wrapper surface is outside its scope by construction, deliberately, and timestamps are the one carve-out that scope leaves unguarded.
ExampleSearchService_Search_sorted called r.CreatedAt.Format(...) with no nil check. It compiles either way and panics on a result whose created_at the API omitted, so the SDK's own godoc was demonstrating precisely the unsafe pattern this change introduces. Reported by chatgpt-codex-connector on #615. The example now guards the dereference and says why, so the documentation teaches the migration instead of the hazard.
…edAt TimelineEvent has no UpdatedAt at all, and its CreatedAt is a required value-typed time.Time. The optional *time.Time pair at timeline.go:85-86 belongs to TimelineAttachment. EverythingFile.UpdatedAt (everything.go:64) is a real optional *time.Time, so it is the accurate cross-reference. The error came from the table in #562, which lists TimelineEvent as carrying *time.Time CreatedAt/UpdatedAt; that row is wrong. Reported by copilot-pull-request-reviewer on #615.
…xample The brief told the next author to type Notification.BubbleUpAt as *time.Time "rather than the bare time.Time pattern the wrapper uses for ReadAt / UnreadAt". Pointerizing those two here made that sentence cite a pattern the tree no longer contains. The reasoning was always right; only the counter-example was load-bearing and it inverted. Cite the fields that actually carry the convention, with line numbers re-resolved to their declaring structs.
f3b46ae to
c5b1b42
Compare
|
@copilot review |
|
@codex review |
|
Codex Review: Didn't find any major issues. Bravo. Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
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". |
Convergence note — head
|
v0.13.0 pointerized the optional fields across the Go surface (#560, #615, #632) and shipped no way to build a pointer. `ptr[T any]` has been sitting unexported in helpers.go the whole time, and go/README.md said nothing about pointer fields, so every consumer hitting the migration writes their own generic helper first. This SDK's own test suite is the proof: schedules_test.go and test_helpers_test.go hand-rolled strPtr, boolPtr, idsPtr and intPtr rather than reach for the unexported one. One generic Ptr rather than AWS-style typed constructors. The optional fields span *string, *bool, *int, *int32, *int64, *time.Time and *[]int64 — a typed set would need six names and still not cover ParticipantIDs *[]int64, where a pointer to an empty slice is what removes every participant. Deref covers the read direction, which is the more dangerous half. Go auto-dereferences a value-receiver method call, so hc.UpdatedAt.IsZero() still compiles against *time.Time and panics at run time on a chart that has never moved. Deref is total: the zero value on nil. The unexported ptr and deref stay as the internal vocabulary at hundreds of conversion sites, but now forward to the exported pair, so the contract callers get cannot drift from the one this package relies on. Additive only: no existing exported signature changes.
* Go: five wrapper timestamps could not represent absence (#620) BREAKING. Five hand-written wrapper fields were typed value time.Time while their generated counterparts were *time.Time: TimelineEvent.CreatedAt timeline.go WebhookDelivery.CreatedAt webhooks.go QuestionReminder.RemindAt checkins.go ClientApprovalResponse.CreatedAt client_approvals.go ClientApprovalResponse.UpdatedAt client_approvals.go An omitted timestamp decoded to the zero time and re-marshaled as a fabricated 0001-01-01T00:00:00Z, indistinguishable from a real value. None of the five carried `,omitempty` — and it would not have helped: encoding/json's empty set has no entry for a struct, so `,omitempty` on a value-typed time.Time is inert. Same class as #562/#615, which fixed five and left these five. The converters drop their deref() and assign the pointer through. Also closes the class, which is the half #620's title names — "and no gate can see them". The evidence lives in the generated client, not the wrapper, so a guard has to read both sides: - TestNoValueTypedOptionalTimestamps keys on `,omitempty` and skips without it, so all five were invisible to it. Its doc comment named this gap and deferred it. - check-wrapper-drift has the pairing but compares tag names only, and teaching it types would still miss WebhookDelivery: its header excludes the webhook-flavored shapes BY DESIGN. TestNoWrapperTimestampNarrowerThanGenerated pairs by struct name + json key instead, which is blind to the converter tiers and covers all five. Scoped to timestamps: a nil-capability rule applied broadly reports ~345 deliberately flattened fields. An absent string marshals away harmlessly; an absent time.Time marshals as a wrong value. Red-proven against the un-fixed source: all five fail the absent-omit test reporting the fabricated instant, and all five fail the new guard — including WebhookDelivery, the one no gate could reach. * Guard the three pointer derefs this branch left in its own tests Making the five wrapper timestamps *time.Time left three assertions in this package's tests dereferencing them unguarded: checkins_test.go:1364 r1.RemindAt.IsZero() webhooks_test.go:177 delivery.CreatedAt.IsZero() timeline_test.go:81 !event.CreatedAt.Equal(expectedTime) They compile — Go auto-dereferences for a value receiver — and pass only because the fixtures happen to carry the key. Drop it from a fixture and they panic, taking the whole test binary down instead of failing with a message naming the field. That is exactly the hazard the branch documents for consumers. Require non-nil first, then assert what was being asserted.
Rebased onto 2afc977 and re-measured rather than incremented. Eight PRs merged since the branch was last updated, not the seven that carried the breaking label: #647 was on the "Not in this release" list and had landed. Counts. 55 class A and 6 class B, 61 surviving a clean build, up from 47/4/51. Per SDK the class split is Go 12/4, Swift 10/0, TypeScript 9/0, Python 8/0, Ruby 10/1, Kotlin 6/1, and the breaking-change column moves to 33/22/18/16/20/17. The body parses back to those numbers rather than agreeing with them by hand. The root README's aggregate sentence is re-derived to match, and now states both halves numerically instead of "most" and "a few". The operation inventory is unchanged at 238 -> 247 with the same 14 added, 5 removed and 11 same-ID route moves, computed from openapi.json at both ends. check-targets is 43, and the derivation is inline where the gate count was previously only projected. The release spans 67 merged PRs, 15 labelled breaking; the gh commands that produce both are embedded in the as-of block, with the note that a labelled PR is not the same unit as an entry, which is why the per-SDK columns exceed 15. #658 is class B, not class A. It does to five wrapper timestamps exactly what #615 did to five others: QuestionReminder.RemindAt, ClientApprovalResponse's CreatedAt and UpdatedAt, TimelineEvent.CreatedAt and WebhookDelivery.CreatedAt compile untouched through a value-receiver call and panic on nil. #615's own check could not see them because it keyed on the omitempty tag and these five did not carry one. The audit is ten fields, and the entry names the near-miss siblings that did not move, ClientApproval's pair in particular. #664 splits. The public CreateScheduleEntryRequest fields were already string and still are, so the wrapper half is silent: the RFC3339 ErrUsage guard is gone, a bare date now creates an all-day entry, and a malformed value reaches bc3 instead of failing locally. That is class A. The generated CreateScheduleEntryRequestContent went time.Time to string, which is a compile error for pkg/generated importers. ReplaceScheduleEntryRequestContent is not a migration from v0.12.0 at all; #632 introduced it. TypeScript and Ruby are doc-comment only. #647 is folded in as merged, with two corrections to what was written when it was still a branch. It touches no schema, so the claim that it had to go Smithy-first is withdrawn; UpdateCardStepRequestContent.DueOn was pointerized by #560. And the v0.12.0 preservation GET was conditional, taken only when the caller left due_on unaddressed, so the request-count table is scoped to that path rather than presented as universal. #648 adds no silent break anywhere. bc3's body is byte-identical before and after, so nothing that was populated stops being so; the assignable's title was never sent and is now spelled content. Every rename and retype is caught statically in Go, Swift, TypeScript and Kotlin and raised immediately in Python and Ruby, so it is one compile-or-runtime entry per SDK. Two corrections nobody asked for. The Go class list opened "Go carries every class-B break in the release", which stopped being true when Ruby's decode entry moved into class B; it now claims only the panic-shaped ones. And todos_write.json carries three errorRaised cases, not two, because #660 added a bare-scalar kill. #660 is a Kotlin class-B entry, which is new. Removing the client-wide isLenient means a present, populated, wrong-typed scalar throws SerializationException where it used to coerce to a string, and no signature moved to announce it. It throws in the response decode, so on a write the mutation has already landed, and it is not a BasecampException outside todolists. #656 is Ruby class A, scoped tightly: only max_retries 0, only an ungoverned GET, which means get_absolute and the Launchpad fetch rather than any operation lacking a policy. Every other configuration is bit-identical. Not in this release is now empty, and says so.
* MIGRATING.md: the v0.13.0 upgrade guide, silent breaks first v0.13.0 breaks all six SDKs and 35 of those breaks are silent — no compile error, no exception, no decoder failure. Label-generated release notes list what merged; they cannot say what a consumer must react to or what wrong behaviour they get if they ignore it. That had no home in this repo. Adds MIGRATING.md at the root, linked from the root README and all six per-SDK READMEs. Silent breaks lead the document, then one section per SDK ordered by severity, plus an operator checklist, a "coverage: corrected and re-scoped" section for what did not ship, and known gaps. No CHANGELOG is reintroduced. The hand-maintained ones were deleted in #115 as superseded by auto-generated notes, and every release body since is machine-built. CONTRIBUTING records the resulting rule: label-generated notes say what merged, MIGRATING says what to do about it. Corrections to the source drafts, each re-derived rather than repeated: - TrashTodo was not a 404. bc3 draws `resources :todos, only: %i[show edit update destroy]`; DELETE /todos/:id returned 204 and set status to "archived", so every caller was archiving. It is the one #619 removal that takes away a working call, and it now carries its own carve-out. - #619 removed three operations, not nine. Nine were re-pathed. Fusing the two sets is what made the blanket 404 reassurance look safe. - Hook operation identity differs by SDK: Go and Ruby emit a short verb, the other four emit the wire operation ID, where the todolist pair kept its names — so an allowlist holding UpdateTodolistOrGroup passes the write and denies the new read. - 238 -> 241 measured at the v0.12.0 tag and at c95d81c, not assumed. - Kotlin binary compatibility is already disclaimed in kotlin/README.md; Swift has no written policy. Both are now stated rather than left unsaid. recordings.get is documented as a known gap with a list-and-filter recipe and its honest cost. The Go recipe compiles against this tree. #637, #629 and #635/#641 were open at the time of writing and are recorded under "Not in this release" rather than described as shipped. * Fix the Go pagination advice, cut the raw-wire workaround, absorb #637/#643 Addresses both P1 review threads on #642 and folds in the two PRs that landed since the first draft. Pagination (P1). Cross-SDK item 1 claimed `page` was a starting offset in every SDK and told readers to drop it to restore the old walk. For Go that was actively harmful: `git show v0.12.0:go/pkg/basecamp/bookmarks.go` returns before followPagination whenever page > 0, so a positive Page already meant one request, and dropping it converts a bounded call into a full account-wide traversal. The item is now scoped to the five SDKs where it holds — re-checked at the tag rather than assumed, since the universal claim had already failed once — with a Go subsection splitting the two real cases: services where the page number was already honored (Bookmarks, Drafts, Everything*, request unchanged) and the fourteen carrying the "not yet honored" doc, which sent no page at all and returned page 1's rows. Gauges is in neither; it had no page. Raw wire (P1). The Forwards().CreateReply example built a path with fmt.Sprintf and called the raw AccountClient.Post against a route with no upstream coverage, which is what AGENTS.md "Never Do These" 4 and 5 forbid. Removed rather than softened, and replaced with a known-gap section stating what a hand-built path gives up. Swept the document: the one other hit documents a real change to the raw client's error codes, so it stays, but its fabricated path is gone and it now says it is not a suggestion to reach for the escape hatch. #643 landed, so basecamp.Ptr and basecamp.Deref replace the hand-rolled ptr helper throughout, the Go section opens with the 300-pointer census and a command that reproduces it, and ParticipantIDs *[]int64 gets its own note: nil leaves participants alone, a pointer to an empty slice removes every one. #637 landed and does NOT add a break to any SDK. color and comments_app_url did not exist on Todolist at v0.12.0 in any of the six — both arrived with #628 earlier in this same release — so from the guide's baseline nothing turned from optional to required. Counts stay 27/20/16/14/16/14. Documented where it bites: color is required-and-nullable so explicit null decodes, comments_app_url rejects null and absence alike. Also: kotlin/README's append-only source-compat promise contradicted this release repeatedly, so it now describes documented pre-1.0 breaking correctness releases; the binary-compat disclaimer is kept and sharpened. release-github.yml links MIGRATING.md from every release body, guarded on the file, so the link cannot be forgotten at tag time. "Silent" is defined as source/runtime-silent against a live server, since a suite pinning request paths does catch some. Counts are stated as-of 51d0d86 with derivations inline, and each in-flight change names the numbers it invalidates so the pre-tag pass is arithmetic. * Split silent breaks into no-signal and fails-at-runtime; absorb #629 and cards Addresses the remaining P2 and a suppressed Copilot comment on #642, re-derives every count against main, and writes the cards due-date change. The P2 was right, and it was a contradiction with this guide's own definition rather than loose wording: "silent" was defined as "does not raise" and then used to file nil-pointer panics. The section is now "Breaks your compiler will not catch" — the property all of it actually shares — split into class A, no signal at all, and class B, compiles then panics or raises but only when a particular field is absent, so it passes every test where that field is populated. Applying the definition consistently moved four entries, not the three flagged: the three Go pointerization panics plus Ruby's Draft#scheduled_posting_at decode, which raises NoMethodError and TypeError and had the same defect. Two moved entries carry real no-signal residue, kept as sub-notes rather than double-counted. Per SDK: Go 8A/3B, Swift 9A, TypeScript 5A, Python 4A, Ruby 2A/1B, Kotlin 3A — 31 + 4 = 35, unchanged in total. Body counts verified against the table by parsing the section, not by eye. The Swift section claimed three new optional Todolist members and named one; the other two are required. Now singular, matching TypeScript. Counts re-derived at 9de44b2: the inventory is 238 -> 247, not 241, since #629 merged. Added, removed and route-moved lists are computed from openapi.json at both ends rather than hand-edited — 14 IDs added, 5 removed, 11 same-ID moves — and the Folders operations are flagged as drawn at /stacks, not /folders. Cards get their own section. The half that matters most is true in production today and is not caused by upgrading: every released SDK encodes "clear a card due date" as omission, bc3 stopped treating omission as a clear, so that call is a silent no-op right now. That is a reason to upgrade rather than a hazard of it, so it sits in the operator checklist. The SDK-side change is read from bf43715 and marked unmerged: single PUT, "due_on": "" as the clear encoding, UpdateStepRequest.DueOn becomes *string, and the GetCard preservation read goes away. The hook collapse is written as the inverse of the {Todolists,Update} split because it fails the opposite way — allowlists do not start denying, but a denylist on {Cards,Get} silently stops blocking the write it used to take down. Removing the preservation GET also removes three named errorRaised kill cases from cards_write.json; the class stays pinned on Todos, which still does a real read-modify-write, so that is said rather than filed as a redundant-GET cleanup. * Audit class A across all six SDKs; add Ruby's missing download retry Fourth review round on #642. Four findings, all upheld. The allowlist framing was wrong in the direction that matters. I wrote that fewer hook events are safe for an allowlist. True only if the allowlist named both operations: one that names UpdateCard and deliberately omits GetCard used to reject cards.update at its read, and after the collapse permits it end to end. Both policy shapes now carry the warning, labelled, plus the observation that they are the same hole seen twice — in each, the thing stopping the write was the read, expressed once as an omission and once as an entry. The class-A counting was inconsistent across all six SDKs, not the two flagged. Python and Kotlin excluded changes their own prose called "no signal whatsoever"; auditing every SDK against the definition moved the totals to 47 class A and 4 class B. The counting policy is now stated in the document so it can be checked against a rule rather than an impression: one entry per distinct change per SDK, counted where it bites; class A if any ordinary call-site shape stays silent even when another is compile-caught; second faces annotated as residue and counted once; raises-only-on-malformed-response is class B. Two things fell out that were not counting problems. Ruby's #563 was missing from the guide entirely — no mention of download_url anywhere in the chapter — verified against source rather than prose: v0.12.0 http.get_no_retry, which sent Accept: application/json and did not retry, became get_download calling request_with_retry with retry_on: DOWNLOAD_RETRY_ON and accept: nil. Ruby now has its own section. The same check confirmed Go's omission of #563 is correct, because Go already retried at v0.12.0. Separately, the Go note claiming the compiler catches only the pkg/generated half of Schedules().UpdateEntry was false: UpdateScheduleEntryRequest's fields became pointers, so any pkg/basecamp call site that set a field fails to build. The class-B definition described only half its own membership. It said the trigger is an absent field, but Ruby's entry fires only when the field is populated. It now says both, and says plainly that class B is a property of a call plus a response rather than of the call — the same method against the other shape is not a break at all. Class A has no such dependency. Stale counts in the chapter intros are fixed. The Go intro still said eleven silent and two panics, which is the first thing a #go link shows, and Swift claimed the most no-signal breaks, which stopped being true at Go ten. Also folds in #652 (projected-example gate, stacked on #648, takes check-targets to 43), moves #648 out of draft at cb438ce, and records that #647 is being reworked Smithy-first because the generated UpdateCardStepRequestContent.DueOn is *types.Date and cannot express "". The consumer-facing card shape is unaffected by that rework. Re-derived against #648: 238 -> 247 with 14 added, 5 removed and 11 same-ID route moves survives unchanged. * Correct four claims in the v0.13.0 guide that do not match the source The opening warning said the runtime failures need a payload where a field is absent. That holds for the three Go entries; Ruby's single class-B entry has the opposite trigger. Draft#scheduled_posting_at and MyNote#created_at/#updated_at run through parse_datetime, which returns nil for nil and a Time otherwise, so .start_with? and Time.parse raise only when the field is populated. A reader following the old text builds the wrong fixture and concludes they are unaffected. Both directions are now named, here and in the root README. Class A was described as breaking on every response. Most of it does, but two groups do not: the error-message and validation entries need an error status to reach the code at all, and the field-map half needs a body of a particular shape; downloadURL's hop-1 retry changes nothing until a network error or one of 429/502/503/504 occurs. Stated as preconditions rather than as a blanket claim. The Go pointer example said only the field selector panics. types.Date.String has a value receiver, so Go rewrites t.DueOn.String() to (*t.DueOn).String() and the nil dereference panics before String is entered. The same holds for IsZero, Before, After and Weekday on Date and for Format, Sub, Unix and Year on time.Time. The summary bullet already said both panic; the example contradicted it. The Accept-header note credited only Python. Ruby dropped it on the same hop: get_download passes accept: nil, and request_headers sets the header only when accept is truthy. Both are named, with the observation that the other four never sent it on that hop at v0.12.0 either. No counts are touched. * Re-derive every count against the final release commit Rebased onto 2afc977 and re-measured rather than incremented. Eight PRs merged since the branch was last updated, not the seven that carried the breaking label: #647 was on the "Not in this release" list and had landed. Counts. 55 class A and 6 class B, 61 surviving a clean build, up from 47/4/51. Per SDK the class split is Go 12/4, Swift 10/0, TypeScript 9/0, Python 8/0, Ruby 10/1, Kotlin 6/1, and the breaking-change column moves to 33/22/18/16/20/17. The body parses back to those numbers rather than agreeing with them by hand. The root README's aggregate sentence is re-derived to match, and now states both halves numerically instead of "most" and "a few". The operation inventory is unchanged at 238 -> 247 with the same 14 added, 5 removed and 11 same-ID route moves, computed from openapi.json at both ends. check-targets is 43, and the derivation is inline where the gate count was previously only projected. The release spans 67 merged PRs, 15 labelled breaking; the gh commands that produce both are embedded in the as-of block, with the note that a labelled PR is not the same unit as an entry, which is why the per-SDK columns exceed 15. #658 is class B, not class A. It does to five wrapper timestamps exactly what #615 did to five others: QuestionReminder.RemindAt, ClientApprovalResponse's CreatedAt and UpdatedAt, TimelineEvent.CreatedAt and WebhookDelivery.CreatedAt compile untouched through a value-receiver call and panic on nil. #615's own check could not see them because it keyed on the omitempty tag and these five did not carry one. The audit is ten fields, and the entry names the near-miss siblings that did not move, ClientApproval's pair in particular. #664 splits. The public CreateScheduleEntryRequest fields were already string and still are, so the wrapper half is silent: the RFC3339 ErrUsage guard is gone, a bare date now creates an all-day entry, and a malformed value reaches bc3 instead of failing locally. That is class A. The generated CreateScheduleEntryRequestContent went time.Time to string, which is a compile error for pkg/generated importers. ReplaceScheduleEntryRequestContent is not a migration from v0.12.0 at all; #632 introduced it. TypeScript and Ruby are doc-comment only. #647 is folded in as merged, with two corrections to what was written when it was still a branch. It touches no schema, so the claim that it had to go Smithy-first is withdrawn; UpdateCardStepRequestContent.DueOn was pointerized by #560. And the v0.12.0 preservation GET was conditional, taken only when the caller left due_on unaddressed, so the request-count table is scoped to that path rather than presented as universal. #648 adds no silent break anywhere. bc3's body is byte-identical before and after, so nothing that was populated stops being so; the assignable's title was never sent and is now spelled content. Every rename and retype is caught statically in Go, Swift, TypeScript and Kotlin and raised immediately in Python and Ruby, so it is one compile-or-runtime entry per SDK. Two corrections nobody asked for. The Go class list opened "Go carries every class-B break in the release", which stopped being true when Ruby's decode entry moved into class B; it now claims only the panic-shaped ones. And todos_write.json carries three errorRaised cases, not two, because #660 added a bare-scalar kill. #660 is a Kotlin class-B entry, which is new. Removing the client-wide isLenient means a present, populated, wrong-typed scalar throws SerializationException where it used to coerce to a string, and no signature moved to announce it. It throws in the response decode, so on a write the mutation has already landed, and it is not a BasecampException outside todolists. #656 is Ruby class A, scoped tightly: only max_retries 0, only an ungoverned GET, which means get_absolute and the Launchpad fetch rather than any operation lacking a policy. Every other configuration is bit-identical. Not in this release is now empty, and says so. * State the schedule-entry clear value per field instead of universally The Swift Behavioural bullet said an explicit "" clears any of the five full-state fields. Only description does. "" on summary is accepted and reads back "Untitled"; starts_at and ends_at are under validates_presence_of in Schedule::Entry, so "" is rejected rather than cleared; allDay is a boolean in every SDK, so "" does not typecheck at all. The carve-out half grouped notify with the three clearable fields even though it is a send directive with no state to clear. * Re-derive the per-SDK README banners against the final class A/B table The six SDK README banners still carried the counts from before the Go reclassification and the recount that followed it, summing to 51 where MIGRATING.md and the root README say 61. Each banner now matches its row in the class A/B table: Go 12+4, Swift 10, TypeScript 9, Python 8, Ruby 10+1, Kotlin 6+1. Kotlin also gains the runtime clause it was missing, since its one class B entry throws on a present field carrying a JSON number or boolean where the model declares a string. * Correct the merged-PR count and the two claims the reviewers caught The release spans 55 merged pull requests, not 67. The 67 came from comparing GitHub's Z-formatted mergedAt against a git timestamp formatted with a local offset, using jq's string >, which is lexicographic rather than temporal; it wrongly swept in twelve PRs merged in the hours before the v0.12.0 tag instant. The derivation embedded in the guide taught that same broken comparison, so it now uses %ct and fromdateiso8601 and says why. The breaking count of fifteen is unchanged, since all fifteen merged after the tag, so the class A/B split, the per-SDK tables and the six README banners are untouched. The header no longer calls 2afc977 the commit the release is cut from. That commit is the last of the release content and the baseline the counts were measured against, but it predates this guide; the tag is cut from main after this merges, on a tree that contains the file the release body links to. The release-body teaser claimed the guide covers only breaks with no exception and no decoder failure. The guide documents six breaks that do fail at runtime, including Ruby and Kotlin raises and a Kotlin decoder failure, so the teaser now names both the silent class and the runtime one.
Closes #562.
Five optional timestamps on the hand-written Go wrapper surface move from
time.Timeto*time.Time. Nothing at your call sites will stop compiling.hc.UpdatedAt.IsZero()compiles exactly as it did before — Go inserts the dereference for a value-receiver method call — and then panics on nil at runtime. No compiler error, just a crash in production the first time the API omits the field.HillChartUpdatedAttime.Time`json:"updated_at,omitempty"`*time.Time`json:"updated_at,omitempty"`NotificationReadAttime.Time`json:"read_at,omitempty"`*time.Time`json:"read_at,omitempty"`NotificationUnreadAttime.Time`json:"unread_at,omitempty"`*time.Time`json:"unread_at,omitempty"`SearchResultCreatedAttime.Time`json:"created_at"`*time.Time`json:"created_at,omitempty"`SearchResultUpdatedAttime.Time`json:"updated_at"`*time.Time`json:"updated_at,omitempty"`Migration
Nil-check before touching any of the five. Every pattern in the first block below compiles both before and after this change; only the second block is safe after it.
Two behavioural notes beyond the type:
IsZero()as "absent" must switch to== nil. That reading was never sound anyway: it could not distinguish an omitted key from a server that genuinely sent0001-01-01T00:00:00Z."updated_at":"0001-01-01T00:00:00Z"; it is now omitted. If you round-trip these structs through your own storage or golden files, that fabricated instant will stop appearing — which is the point, but it is a diff.SearchResultadditionally gains,omitemptyon both tags. Those two were previously tagged as required while the schema marks them optional and the generated client types them*time.Time; the tags now match reality.The defect, precisely
All five schemas mark the field optional and the generated client types all five as
*time.Time. The wrapper flattened them — throughderef()inhillChartFromGenerated/searchResultFromGenerated, and via directjson.UnmarshalforNotification.That did two things at once:
,omitemptywas inert.encoding/json's empty set isfalse/0/ nil pointer / nil interface / empty array, slice, map, string. A struct is never empty, andtime.Timeis a struct — so the key was re-emitted on every marshal, carrying the fabricated0001-01-01T00:00:00Z.Point 2 is the boundary that separates timestamps from the rest of this surface.
Title string `json:"title,omitempty"`is lossy in a benign way: absent and""collapse, and both are then omitted, so the value round-trips. A value-typed optionaltime.Timefabricates data on the wire.How the five were found — and five more that are not fixed here
The issue body names
HillChart.UpdatedAt; its title also names theSearchResulttimestamps. Neither grep nor any existing gate can enumerate the class, because the wrapper source does not say the field is optional — that evidence lives in the schema and in the generated client. So the enumeration is a type cross-reference between the two sides:go/pkg/basecamp/*.gofor struct fields typed exactlytime.Time, keyed(struct name, json tag)go/pkg/generated/client.gen.gothe same way*time.TimeAgainst this PR's base (
a7354ae35) that scan reads 106 value-typedtime.Timewrapper fields, of which 10 have an optional generated counterpart, 0 unpaired. It discriminates:generated.Webhook.CreatedAt,generated.Question.CreatedAtandgenerated.ClientApproval.CreatedAtare valuetime.Time(genuinely required) and do not appear;EverythingFile.CreatedAt/UpdatedAtare already*time.Timeand do not appear.This PR fixes 5 of those 10. The other 5 are real and are reported, not fixed, in #620:
TimelineEventCreatedAttimeline.go:20—time.Time`json:"created_at"`*time.TimeWebhookDeliveryCreatedAtwebhooks.go:40—time.Time`json:"created_at"`*time.TimeQuestionReminderRemindAtcheckins.go:143—time.Time`json:"remind_at"`*time.TimeClientApprovalResponseCreatedAtclient_approvals.go:68—time.Time`json:"created_at"`*time.TimeClientApprovalResponseUpdatedAtclient_approvals.go:69—time.Time`json:"updated_at"`*time.TimeNone is
@requiredinspec/basecamp.smithy. Each is a silent break with the same failure mode as the five above, so each needs the same deliberate migration note — which is why they are a separate change rather than a late amendment to this one, not because they are less real.The five that do ship together ship together because the failure mode is invisible to the compiler: splitting them would ask consumers to hunt for nil-safety three times over, each time with no build error to guide them.
Red proof
go/pkg/basecamp/optional_timestamps_test.gois written so every assertion compiles against both the fixed and un-fixed trees — it asserts on marshaled JSON keys, never on the field types, so nothing had to be edited between the two runs. Against the un-fixed tree, verbatim fromgo test ./pkg/basecamp/ -run 'TestOptionalTimestamps|TestNilOptionalTimestampPanics|TestNoValueTypedOptionalTimestamps' -v:Note what
TestNilOptionalTimestampPanicsOnValueReceiverCallactually proves. Pre-fix,hc.UpdatedAt.IsZero()on an absent timestamp does not panic and does not complain — it silently answerstrue. That silence is the disease. The panic is the post-fix migration hazard, and the test asserts it so the hazard lives in code rather than only in this description.TestOptionalTimestampsRoundTripWhenPresentis the positive control and passes on both trees, so the fix cannot have been "omit everything".Note also that the AST guard's red run lists only three of the five — that is not a defect in the run, it is the guard's coverage, and the next section is about exactly that.
Post-fix, all six subtests pass — see verification below.
Why the existing gate did not catch this, and what is still unguarded
scripts/check-go-optional-pointersenforces this invariant on the generated client, so the first question was whether it should have fired. Stating the answer plainly, because an earlier version of this description and a comment on #562 both got it wrong in the direction that hides a bug:Pointed at the wrapper surface, it would have caught three of the five.
HillChart.UpdatedAt,Notification.ReadAtandNotification.UnreadAtare value-typed and carry,omitempty, which is exactly its violation shape. Run against the pre-fix files it exits 1 on each:It caught none of them only because
make go-check-optional-pointersinvokes it with no argument:so its entire domain is
go/pkg/generated/client.gen.go. That scoping should nonetheless stay: pointed at the whole pre-fix wrapper surface it reports 345 violations, because this package flattens optional*string/*bool/*intto value types on purpose — that is what makes it the "clean" surface.It could not have caught the other two at any scope. It keys entirely on
,omitempty(next unless m[3].match?(OMITEMPTY)), andSearchResult.CreatedAt/UpdatedAtwere taggedjson:"created_at"with no options — tagged as if required. Run against the pre-fixsearch.goit reports four violations and neither timestamp is among them.So this PR adds
TestNoValueTypedOptionalTimestamps: an AST guard overgo/pkg/basecamp/*.gorejecting any value-typedtime.Timecarrying,omitemptyin any tag key. It runs undermake go-test— no new Makefile target, no new CI wiring, nothing for a sibling lane to collide with. It asserts it scanned a non-zero number oftime.Timefields, so a broken walk fails loudly instead of reporting success forever.Be clear about what that guard does not do. It inherits the same
,omitemptyblind spot (if !hasOmitempty(tag) { continue }), so it would not have caught the twoSearchResultfields either, and it does not catch the five holdouts in #620 — all five are tagged without options. It prevents regression of the three-field shape, not of the class.Two gaps stay open, reported rather than quietly closed:
Type-level wrapper/generated drift is unguarded — this is the one that matters.
scripts/check-wrapper-driftalready computes the(wrapper, generated)struct pairing from the*FromGeneratedconverters, but compares JSON tag names only. A wrapper field typedtime.Timewhose generated counterpart is*time.Timepasses it cleanly. That is the only place in the tree holding both sides of the comparison, and it is where the 10-field cross-reference above belongs as a permanent gate. Two limits for whoever writes it:Notificationhas no*FromGeneratedconverter (directjson.Unmarshal), so converter-derived pairing misses it; and the comparison must be scoped to timestamps or it re-derives the 345-violation wall. Tracked in Five more Go wrapper timestamps cannot represent absence, and no gate can see them #620.Six non-timestamp fields are in the same inert-
omitemptyclass:Named structs, so
omitemptyis equally inert, and each emits a fully zero-valued nested object instead of being omitted. Less harmful than a fabricated instant — a zero{}reads as obviously empty in a way0001-01-01T00:00:00Zdoes not — and outside this lane, so they are named in Five more Go wrapper timestamps cannot represent absence, and no gate can see them #620 rather than swept in.Review findings, all real, all fixed
1. The SDK's own godoc demonstrated the hazard (
chatgpt-codex-connector).ExampleSearchService_Search_sortedcalledr.CreatedAt.Format(…)with no nil check — the exact unsafe pattern this PR warns about, shipped in the documentation. Fixed in1fa9c13b4. Reproduced literally first: that expression against aSearchResult{Title: "Kickoff notes"}panics withruntime error: invalid memory address or nil pointer dereference.make checkwas green over it because godoc examples without an// Output:comment are compiled but never executed, and becauseTestNoValueTypedOptionalTimestampsis a declaration guard — it can prove no field is declared with the unsafe shape, but it cannot see an unsafe use.So rather than fix the one named site and hope, I enumerated the class with the compiler. In a scratch copy all five fields became a defined pointer type with an empty method set:
Every auto-dereferencing use site then becomes a compile error, so the type checker lists them exhaustively. Three passes, neutralizing each batch to reach the next package:
Seven sites in the whole module; the final pass is clean. Six are deliberately guarded (
hill_charts_test.gobehindif hc.UpdatedAt == nil,search_test.gobehindif … == nil { … } else if, andoptional_timestamps_test.go:196is the intentional nil-deref in the panic test). The seventh was the example. The probe is a one-off technique, not a committed guard — a permanent version needs a type-aware nilness pass, which belongs with thecheck-wrapper-driftgap above.2. A doc cross-reference that named a field that does not exist (
copilot-pull-request-reviewer). TheHillChart.UpdatedAtcomment citedTimelineEvent.UpdatedAtas precedent.TimelineEvent(timeline.go:18) has noUpdatedAt; the optional*time.Timepair attimeline.go:85-86belongs toTimelineAttachment(timeline.go:62). Now citesEverythingFile.UpdatedAt(everything.go:64), re-resolved to its declaring struct rather than assumed. Fixed in1ec2c7583.That error came from #562's own table. My correction on the issue then overshot — it called
TimelineEvent.CreatedAt"required and value-typed", whenstructure TimelineEventcarries no@requiredoncreated_atand the generated client types it*time.Time. It is value-typed, and it is a holdout, not a counter-example. Re-corrected on the issue and tracked in #620.3. A planning doc left stale by this change (
copilot-pull-request-reviewer, raised in the review body rather than as an inline thread).go/BRIEF-bc5-forward-compat-wrappers.md:147told the next author to typeNotification.BubbleUpAtas*time.Time"rather than the baretime.Timepattern the wrapper uses forReadAt/UnreadAt" — a counter-example this PR deletes. The advice was right and its only concrete anchor inverted. Now citesCard.CompletedAt(cards.go:101),Todo.CompletedAt(todos.go:52) and the newly-pointerizedReadAt/UnreadAt(my_notifications.go:52-53), each re-resolved to source.Scope
Go only. No spec change:
spec/basecamp.smithyalready marks all five optional (no@requiredonHillChart.updated_at,Notification.read_at/unread_at,SearchResult.created_at/updated_at), and the generated client already types them*time.Time— the wrapper was the only thing out of step.spec/basecamp.smithyandopenapi.jsonare untouched, as is every generated artifact.No conformance fixture and no runner dispatch: nothing under
conformance/runner/**reads any of the five fields, so no runner gains a new case in any of the six.