Skip to content

Go: pointerize the five optional timestamps that could not represent absence - #615

Merged
jeremy merged 4 commits into
mainfrom
fix/562-optional-timestamp-pointers
Aug 3, 2026
Merged

jeremy merged 4 commits into
mainfrom
fix/562-optional-timestamp-pointers

Conversation

@jeremy

@jeremy jeremy commented Aug 3, 2026 •

Copy link
Copy Markdown
Member

Closes #562.

⚠️ This is a silent breaking change. Read the migration note.

Five optional timestamps on the hand-written Go wrapper surface move from time.Time to *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.

Type Field Before After
HillChart UpdatedAt time.Time `json:"updated_at,omitempty"` *time.Time `json:"updated_at,omitempty"`
Notification ReadAt time.Time `json:"read_at,omitempty"` *time.Time `json:"read_at,omitempty"`
Notification UnreadAt time.Time `json:"unread_at,omitempty"` *time.Time `json:"unread_at,omitempty"`
SearchResult CreatedAt time.Time `json:"created_at"` *time.Time `json:"created_at,omitempty"`
SearchResult UpdatedAt time.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.

// BEFORE — still compiles after this change, and panics on nil.
if hc.UpdatedAt.IsZero() { … }
fmt.Println(hc.UpdatedAt.Format(time.RFC3339))
age := time.Since(n.ReadAt)
sort.Slice(rs, func(i, j int) bool { return rs[i].CreatedAt.Before(rs[j].CreatedAt) })
// AFTER — nil means "the API did not send this field".
if hc.UpdatedAt == nil { … }                            // absent
if hc.UpdatedAt != nil && hc.UpdatedAt.IsZero() { … }   // present and zero

if hc.UpdatedAt != nil {
    fmt.Println(hc.UpdatedAt.Format(time.RFC3339))
}

if n.ReadAt != nil {
    age := time.Since(*n.ReadAt)
}

sort.Slice(rs, func(i, j int) bool {
    if rs[i].CreatedAt == nil || rs[j].CreatedAt == nil {
        return rs[j].CreatedAt != nil                    // absent sorts last
    }
    return rs[i].CreatedAt.Before(*rs[j].CreatedAt)
})

Two behavioural notes beyond the type:

  • Absent no longer reads as the zero time. Code that treated 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 sent 0001-01-01T00:00:00Z.
  • The wire form changes on re-marshal. An absent timestamp previously came back out as "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.

SearchResult additionally gains ,omitempty on 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 — through deref() in hillChartFromGenerated / searchResultFromGenerated, and via direct json.Unmarshal for Notification.

That did two things at once:

  1. Absence collapsed to the zero time, indistinguishable from a real (if implausible) value.
  2. ,omitempty was inert. encoding/json's empty set is false / 0 / nil pointer / nil interface / empty array, slice, map, string. A struct is never empty, and time.Time is a struct — so the key was re-emitted on every marshal, carrying the fabricated 0001-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 optional time.Time fabricates 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 the SearchResult timestamps. 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:

  • parse every non-test go/pkg/basecamp/*.go for struct fields typed exactly time.Time, keyed (struct name, json tag)
  • parse go/pkg/generated/client.gen.go the same way
  • report every wrapper field whose name-matched, tag-matched generated counterpart is *time.Time

Against this PR's base (a7354ae35) that scan reads 106 value-typed time.Time wrapper fields, of which 10 have an optional generated counterpart, 0 unpaired. It discriminates: generated.Webhook.CreatedAt, generated.Question.CreatedAt and generated.ClientApproval.CreatedAt are value time.Time (genuinely required) and do not appear; EverythingFile.CreatedAt/UpdatedAt are already *time.Time and do not appear.

This PR fixes 5 of those 10. The other 5 are real and are reported, not fixed, in #620:

Struct Field Wrapper Generated
TimelineEvent CreatedAt timeline.go:20 — time.Time `json:"created_at"` *time.Time
WebhookDelivery CreatedAt webhooks.go:40 — time.Time `json:"created_at"` *time.Time
QuestionReminder RemindAt checkins.go:143 — time.Time `json:"remind_at"` *time.Time
ClientApprovalResponse CreatedAt client_approvals.go:68 — time.Time `json:"created_at"` *time.Time
ClientApprovalResponse UpdatedAt client_approvals.go:69 — time.Time `json:"updated_at"` *time.Time

None is @required in spec/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.go is 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 from go test ./pkg/basecamp/ -run 'TestOptionalTimestamps|TestNilOptionalTimestampPanics|TestNoValueTypedOptionalTimestamps' -v:

=== RUN   TestOptionalTimestampsOmitWhenAbsent
=== RUN   TestOptionalTimestampsOmitWhenAbsent/HillChart
    optional_timestamps_test.go:112: HillChart.updated_at was absent on the wire but re-marshaled as "0001-01-01T00:00:00Z"; a value-typed time.Time cannot represent absence and `,omitempty` never fires for a struct
=== RUN   TestOptionalTimestampsOmitWhenAbsent/Notification
    optional_timestamps_test.go:112: Notification.read_at was absent on the wire but re-marshaled as "0001-01-01T00:00:00Z"; a value-typed time.Time cannot represent absence and `,omitempty` never fires for a struct
    optional_timestamps_test.go:112: Notification.unread_at was absent on the wire but re-marshaled as "0001-01-01T00:00:00Z"; a value-typed time.Time cannot represent absence and `,omitempty` never fires for a struct
=== RUN   TestOptionalTimestampsOmitWhenAbsent/SearchResult
    optional_timestamps_test.go:112: SearchResult.created_at was absent on the wire but re-marshaled as "0001-01-01T00:00:00Z"; a value-typed time.Time cannot represent absence and `,omitempty` never fires for a struct
    optional_timestamps_test.go:112: SearchResult.updated_at was absent on the wire but re-marshaled as "0001-01-01T00:00:00Z"; a value-typed time.Time cannot represent absence and `,omitempty` never fires for a struct
--- FAIL: TestOptionalTimestampsOmitWhenAbsent (0.00s)
    --- FAIL: TestOptionalTimestampsOmitWhenAbsent/HillChart (0.00s)
    --- FAIL: TestOptionalTimestampsOmitWhenAbsent/Notification (0.00s)
    --- FAIL: TestOptionalTimestampsOmitWhenAbsent/SearchResult (0.00s)
=== RUN   TestOptionalTimestampsRoundTripWhenPresent
=== RUN   TestOptionalTimestampsRoundTripWhenPresent/HillChart
=== RUN   TestOptionalTimestampsRoundTripWhenPresent/Notification
=== RUN   TestOptionalTimestampsRoundTripWhenPresent/SearchResult
--- PASS: TestOptionalTimestampsRoundTripWhenPresent (0.00s)
    --- PASS: TestOptionalTimestampsRoundTripWhenPresent/HillChart (0.00s)
    --- PASS: TestOptionalTimestampsRoundTripWhenPresent/Notification (0.00s)
    --- PASS: TestOptionalTimestampsRoundTripWhenPresent/SearchResult (0.00s)
=== RUN   TestOptionalTimestampsSurviveGeneratedConversion
=== RUN   TestOptionalTimestampsSurviveGeneratedConversion/HillChart
    optional_timestamps_test.go:168: HillChart.updated_at: generated value carried a nil timestamp but the wrapper emitted "0001-01-01T00:00:00Z"
=== RUN   TestOptionalTimestampsSurviveGeneratedConversion/SearchResult
    optional_timestamps_test.go:168: SearchResult.created_at: generated value carried a nil timestamp but the wrapper emitted "0001-01-01T00:00:00Z"
    optional_timestamps_test.go:168: SearchResult.updated_at: generated value carried a nil timestamp but the wrapper emitted "0001-01-01T00:00:00Z"
--- FAIL: TestOptionalTimestampsSurviveGeneratedConversion (0.00s)
    --- FAIL: TestOptionalTimestampsSurviveGeneratedConversion/HillChart (0.00s)
    --- FAIL: TestOptionalTimestampsSurviveGeneratedConversion/SearchResult (0.00s)
=== RUN   TestNilOptionalTimestampPanicsOnValueReceiverCall
    optional_timestamps_test.go:191: expected a nil-pointer panic from IsZero() on an absent UpdatedAt: if this line is reached the field is still value-typed and absence reads as 0001-01-01T00:00:00Z
--- FAIL: TestNilOptionalTimestampPanicsOnValueReceiverCall (0.00s)
=== RUN   TestNoValueTypedOptionalTimestamps
    optional_timestamps_test.go:280: optional timestamp is value-typed and cannot represent absence (`,omitempty` is inert for a struct, so the key is emitted as 0001-01-01T00:00:00Z): hill_charts.go:15:2: UpdatedAt time.Time `json:"updated_at,omitempty"`
    optional_timestamps_test.go:280: optional timestamp is value-typed and cannot represent absence (`,omitempty` is inert for a struct, so the key is emitted as 0001-01-01T00:00:00Z): my_notifications.go:46:2: ReadAt time.Time `json:"read_at,omitempty"`
    optional_timestamps_test.go:280: optional timestamp is value-typed and cannot represent absence (`,omitempty` is inert for a struct, so the key is emitted as 0001-01-01T00:00:00Z): my_notifications.go:47:2: UnreadAt time.Time `json:"unread_at,omitempty"`
--- FAIL: TestNoValueTypedOptionalTimestamps (0.03s)
FAIL
FAIL	github.com/basecamp/basecamp-sdk/go/pkg/basecamp	0.310s
FAIL
REAL_EXIT=1

Note what TestNilOptionalTimestampPanicsOnValueReceiverCall actually proves. Pre-fix, hc.UpdatedAt.IsZero() on an absent timestamp does not panic and does not complain — it silently answers true. 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.

TestOptionalTimestampsRoundTripWhenPresent is 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-pointers enforces 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.ReadAt and Notification.UnreadAt are value-typed and carry ,omitempty, which is exactly its violation shape. Run against the pre-fix files it exits 1 on each:

$ scripts/check-go-optional-pointers <pre-fix hill_charts.go>
check-go-optional-pointers: 5 optional field(s) cannot represent absence (non-pointer value type with omitempty):
  hill_charts.go:15: UpdatedAt time.Time
  …
REAL_EXIT=1

$ scripts/check-go-optional-pointers <pre-fix my_notifications.go>
  my_notifications.go:46: ReadAt time.Time
  my_notifications.go:47: UnreadAt time.Time
REAL_EXIT=1

It caught none of them only because make go-check-optional-pointers invokes it with no argument:

path = ARGV[0] || File.expand_path("../go/pkg/generated/client.gen.go", __dir__)

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 / *int to 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)), and SearchResult.CreatedAt/UpdatedAt were tagged json:"created_at" with no options — tagged as if required. Run against the pre-fix search.go it reports four violations and neither timestamp is among them.

So this PR adds TestNoValueTypedOptionalTimestamps: an AST guard over go/pkg/basecamp/*.go rejecting any value-typed time.Time carrying ,omitempty in any tag key. It runs under make 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 of time.Time fields, so a broken walk fails loudly instead of reporting success forever.

Be clear about what that guard does not do. It inherits the same ,omitempty blind spot (if !hasOmitempty(tag) { continue }), so it would not have caught the two SearchResult fields 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:

  1. Type-level wrapper/generated drift is unguarded — this is the one that matters. scripts/check-wrapper-drift already computes the (wrapper, generated) struct pairing from the *FromGenerated converters, but compares JSON tag names only. A wrapper field typed time.Time whose generated counterpart is *time.Time passes 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: Notification has no *FromGenerated converter (direct json.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.

  2. Six non-timestamp fields are in the same inert-omitempty class:

    go/pkg/basecamp/account_service.go:28: Limits       AccountLimits       `json:"limits,omitempty"`
    go/pkg/basecamp/account_service.go:29: Settings     AccountSettings     `json:"settings,omitempty"`
    go/pkg/basecamp/account_service.go:30: Subscription AccountSubscription `json:"subscription,omitempty"`
    go/pkg/basecamp/my_assignments.go:24:  Bucket       MyAssignmentBucket  `json:"bucket,omitempty"`
    go/pkg/basecamp/my_assignments.go:25:  Parent       MyAssignmentParent  `json:"parent,omitempty"`
    go/pkg/basecamp/people.go:571:         Person       OutOfOfficePerson   `json:"person,omitempty"`
    

    Named structs, so omitempty is 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 way 0001-01-01T00:00:00Z does 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_sorted called r.CreatedAt.Format(…) with no nil check — the exact unsafe pattern this PR warns about, shipped in the documentation. Fixed in 1fa9c13b4. Reproduced literally first: that expression against a SearchResult{Title: "Kickoff notes"} panics with runtime error: invalid memory address or nil pointer dereference.

make check was green over it because godoc examples without an // Output: comment are compiled but never executed, and because TestNoValueTypedOptionalTimestamps is 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:

type probeTime *time.Time   // defined, not an alias: no method set, no selector auto-deref

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:

pkg/basecamp/hill_charts_test.go:84:18: hc.UpdatedAt.IsZero undefined (type probeTime has no field or method IsZero)
pkg/basecamp/hill_charts_test.go:87:18: hc.UpdatedAt.Year undefined (type probeTime has no field or method Year)
pkg/basecamp/hill_charts_test.go:88:55: hc.UpdatedAt.Year undefined (type probeTime has no field or method Year)
pkg/basecamp/optional_timestamps_test.go:196:19: hc.UpdatedAt.IsZero undefined (type probeTime has no field or method IsZero)
pkg/basecamp/search_test.go:193:25: r1.CreatedAt.IsZero undefined (type probeTime has no field or method IsZero)
pkg/basecamp/search_test.go:198:25: r1.UpdatedAt.IsZero undefined (type probeTime has no field or method IsZero)
vet: pkg/basecamp/example_test.go:255:26: r.CreatedAt.Format undefined (type basecamp.probeTime has no field or method Format)
$ go vet ./...
REAL_EXIT=0

Seven sites in the whole module; the final pass is clean. Six are deliberately guarded (hill_charts_test.go behind if hc.UpdatedAt == nil, search_test.go behind if … == nil { … } else if, and optional_timestamps_test.go:196 is 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 the check-wrapper-drift gap above.

2. A doc cross-reference that named a field that does not exist (copilot-pull-request-reviewer). The HillChart.UpdatedAt comment cited TimelineEvent.UpdatedAt as precedent. TimelineEvent (timeline.go:18) has no UpdatedAt; the optional *time.Time pair at timeline.go:85-86 belongs to TimelineAttachment (timeline.go:62). Now cites EverythingFile.UpdatedAt (everything.go:64), re-resolved to its declaring struct rather than assumed. Fixed in 1ec2c7583.

That error came from #562's own table. My correction on the issue then overshot — it called TimelineEvent.CreatedAt "required and value-typed", when structure TimelineEvent carries no @required on created_at and 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:147 told the next author to type Notification.BubbleUpAt as *time.Time "rather than the bare time.Time pattern the wrapper uses for ReadAt / UnreadAt" — a counter-example this PR deletes. The advice was right and its only concrete anchor inverted. Now cites Card.CompletedAt (cards.go:101), Todo.CompletedAt (todos.go:52) and the newly-pointerized ReadAt/UnreadAt (my_notifications.go:52-53), each re-resolved to source.

Scope

Go only. No spec change: spec/basecamp.smithy already marks all five optional (no @required on HillChart.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.smithy and openapi.json are 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.

Copilot AI review requested due to automatic review settings August 3, 2026 17:50
@jeremy jeremy added bug Something isn't working go breaking Breaking change to public API labels Aug 3, 2026
@jeremy

jeremy commented Aug 3, 2026

Copy link
Copy Markdown
Member Author

npm Audit (TypeScript SDK) is red, and it is not this branch

It is a freshly published advisory hitting main's own lockfile, not anything here.

  • This PR's diff is six files, all under go/pkg/basecamp/. git diff --name-only origin/main...HEAD lists no JavaScript, and typescript/package.json / typescript/package-lock.json are byte-identical to origin/main.
  • The advisory is GHSA-rgw5-rvv9-x895 — brace-expansion DoS, high, a transitive dep. It was published after main's last CI run (that job went green at 14:46 UTC; this one failed at 17:50 UTC on the same lockfile).
  • Running CI's exact command in this worktree, against the lockfile that is identical to main:
$ npm audit --audit-level=high
# npm audit report

brace-expansion  4.0.0 - 5.0.8
Severity: high
brace-expansion: DoS via unbounded intermediate arrays, bypassing the CVE-2026-14257 mitigation - https://github.com/advisories/GHSA-rgw5-rvv9-x895
fix available via `npm audit fix`
node_modules/brace-expansion

1 high severity vulnerability

To address all issues, run:
  npm audit fix
REAL_EXIT=1

Deliberately not fixed here. Bumping the lockfile in a Go-only breaking-change PR would put a dependency change in the wrong review and would conflict with every other branch currently open against main. It wants its own PR (or the dependabot run that will pick it up), and it will be red on main and on every open branch until then.

Every other check on this PR is green.

@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: 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".

Comment thread go/pkg/basecamp/search.go

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 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; drop deref(...) in the *FromGenerated converters and add ,omitempty to the two SearchResult tags.
  • Add optional_timestamps_test.go: round-trip proofs, a nil-pointer migration-hazard test, and an AST regression guard (TestNoValueTypedOptionalTimestamps) rejecting value-typed time.Time fields 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.

Comment thread go/pkg/basecamp/hill_charts.go Outdated
jeremy added a commit that referenced this pull request Aug 3, 2026
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.
Copilot AI review requested due to automatic review settings August 3, 2026 17:56

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 7 out of 7 changed files in this pull request and generated no new comments.

jeremy added a commit that referenced this pull request Aug 3, 2026
…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.
Copilot AI review requested due to automatic review settings August 3, 2026 18:04

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 7 out of 7 changed files in this pull request and generated no new comments.

@jeremy

jeremy commented Aug 3, 2026

Copy link
Copy Markdown
Member Author

@codex review

Re-review request at f3b46ae98. Your earlier pass was pinned to 4abc5d71f6; two commits have landed since, both from review findings:

  • 34efdfc30 — your example nil-check finding, plus a compile-level sweep (all five fields swapped to a method-less defined pointer type) proving the example was the only unguarded use site in the module.
  • f3b46ae98 — a doc cross-reference that named a nonexistent field, found by Copilot.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 🎉

Reviewed commit: f3b46ae98a

ℹ️ 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".

jeremy added 4 commits August 3, 2026 12:00
…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.

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.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@jeremy

jeremy commented Aug 3, 2026

Copy link
Copy Markdown
Member Author

@copilot review

@jeremy

jeremy commented Aug 3, 2026

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Bravo.

Reviewed commit: c5b1b42858

ℹ️ 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".

@jeremy

jeremy commented Aug 3, 2026

Copy link
Copy Markdown
Member Author

Convergence note — head c5b1b4285

The npm Audit (TypeScript SDK) failure was branch staleness, not this PR. This branch carried "brace-expansion": ">=5.0.7" in typescript/package.json overrides; GHSA-rgw5-rvv9-x895 covers 4.0.0–5.0.8. main had already raised the floor in #616. This PR touches zero TypeScript files, so the fix was to take main's version, not to hand-edit the override or run npm audit fix.

Rebased onto origin/main (a7354ae35, 2 commits ahead). The rebase was a clean replay — the branch never touched Makefile or .github/workflows/test.yml, so no gate-wiring conflict arose; check: still lists 35 targets, and git diff base..HEAD is byte-identical before and after the rebase.

Resolved state, verified in the committed tree:

override:  >=5.0.9
lockfile:  node_modules/brace-expansion -> 5.0.9
installed: 5.0.9
$ npm ci && npm audit --audit-level=high     # CI's exact command
found 0 vulnerabilities
NPM_AUDIT_REAL_EXIT=0

npm Audit (TypeScript SDK) is green at this head.

Full local make check: MAKE_CHECK_REAL_EXIT=0, with PRE_SHA == POST_SHA == c5b1b42858b120b7206597bbf62aca692749923c so the run is attributable to one tree. Swift executed rather than skipped (374 passing test cases in the log). Local Kotlin jvmTest reported UP-TO-DATE, which is not evidence of execution — CI's Kotlin Tests job runs it from a clean checkout and is green here. make check dirtied typescript/package-lock.json with the known macOS libc-stripping churn (#612); the diff was inspected (24 deletions, all libc entries for Linux-only optional deps), restored from HEAD, and not committed.

Beyond the rebase

A third review finding, raised in a review body rather than an inline thread, and so easy to miss at "0 unresolved": copilot-pull-request-reviewer noted that go/BRIEF-bc5-forward-compat-wrappers.md:147 still described ReadAt/UnreadAt as the bare-time.Time counter-example. Pointerizing them here deleted the only concrete anchor of that advice. Fixed in c5b1b4285; see finding 3 in the description.

Five more holdouts of this exact defect class exist and are not fixed here. The description previously implied the sweep was complete. It was not: a type cross-reference between the wrapper and the generated client finds 10 value-typed time.Time wrapper fields whose generated counterpart is *time.Time. This PR fixes 5. The other 5 — TimelineEvent.CreatedAt, WebhookDelivery.CreatedAt, QuestionReminder.RemindAt, ClientApprovalResponse.CreatedAt/UpdatedAt — are reported in #620, not fixed, because each is a separate silent break deserving its own migration note.

Two corrections to things this PR previously asserted, both of which pointed away from a real bug:

  1. "scripts/check-go-optional-pointers did not miss these" was wrong. At wrapper scope it would have caught 3 of the 5 (it exits 1 on the pre-fix hill_charts.go and my_notifications.go); it caught none only because make go-check-optional-pointers passes no argument, so its domain is client.gen.go alone. It could not have caught the 2 SearchResult fields at any scope, because it keys on ,omitempty and those carried none. The new TestNoValueTypedOptionalTimestamps inherits that same blind spot — stated plainly in the description now, because a guard that appears to close a class but closes half of it is worse than no guard.
  2. My earlier comment on Go wrapper surface types optional timestamps inconsistently (HillChart.UpdatedAt, SearchResult timestamps) #562 called TimelineEvent.CreatedAt "required and value-typed". It is value-typed, but not required — no @required in structure TimelineEvent, and generated.TimelineEvent.CreatedAt is *time.Time. It is a holdout, not a counter-example. Re-corrected on the issue.

Reviews at this head

reviewThreads totalCount: 2, both returned (no pagination truncation), 0 unresolved. Codex re-reviewed the exact head c5b1b42858 — "Didn't find any major issues."

copilot-pull-request-reviewer is red with Copilot encountered an error and was unable to review this pull request — the known infrastructure flake, not a finding. Re-requested once; it errored again. Its last successful pass (18:08:14Z) reviewed 7 of 7 files with no new comments; the only file added since is the BRIEF doc fix, which implements Copilot's own review-body finding. Every other check is green.

@jeremy
jeremy merged commit bc29845 into main Aug 3, 2026
43 of 45 checks passed
@jeremy
jeremy deleted the fix/562-optional-timestamp-pointers branch August 3, 2026 19:30
jeremy added a commit that referenced this pull request Aug 4, 2026
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.
jeremy added a commit that referenced this pull request Aug 4, 2026
* 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.
jeremy added a commit that referenced this pull request Aug 4, 2026
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.
jeremy added a commit that referenced this pull request Aug 4, 2026
* 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.
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 bug Something isn't working go

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Go wrapper surface types optional timestamps inconsistently (HillChart.UpdatedAt, SearchResult timestamps)

2 participants