From 2f1fdcc6b83a1b6e13401087f2b336d40a12cd3e Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 28 Jul 2026 00:22:41 -0700 Subject: [PATCH 1/4] Absorb bubble_up_url across the four shapes that carry it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Already at the provenance pin in my/readings/_reading.json.jbuilder and modeled on Reading. The post-pin drift is the shared recording and todolist partials, and requiredness differs by shape. recordings/_recording.json.jbuilder emits the key only when the caller passes local_assigns[:bubbleupable]. todolists/_todolist.json.jbuilder is the ONLY partial that passes it, and it passes bubbleupable: true unconditionally. Every list, show, and group path renders that partial. So Todolist and TodolistGroup get @required; Recording and SearchResult stay optional, because they are polymorphic projections where only the Todolist variant receives the flag. SearchResult is in scope because Todolists are searchable — Search::Type::TYPE_FILTER_MAPPING maps "Todo" => %w[Todo Todolist] — and Searches::ApiHelper#api_search_result_template_path falls through to to_recordable_partial_path, which for a Todolist resolves to the same todolist partial. Adding required response members changes generated public types, so this is breaking. Fixtures: bubble_up_url added to the direct Todolist and group fixtures, plus field-bearing representatives for the two optional shapes — a Todolist-shaped element in recordings/list.json and a Todolist search result. recordings/get.json is a Message and correctly keeps omitting the key. Both new representatives are registered in the fixture manifest so the coverage guard validates them, and the Go tests assert the shape discriminates: Todolist carries it, every other type does not. spec/api-gaps/recording-bubbleupable-field.md stays open. It tracks a distinct boolean bubbleupable field expressing per-user eligibility. bubble_up_url is a URL whose presence is decided by the rendering call site, so it is constant per shape and cannot answer whether this user can bubble this up. Noted in the entry. --- go/pkg/basecamp/recordings.go | 9 +++- go/pkg/basecamp/recordings_test.go | 17 +++++- go/pkg/basecamp/search.go | 13 +++-- go/pkg/basecamp/search_test.go | 22 ++++++-- go/pkg/basecamp/todolist_groups.go | 28 ++++++---- go/pkg/basecamp/todolists.go | 20 ++++--- go/pkg/generated/client.gen.go | 54 ++++++++++++++----- .../sdk/generated/models/Recording.kt | 1 + .../basecamp/sdk/generated/models/Todolist.kt | 1 + .../sdk/generated/models/TodolistGroup.kt | 1 + openapi.json | 20 +++++++ python/src/basecamp/generated/types.py | 4 ++ ruby/lib/basecamp/generated/metadata.json | 2 +- ruby/lib/basecamp/generated/types.rb | 22 +++++--- spec/api-gaps/recording-bubbleupable-field.md | 11 ++++ spec/basecamp.smithy | 32 +++++++++++ spec/fixtures/manifest.yaml | 21 +++++++- spec/fixtures/recordings/list.json | 50 +++++++++++++++++ spec/fixtures/search/results.json | 49 +++++++++++++++++ spec/fixtures/todolist_groups/get.json | 1 + spec/fixtures/todolist_groups/list.json | 2 + spec/fixtures/todolists/get.json | 1 + spec/fixtures/todolists/list.json | 2 + spec/overlays/examples.smithy | 2 + .../Basecamp/Generated/Models/Recording.swift | 3 ++ .../Generated/Models/SearchResult.swift | 6 +++ .../Basecamp/Generated/Models/Todolist.swift | 3 ++ .../Generated/Models/TodolistGroup.swift | 3 ++ typescript/src/generated/metadata.ts | 2 +- .../src/generated/openapi-stripped.json | 20 +++++++ typescript/src/generated/schema.d.ts | 34 ++++++++++++ 31 files changed, 405 insertions(+), 51 deletions(-) diff --git a/go/pkg/basecamp/recordings.go b/go/pkg/basecamp/recordings.go index 5a35cc684..6bdaac14d 100644 --- a/go/pkg/basecamp/recordings.go +++ b/go/pkg/basecamp/recordings.go @@ -44,7 +44,13 @@ type Recording struct { URL string `json:"url"` AppURL string `json:"app_url"` BookmarkURL string `json:"bookmark_url"` - Content string `json:"content,omitempty"` + // BubbleUpURL is the URL of the Bubble Up record for this recording. Optional + // on this polymorphic projection: recordings/_recording.json.jbuilder emits + // the key only when the caller passes bubbleupable, and todolists/_todolist + // is the only partial that does — so a Todolist-shaped instance carries it + // and the other recording types do not. + BubbleUpURL string `json:"bubble_up_url,omitempty"` + Content string `json:"content,omitempty"` // ContentAttachments and DescriptionAttachments are the rich text companion // arrays carried through the generic recording projection. A given recording // is one type, so it carries only the array matching its rich text attribute @@ -436,6 +442,7 @@ func recordingFromGenerated(gr generated.Recording) Recording { URL: gr.Url, AppURL: gr.AppUrl, BookmarkURL: gr.BookmarkUrl, + BubbleUpURL: gr.BubbleUpUrl, Content: gr.Content, CommentsCount: int(gr.CommentsCount), CommentsURL: gr.CommentsUrl, diff --git a/go/pkg/basecamp/recordings_test.go b/go/pkg/basecamp/recordings_test.go index fe6fd21b1..4140ead0f 100644 --- a/go/pkg/basecamp/recordings_test.go +++ b/go/pkg/basecamp/recordings_test.go @@ -32,8 +32,21 @@ func TestRecording_UnmarshalList(t *testing.T) { t.Fatalf("failed to unmarshal list.json: %v", err) } - if len(recordings) != 2 { - t.Errorf("expected 2 recordings, got %d", len(recordings)) + if len(recordings) != 3 { + t.Errorf("expected 3 recordings, got %d", len(recordings)) + } + + // bubble_up_url is optional on the generic Recording projection: only the + // todolist partial passes bubbleupable, so the two Message recordings omit + // it and the Todolist one carries it. + for _, r := range recordings { + if r.Type == "Todolist" { + if r.BubbleUpURL == "" { + t.Errorf("Todolist recording %d: expected BubbleUpURL to be set", r.ID) + } + } else if r.BubbleUpURL != "" { + t.Errorf("%s recording %d: expected no BubbleUpURL, got %q", r.Type, r.ID, r.BubbleUpURL) + } } // Verify first recording diff --git a/go/pkg/basecamp/search.go b/go/pkg/basecamp/search.go index 3d19bea6f..dac6a992d 100644 --- a/go/pkg/basecamp/search.go +++ b/go/pkg/basecamp/search.go @@ -22,9 +22,15 @@ type SearchResult struct { URL string `json:"url"` AppURL string `json:"app_url"` BookmarkURL string `json:"bookmark_url"` - Parent *Parent `json:"parent,omitempty"` - Bucket *Bucket `json:"bucket,omitempty"` - Creator *Person `json:"creator,omitempty"` + // BubbleUpURL is the URL of the Bubble Up record for this recording. Optional + // on this polymorphic projection: recordings/_recording.json.jbuilder emits + // the key only when the caller passes bubbleupable, and todolists/_todolist + // is the only partial that does — so a Todolist-shaped instance carries it + // and the other recording types do not. + BubbleUpURL string `json:"bubble_up_url,omitempty"` + Parent *Parent `json:"parent,omitempty"` + Bucket *Bucket `json:"bucket,omitempty"` + Creator *Person `json:"creator,omitempty"` // Content and Description are always present on the wire and always null: // api/searches/show.json.jbuilder renders the recording's own partial and // then unconditionally overwrites both with nil to keep the large HTML body @@ -341,6 +347,7 @@ func searchResultFromGenerated(gsr generated.SearchResult) SearchResult { URL: gsr.Url, AppURL: gsr.AppUrl, BookmarkURL: gsr.BookmarkUrl, + BubbleUpURL: gsr.BubbleUpUrl, Content: gsr.Content, Description: gsr.Description, PlainTextContent: gsr.PlainTextContent, diff --git a/go/pkg/basecamp/search_test.go b/go/pkg/basecamp/search_test.go index 7a2ad6769..2ceffa8f8 100644 --- a/go/pkg/basecamp/search_test.go +++ b/go/pkg/basecamp/search_test.go @@ -36,8 +36,22 @@ func TestSearchResult_UnmarshalResults(t *testing.T) { t.Fatalf("failed to unmarshal results.json: %v", err) } - if len(results) != 3 { - t.Errorf("expected 3 results, got %d", len(results)) + if len(results) != 4 { + t.Errorf("expected 4 results, got %d", len(results)) + } + + // bubble_up_url rides the polymorphic search projection: Todolists are + // searchable and api_search_result_template_path falls through to + // todolists/todolist, which passes bubbleupable: true. Every other result + // type omits the key. + for _, r := range results { + if r.Type == "Todolist" { + if r.BubbleUpURL == "" { + t.Errorf("Todolist result %d: expected BubbleUpURL to be set", r.ID) + } + } else if r.BubbleUpURL != "" { + t.Errorf("%s result %d: expected no BubbleUpURL, got %q", r.Type, r.ID, r.BubbleUpURL) + } } // Verify first result (Message) @@ -515,8 +529,8 @@ func TestSearchService_Search_BestMatchSort(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if len(result.Results) != 3 { - t.Errorf("expected 3 results, got %d", len(result.Results)) + if len(result.Results) != 4 { + t.Errorf("expected 4 results, got %d", len(result.Results)) } // End-to-end projection proof: the polymorphic search projection carries a diff --git a/go/pkg/basecamp/todolist_groups.go b/go/pkg/basecamp/todolist_groups.go index 0f10da1cc..dc6047cd3 100644 --- a/go/pkg/basecamp/todolist_groups.go +++ b/go/pkg/basecamp/todolist_groups.go @@ -33,17 +33,22 @@ type TodolistGroup struct { AppURL string `json:"app_url"` BookmarkURL string `json:"bookmark_url"` SubscriptionURL string `json:"subscription_url"` - CommentsCount int `json:"comments_count"` - CommentsURL string `json:"comments_url"` - Position int `json:"position"` - Parent *Parent `json:"parent,omitempty"` - Bucket *Bucket `json:"bucket,omitempty"` - Creator *Person `json:"creator,omitempty"` - Name string `json:"name"` - Completed bool `json:"completed"` - CompletedRatio string `json:"completed_ratio"` - TodosURL string `json:"todos_url"` - AppTodosURL string `json:"app_todos_url"` + // BubbleUpURL is the URL of the Bubble Up record for this recording. Always + // present: todolists/_todolist.json.jbuilder renders the shared recording + // partial with bubbleupable: true unconditionally, and every list, show, and + // group path renders that partial. + BubbleUpURL string `json:"bubble_up_url"` + CommentsCount int `json:"comments_count"` + CommentsURL string `json:"comments_url"` + Position int `json:"position"` + Parent *Parent `json:"parent,omitempty"` + Bucket *Bucket `json:"bucket,omitempty"` + Creator *Person `json:"creator,omitempty"` + Name string `json:"name"` + Completed bool `json:"completed"` + CompletedRatio string `json:"completed_ratio"` + TodosURL string `json:"todos_url"` + AppTodosURL string `json:"app_todos_url"` } // CreateTodolistGroupRequest specifies the parameters for creating a todolist group. @@ -353,6 +358,7 @@ func todolistGroupFromGenerated(gg generated.TodolistGroup) TodolistGroup { AppURL: gg.AppUrl, BookmarkURL: gg.BookmarkUrl, SubscriptionURL: gg.SubscriptionUrl, + BubbleUpURL: gg.BubbleUpUrl, CommentsCount: int(gg.CommentsCount), CommentsURL: gg.CommentsUrl, Position: int(gg.Position), diff --git a/go/pkg/basecamp/todolists.go b/go/pkg/basecamp/todolists.go index a65aa4ef6..178aa901e 100644 --- a/go/pkg/basecamp/todolists.go +++ b/go/pkg/basecamp/todolists.go @@ -29,13 +29,18 @@ type Todolist struct { BoostsCount int `json:"boosts_count,omitempty"` BoostsURL string `json:"boosts_url,omitempty"` SubscriptionURL string `json:"subscription_url"` - CommentsCount int `json:"comments_count"` - CommentsURL string `json:"comments_url"` - Position int `json:"position"` - Parent *Parent `json:"parent,omitempty"` - Bucket *Bucket `json:"bucket,omitempty"` - Creator *Person `json:"creator,omitempty"` - Description string `json:"description"` + // BubbleUpURL is the URL of the Bubble Up record for this recording. Always + // present: todolists/_todolist.json.jbuilder renders the shared recording + // partial with bubbleupable: true unconditionally, and every list, show, and + // group path renders that partial. + BubbleUpURL string `json:"bubble_up_url"` + CommentsCount int `json:"comments_count"` + CommentsURL string `json:"comments_url"` + Position int `json:"position"` + Parent *Parent `json:"parent,omitempty"` + Bucket *Bucket `json:"bucket,omitempty"` + Creator *Person `json:"creator,omitempty"` + Description string `json:"description"` // DescriptionAttachments holds structured metadata for the downloadable files // embedded in the rich text Description. @required — the API always sends this // array (empty when the description has no inline files). No omitempty, so on @@ -400,6 +405,7 @@ func todolistFromGenerated(gtl generated.Todolist) Todolist { BoostsCount: int(gtl.BoostsCount), BoostsURL: gtl.BoostsUrl, SubscriptionURL: gtl.SubscriptionUrl, + BubbleUpURL: gtl.BubbleUpUrl, CommentsCount: int(gtl.CommentsCount), CommentsURL: gtl.CommentsUrl, Position: int(gtl.Position), diff --git a/go/pkg/generated/client.gen.go b/go/pkg/generated/client.gen.go index 069d10028..cef7b41cc 100644 --- a/go/pkg/generated/client.gen.go +++ b/go/pkg/generated/client.gen.go @@ -1966,8 +1966,16 @@ type Recording struct { // aggregate feeds (`/messages.json`, `/comments.json`), whose type-specific // partials render with `boostable: true`. Optional (absent on non-boostable // recordings and on the base/webhook partial). - BoostsCount *int32 `json:"boosts_count,omitempty"` - BoostsUrl *string `json:"boosts_url,omitempty"` + BoostsCount *int32 `json:"boosts_count,omitempty"` + BoostsUrl *string `json:"boosts_url,omitempty"` + + // BubbleUpUrl URL of the Bubble Up record for this recording (BC5 addition). Optional + // here because this is a polymorphic projection: + // `recordings/_recording.json.jbuilder` emits the key only when the caller + // passes `local_assigns[:bubbleupable]`, and `todolists/_todolist` is the + // only partial that does. So a Todolist-shaped instance carries it and the + // other recording types do not. + BubbleUpUrl string `json:"bubble_up_url,omitempty"` Bucket RecordingBucket `json:"bucket"` // Category A message category (type) as rendered by the shared recordings/_category @@ -2221,8 +2229,16 @@ type SearchResponseContent = []SearchResult // SearchResult defines model for SearchResult. type SearchResult struct { - AppUrl string `json:"app_url"` - BookmarkUrl string `json:"bookmark_url,omitempty"` + AppUrl string `json:"app_url"` + BookmarkUrl string `json:"bookmark_url,omitempty"` + + // BubbleUpUrl URL of the Bubble Up record for this recording (BC5 addition). Optional + // here because this is a polymorphic projection: + // `recordings/_recording.json.jbuilder` emits the key only when the caller + // passes `local_assigns[:bubbleupable]`, and `todolists/_todolist` is the + // only partial that does. So a Todolist-shaped instance carries it and the + // other recording types do not. + BubbleUpUrl string `json:"bubble_up_url,omitempty"` Bucket RecordingBucket `json:"bucket,omitempty"` // Content Always present, always null. `api/searches/show.json.jbuilder` renders the @@ -2603,11 +2619,18 @@ type TodoParent struct { // Todolist defines model for Todolist. type Todolist struct { - AppTodosUrl string `json:"app_todos_url,omitempty"` - AppUrl string `json:"app_url"` - BookmarkUrl string `json:"bookmark_url,omitempty"` - BoostsCount int32 `json:"boosts_count,omitempty"` - BoostsUrl string `json:"boosts_url,omitempty"` + AppTodosUrl string `json:"app_todos_url,omitempty"` + AppUrl string `json:"app_url"` + BookmarkUrl string `json:"bookmark_url,omitempty"` + BoostsCount int32 `json:"boosts_count,omitempty"` + BoostsUrl string `json:"boosts_url,omitempty"` + + // BubbleUpUrl URL of the Bubble Up record for this recording (BC5 addition). Required: + // `todolists/_todolist.json.jbuilder` renders the shared recording partial + // with `bubbleupable: true` unconditionally, and every list, show, and group + // path renders that partial — so the key is present on every projection of + // this shape. + BubbleUpUrl string `json:"bubble_up_url"` Bucket TodoBucket `json:"bucket"` CommentsCount int32 `json:"comments_count,omitempty"` CommentsUrl string `json:"comments_url,omitempty"` @@ -2637,9 +2660,16 @@ type Todolist struct { // TodolistGroup defines model for TodolistGroup. type TodolistGroup struct { - AppTodosUrl string `json:"app_todos_url,omitempty"` - AppUrl string `json:"app_url"` - BookmarkUrl string `json:"bookmark_url,omitempty"` + AppTodosUrl string `json:"app_todos_url,omitempty"` + AppUrl string `json:"app_url"` + BookmarkUrl string `json:"bookmark_url,omitempty"` + + // BubbleUpUrl URL of the Bubble Up record for this recording (BC5 addition). Required: + // `todolists/_todolist.json.jbuilder` renders the shared recording partial + // with `bubbleupable: true` unconditionally, and every list, show, and group + // path renders that partial — so the key is present on every projection of + // this shape. + BubbleUpUrl string `json:"bubble_up_url"` Bucket TodoBucket `json:"bucket"` CommentsCount int32 `json:"comments_count,omitempty"` CommentsUrl string `json:"comments_url,omitempty"` diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/Recording.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/Recording.kt index 32f15b041..dfa6e6a21 100644 --- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/Recording.kt +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/Recording.kt @@ -25,6 +25,7 @@ data class Recording( val bucket: RecordingBucket, val creator: Person, @SerialName("bookmark_url") val bookmarkUrl: String? = null, + @SerialName("bubble_up_url") val bubbleUpUrl: String? = null, val content: String? = null, @SerialName("content_attachments") val contentAttachments: List? = null, @SerialName("description_attachments") val descriptionAttachments: List? = null, diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/Todolist.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/Todolist.kt index 8530b3812..58bde4022 100644 --- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/Todolist.kt +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/Todolist.kt @@ -22,6 +22,7 @@ data class Todolist( val type: String, val url: String, @SerialName("app_url") val appUrl: String, + @SerialName("bubble_up_url") val bubbleUpUrl: String, val parent: TodoParent, val bucket: TodoBucket, val creator: Person, diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/TodolistGroup.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/TodolistGroup.kt index 30226340e..970d344b2 100644 --- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/TodolistGroup.kt +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/TodolistGroup.kt @@ -22,6 +22,7 @@ data class TodolistGroup( val type: String, val url: String, @SerialName("app_url") val appUrl: String, + @SerialName("bubble_up_url") val bubbleUpUrl: String, val parent: TodoParent, val bucket: TodoBucket, val creator: Person, diff --git a/openapi.json b/openapi.json index fac3028e6..37f71dc66 100644 --- a/openapi.json +++ b/openapi.json @@ -19457,6 +19457,7 @@ "description_attachments": [], "url": "https://3.basecampapi.com/999/buckets/12345678/todolists/987654.json", "app_url": "https://3.basecamp.com/999/buckets/12345678/todolists/987654", + "bubble_up_url": "https://3.basecampapi.com/999/buckets/12345678/recordings/987654/bubble_up.json", "creator": { "id": 1, "name": "Someone", @@ -19496,6 +19497,7 @@ "type": "TodolistGroup", "url": "https://3.basecampapi.com/999/buckets/12345678/todolists/111222.json", "app_url": "https://3.basecamp.com/999/buckets/12345678/todolists/111222", + "bubble_up_url": "https://3.basecampapi.com/999/buckets/12345678/recordings/111222/bubble_up.json", "creator": { "id": 1, "name": "Someone", @@ -28675,6 +28677,10 @@ "bookmark_url": { "type": "string" }, + "bubble_up_url": { + "type": "string", + "description": "URL of the Bubble Up record for this recording (BC5 addition). Optional\nhere because this is a polymorphic projection:\n`recordings/_recording.json.jbuilder` emits the key only when the caller\npasses `local_assigns[:bubbleupable]`, and `todolists/_todolist` is the\nonly partial that does. So a Todolist-shaped instance carries it and the\nother recording types do not." + }, "content": { "type": "string" }, @@ -29378,6 +29384,10 @@ "bookmark_url": { "type": "string" }, + "bubble_up_url": { + "type": "string", + "description": "URL of the Bubble Up record for this recording (BC5 addition). Optional\nhere because this is a polymorphic projection:\n`recordings/_recording.json.jbuilder` emits the key only when the caller\npasses `local_assigns[:bubbleupable]`, and `todolists/_todolist` is the\nonly partial that does. So a Todolist-shaped instance carries it and the\nother recording types do not." + }, "parent": { "$ref": "#/components/schemas/RecordingParent" }, @@ -30248,6 +30258,10 @@ "subscription_url": { "type": "string" }, + "bubble_up_url": { + "type": "string", + "description": "URL of the Bubble Up record for this recording (BC5 addition). Required:\n`todolists/_todolist.json.jbuilder` renders the shared recording partial\nwith `bubbleupable: true` unconditionally, and every list, show, and group\npath renders that partial — so the key is present on every projection of\nthis shape." + }, "comments_count": { "type": "integer", "format": "int32" @@ -30305,6 +30319,7 @@ }, "required": [ "app_url", + "bubble_up_url", "bucket", "created_at", "creator", @@ -30372,6 +30387,10 @@ "subscription_url": { "type": "string" }, + "bubble_up_url": { + "type": "string", + "description": "URL of the Bubble Up record for this recording (BC5 addition). Required:\n`todolists/_todolist.json.jbuilder` renders the shared recording partial\nwith `bubbleupable: true` unconditionally, and every list, show, and group\npath renders that partial — so the key is present on every projection of\nthis shape." + }, "comments_count": { "type": "integer", "format": "int32" @@ -30410,6 +30429,7 @@ }, "required": [ "app_url", + "bubble_up_url", "bucket", "created_at", "creator", diff --git a/python/src/basecamp/generated/types.py b/python/src/basecamp/generated/types.py index c18269c6f..291a83e8b 100644 --- a/python/src/basecamp/generated/types.py +++ b/python/src/basecamp/generated/types.py @@ -1236,6 +1236,7 @@ class RateLimitErrorResponseContent(TypedDict): "bookmark_url": NotRequired[str], "boosts_count": NotRequired[int], "boosts_url": NotRequired[str], + "bubble_up_url": NotRequired[str], "bucket": "RecordingBucket", "category": NotRequired["RecordingCategory"], "comments_count": NotRequired[int], @@ -1405,6 +1406,7 @@ class SearchMetadata(TypedDict): class SearchResult(TypedDict): app_url: str bookmark_url: NotRequired[str] + bubble_up_url: NotRequired[str] bucket: NotRequired[RecordingBucket] content: str | None content_attachments: NotRequired[list[RichTextAttachment]] @@ -1600,6 +1602,7 @@ class Todolist(TypedDict): bookmark_url: NotRequired[str] boosts_count: NotRequired[int] boosts_url: NotRequired[str] + bubble_up_url: str bucket: TodoBucket comments_count: NotRequired[int] comments_url: NotRequired[str] @@ -1629,6 +1632,7 @@ class TodolistGroup(TypedDict): app_todos_url: NotRequired[str] app_url: str bookmark_url: NotRequired[str] + bubble_up_url: str bucket: TodoBucket comments_count: NotRequired[int] comments_url: NotRequired[str] diff --git a/ruby/lib/basecamp/generated/metadata.json b/ruby/lib/basecamp/generated/metadata.json index 7d1d23650..470b54131 100644 --- a/ruby/lib/basecamp/generated/metadata.json +++ b/ruby/lib/basecamp/generated/metadata.json @@ -1,7 +1,7 @@ { "$schema": "https://basecamp.com/schemas/sdk-metadata.json", "version": "1.0.0", - "generated": "2026-07-28T06:50:08Z", + "generated": "2026-07-28T07:10:09Z", "operations": { "GetAccount": { "retry": { diff --git a/ruby/lib/basecamp/generated/types.rb b/ruby/lib/basecamp/generated/types.rb index 27539b763..03757bc3b 100644 --- a/ruby/lib/basecamp/generated/types.rb +++ b/ruby/lib/basecamp/generated/types.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # Auto-generated from OpenAPI spec. Do not edit manually. -# Generated: 2026-07-28T06:50:08Z +# Generated: 2026-07-28T07:10:09Z require "json" require "time" @@ -3097,7 +3097,7 @@ def to_json(*args) # Recording class Recording include TypeHelpers - attr_accessor :app_url, :bucket, :created_at, :creator, :id, :inherits_status, :status, :title, :type, :updated_at, :url, :visible_to_clients, :bookmark_url, :boosts_count, :boosts_url, :category, :comments_count, :comments_url, :content, :content_attachments, :description, :description_attachments, :from, :group_on, :parent, :position, :replies_count, :replies_url, :service, :subject, :subscription_url + attr_accessor :app_url, :bucket, :created_at, :creator, :id, :inherits_status, :status, :title, :type, :updated_at, :url, :visible_to_clients, :bookmark_url, :boosts_count, :boosts_url, :bubble_up_url, :category, :comments_count, :comments_url, :content, :content_attachments, :description, :description_attachments, :from, :group_on, :parent, :position, :replies_count, :replies_url, :service, :subject, :subscription_url # @return [Array] def self.required_fields @@ -3120,6 +3120,7 @@ def initialize(data = {}) @bookmark_url = data["bookmark_url"] @boosts_count = parse_integer(data["boosts_count"]) @boosts_url = data["boosts_url"] + @bubble_up_url = data["bubble_up_url"] @category = parse_type(data["category"], "RecordingCategory") @comments_count = parse_integer(data["comments_count"]) @comments_url = data["comments_url"] @@ -3155,6 +3156,7 @@ def to_h "bookmark_url" => @bookmark_url, "boosts_count" => @boosts_count, "boosts_url" => @boosts_url, + "bubble_up_url" => @bubble_up_url, "category" => @category, "comments_count" => @comments_count, "comments_url" => @comments_url, @@ -3511,7 +3513,7 @@ def to_json(*args) # SearchResult class SearchResult include TypeHelpers - attr_accessor :app_url, :content, :description, :id, :title, :type, :url, :bookmark_url, :bucket, :content_attachments, :created_at, :creator, :description_attachments, :inherits_status, :parent, :plain_text_content, :plain_text_description, :status, :subject, :updated_at, :visible_to_clients + attr_accessor :app_url, :content, :description, :id, :title, :type, :url, :bookmark_url, :bubble_up_url, :bucket, :content_attachments, :created_at, :creator, :description_attachments, :inherits_status, :parent, :plain_text_content, :plain_text_description, :status, :subject, :updated_at, :visible_to_clients # @return [Array] def self.required_fields @@ -3527,6 +3529,7 @@ def initialize(data = {}) @type = data["type"] @url = data["url"] @bookmark_url = data["bookmark_url"] + @bubble_up_url = data["bubble_up_url"] @bucket = parse_type(data["bucket"], "RecordingBucket") @content_attachments = parse_array(data["content_attachments"], "RichTextAttachment") @created_at = parse_datetime(data["created_at"]) @@ -3552,6 +3555,7 @@ def to_h "type" => @type, "url" => @url, "bookmark_url" => @bookmark_url, + "bubble_up_url" => @bubble_up_url, "bucket" => @bucket, "content_attachments" => @content_attachments, "created_at" => @created_at, @@ -4048,15 +4052,16 @@ def to_json(*args) # Todolist class Todolist include TypeHelpers - attr_accessor :app_url, :bucket, :created_at, :creator, :description_attachments, :id, :inherits_status, :name, :parent, :status, :title, :type, :updated_at, :url, :visible_to_clients, :app_todos_url, :bookmark_url, :boosts_count, :boosts_url, :comments_count, :comments_url, :completed, :completed_ratio, :description, :groups_url, :position, :subscription_url, :todos_url + attr_accessor :app_url, :bubble_up_url, :bucket, :created_at, :creator, :description_attachments, :id, :inherits_status, :name, :parent, :status, :title, :type, :updated_at, :url, :visible_to_clients, :app_todos_url, :bookmark_url, :boosts_count, :boosts_url, :comments_count, :comments_url, :completed, :completed_ratio, :description, :groups_url, :position, :subscription_url, :todos_url # @return [Array] def self.required_fields - %i[app_url bucket created_at creator description_attachments id inherits_status name parent status title type updated_at url visible_to_clients].freeze + %i[app_url bubble_up_url bucket created_at creator description_attachments id inherits_status name parent status title type updated_at url visible_to_clients].freeze end def initialize(data = {}) @app_url = data["app_url"] + @bubble_up_url = data["bubble_up_url"] @bucket = parse_type(data["bucket"], "TodoBucket") @created_at = parse_datetime(data["created_at"]) @creator = parse_type(data["creator"], "Person") @@ -4089,6 +4094,7 @@ def initialize(data = {}) def to_h { "app_url" => @app_url, + "bubble_up_url" => @bubble_up_url, "bucket" => @bucket, "created_at" => @created_at, "creator" => @creator, @@ -4127,15 +4133,16 @@ def to_json(*args) # TodolistGroup class TodolistGroup include TypeHelpers - attr_accessor :app_url, :bucket, :created_at, :creator, :id, :inherits_status, :name, :parent, :status, :title, :type, :updated_at, :url, :visible_to_clients, :app_todos_url, :bookmark_url, :comments_count, :comments_url, :completed, :completed_ratio, :position, :subscription_url, :todos_url + attr_accessor :app_url, :bubble_up_url, :bucket, :created_at, :creator, :id, :inherits_status, :name, :parent, :status, :title, :type, :updated_at, :url, :visible_to_clients, :app_todos_url, :bookmark_url, :comments_count, :comments_url, :completed, :completed_ratio, :position, :subscription_url, :todos_url # @return [Array] def self.required_fields - %i[app_url bucket created_at creator id inherits_status name parent status title type updated_at url visible_to_clients].freeze + %i[app_url bubble_up_url bucket created_at creator id inherits_status name parent status title type updated_at url visible_to_clients].freeze end def initialize(data = {}) @app_url = data["app_url"] + @bubble_up_url = data["bubble_up_url"] @bucket = parse_type(data["bucket"], "TodoBucket") @created_at = parse_datetime(data["created_at"]) @creator = parse_type(data["creator"], "Person") @@ -4163,6 +4170,7 @@ def initialize(data = {}) def to_h { "app_url" => @app_url, + "bubble_up_url" => @bubble_up_url, "bucket" => @bucket, "created_at" => @created_at, "creator" => @creator, diff --git a/spec/api-gaps/recording-bubbleupable-field.md b/spec/api-gaps/recording-bubbleupable-field.md index 16ed86655..98e781635 100644 --- a/spec/api-gaps/recording-bubbleupable-field.md +++ b/spec/api-gaps/recording-bubbleupable-field.md @@ -27,6 +27,17 @@ did not close this. `bubbleupable` appears **nowhere in `doc/api/`** on `master` — no documented wire contract exists. Status stays `no-json-contract`. +**Not closed by the `bubble_up_url` absorption (2026-07-28):** the SDK now +models `bubble_up_url` on Todolist, TodolistGroup, Recording, and +SearchResult, sourced from `recordings/_recording.json.jbuilder`'s +`local_assigns[:bubbleupable]` branch. That is a **URL**, and its presence is +decided by the *rendering call site* — `todolists/_todolist` passes +`bubbleupable: true` unconditionally — not by the current user's eligibility. +This gap tracks a distinct **boolean** `bubbleupable` field expressing +per-user eligibility, which still has no wire contract. Presence of +`bubble_up_url` is not a substitute: it is constant per shape, so it cannot +answer "can *this* user bubble this up". + ## Why it matters Without the field, SDK consumers can't pre-compute the eligibility of UI diff --git a/spec/basecamp.smithy b/spec/basecamp.smithy index e7524b747..c55a68634 100644 --- a/spec/basecamp.smithy +++ b/spec/basecamp.smithy @@ -1536,6 +1536,14 @@ structure Todolist { app_url: String bookmark_url: String subscription_url: String + + /// URL of the Bubble Up record for this recording (BC5 addition). Required: + /// `todolists/_todolist.json.jbuilder` renders the shared recording partial + /// with `bubbleupable: true` unconditionally, and every list, show, and group + /// path renders that partial — so the key is present on every projection of + /// this shape. + @required + bubble_up_url: String comments_count: Integer comments_url: String position: Integer @@ -1591,6 +1599,14 @@ structure TodolistGroup { app_url: String bookmark_url: String subscription_url: String + + /// URL of the Bubble Up record for this recording (BC5 addition). Required: + /// `todolists/_todolist.json.jbuilder` renders the shared recording partial + /// with `bubbleupable: true` unconditionally, and every list, show, and group + /// path renders that partial — so the key is present on every projection of + /// this shape. + @required + bubble_up_url: String comments_count: Integer comments_url: String position: Integer @@ -6306,6 +6322,14 @@ structure Recording { @required app_url: String bookmark_url: String + + /// URL of the Bubble Up record for this recording (BC5 addition). Optional + /// here because this is a polymorphic projection: + /// `recordings/_recording.json.jbuilder` emits the key only when the caller + /// passes `local_assigns[:bubbleupable]`, and `todolists/_todolist` is the + /// only partial that does. So a Todolist-shaped instance carries it and the + /// other recording types do not. + bubble_up_url: String content: String /// Rich-text companion arrays carried through the generic recording /// projection (`to_recordable_partial_path` renders the full type-specific @@ -7973,6 +7997,14 @@ structure SearchResult { @required app_url: String bookmark_url: String + + /// URL of the Bubble Up record for this recording (BC5 addition). Optional + /// here because this is a polymorphic projection: + /// `recordings/_recording.json.jbuilder` emits the key only when the caller + /// passes `local_assigns[:bubbleupable]`, and `todolists/_todolist` is the + /// only partial that does. So a Todolist-shaped instance carries it and the + /// other recording types do not. + bubble_up_url: String parent: RecordingParent bucket: RecordingBucket creator: Person diff --git a/spec/fixtures/manifest.yaml b/spec/fixtures/manifest.yaml index b47438233..d69c73655 100644 --- a/spec/fixtures/manifest.yaml +++ b/spec/fixtures/manifest.yaml @@ -139,6 +139,14 @@ targets: fixture: recordings/get.json pointer: "" schema: Recording + # bubble_up_url is optional on Recording — recordings/_recording.json.jbuilder + # emits it only when the caller passes bubbleupable, and todolists/_todolist is + # the only partial that does. recordings/get.json is a Message and correctly + # omits it, so this Todolist-shaped element is the field-bearing representative. + - id: recording-list-todolist + fixture: recordings/list.json + pointer: "/2" + schema: Recording - id: todo-get fixture: todos/get.json pointer: "" @@ -150,6 +158,15 @@ targets: fixture: search/results.json pointer: "/0" schema: SearchResult + # Todolists are searchable (Search::Type::TYPE_FILTER_MAPPING maps + # "Todo" => %w[Todo Todolist]) and api_search_result_template_path falls through + # to to_recordable_partial_path → todolists/todolist, which passes + # bubbleupable: true. So a Todolist search result carries bubble_up_url while + # the other result types do not — this is that representative. + - id: search-result-todolist + fixture: search/results.json + pointer: "/3" + schema: SearchResult # Two concrete RichTextAttachment instances (populated elements, not empty # arrays) — the only way to cover RichTextAttachment per the concrete-instance @@ -193,9 +210,9 @@ covered_schemas: ClientCorrespondence: [client-correspondence-get] ClientReply: [client-reply-get] QuestionAnswer: [question-answer-get] - Recording: [recording-get] + Recording: [recording-get, recording-list-todolist] Todo: [todo-get] - SearchResult: [search-result-0] + SearchResult: [search-result-0, search-result-todolist] RichTextAttachment: [richtext-comment-0, richtext-upload-0] Gauge: [gauge-get] GaugeNeedle: [gauge-needle-get] diff --git a/spec/fixtures/recordings/list.json b/spec/fixtures/recordings/list.json index d3460c476..acc727308 100644 --- a/spec/fixtures/recordings/list.json +++ b/spec/fixtures/recordings/list.json @@ -122,5 +122,55 @@ "thumbnail_url": "https://3.basecampapi.com/195539477/buckets/2085958499/blobs/BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvJTA51--pr0j0051/thumbnails/diagram-51.png" } ] + }, + { + "id": 1069479519, + "status": "active", + "visible_to_clients": false, + "created_at": "2022-10-28T15:21:46.169Z", + "updated_at": "2022-10-28T15:21:46.169Z", + "title": "Launch Tasks", + "inherits_status": true, + "type": "Todolist", + "url": "https://3.basecampapi.com/195539477/buckets/2085958500/todolists/1069479519.json", + "app_url": "https://3.basecamp.com/195539477/buckets/2085958500/todolists/1069479519", + "bookmark_url": "https://3.basecampapi.com/195539477/my/bookmarks/BAh7CEkiCGdpZAY6BkVUSSIuZ2lkOi8vYmMzL1JlY29yZGluZy8xMDY5NDc5MzUxP2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIg1yZWFkYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--aabbccdd.json", + "bubble_up_url": "https://3.basecampapi.com/195539477/buckets/2085958500/recordings/1069479519/bubble_up.json", + "parent": { + "id": 1069479338, + "title": "Message Board", + "type": "Message::Board", + "url": "https://3.basecampapi.com/195539477/buckets/2085958499/message_boards/1069479338.json", + "app_url": "https://3.basecamp.com/195539477/buckets/2085958499/message_boards/1069479338" + }, + "bucket": { + "id": 2085958499, + "name": "The Leto Laptop", + "type": "Project" + }, + "creator": { + "id": 1049715914, + "attachable_sgid": "BAh7CEkiCGdpZAY6BkVUSSIrZ2lkOi8vYmMzL1BlcnNvbi8xMDQ5NzE1OTE0P2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIg9hdHRhY2hhYmxlBjsAVEkiD2V4cGlyZXNfYXQGOwBUMA==--aabbccdd", + "name": "Victor Cooper", + "email_address": "victor@honchodesign.com", + "personable_type": "User", + "title": "Chief Strategist", + "bio": "Don't let your dreams be dreams", + "location": "Chicago, IL", + "created_at": "2022-11-22T08:23:21.732Z", + "updated_at": "2022-11-22T08:23:21.732Z", + "admin": true, + "owner": true, + "client": false, + "employee": true, + "time_zone": "America/Chicago", + "avatar_url": "https://3.basecamp-static.com/195539477/people/BAhpBMpkkT4=--avatar", + "company": { + "id": 1033447817, + "name": "Honcho Design" + }, + "can_manage_projects": true, + "can_manage_people": true + } } ] diff --git a/spec/fixtures/search/results.json b/spec/fixtures/search/results.json index f69b67ce3..aee6801d8 100644 --- a/spec/fixtures/search/results.json +++ b/spec/fixtures/search/results.json @@ -188,5 +188,54 @@ "thumbnail_url": "https://3.basecampapi.com/195539477/buckets/2085958499/blobs/BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8vYmMzL0FjdGl2ZVN0b3JhZ2U6OkJsb2IvJTA32--pr0j0032/thumbnails/diagram-32.png" } ] + }, + { + "id": 1069479519, + "status": "active", + "visible_to_clients": false, + "created_at": "2022-10-29T10:30:00.000Z", + "updated_at": "2022-10-29T10:30:00.000Z", + "title": "Launch Tasks", + "inherits_status": true, + "type": "Todolist", + "url": "https://3.basecampapi.com/195539477/buckets/2085958500/todolists/1069479519.json", + "app_url": "https://3.basecamp.com/195539477/buckets/2085958500/todolists/1069479519", + "bookmark_url": "https://3.basecampapi.com/195539477/my/bookmarks/BAh7CEkiCGdpZAY6BkVUSSIuZ2lkOi8vYmMzL1JlY29yZGluZy8xMDY5NDc5NDAwP2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIg1yZWFkYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--eeffgghh.json", + "bubble_up_url": "https://3.basecampapi.com/195539477/buckets/2085958500/recordings/1069479519/bubble_up.json", + "content": null, + "description": null, + "plain_text_description": "Everything that has to ship before the Leto launch.", + "parent": { + "id": 1069479390, + "title": "Hardware Design", + "type": "Todolist", + "url": "https://3.basecampapi.com/195539477/buckets/2085958499/todolists/1069479390.json", + "app_url": "https://3.basecamp.com/195539477/buckets/2085958499/todolists/1069479390" + }, + "bucket": { + "id": 2085958499, + "name": "The Leto Laptop", + "type": "Project" + }, + "creator": { + "id": 1049715915, + "attachable_sgid": "BAh7CEkiCGdpZAY6BkVUSSIrZ2lkOi8vYmMzL1BlcnNvbi8xMDQ5NzE1OTE1P2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIg9hdHRhY2hhYmxlBjsAVEkiD2V4cGlyZXNfYXQGOwBUMA==--aeb392ebf54ffd820e45f27add22bae3a8c7da56", + "name": "Annie Bryan", + "email_address": "annie@honchodesign.com", + "personable_type": "User", + "title": "Central Markets Manager", + "bio": "To open a store is easy, to keep it open is an art", + "location": null, + "created_at": "2022-11-22T08:23:21.911Z", + "updated_at": "2022-11-22T08:23:21.911Z", + "admin": false, + "owner": false, + "client": false, + "employee": true, + "time_zone": "America/Chicago", + "avatar_url": "https://3.basecamp-static.com/195539477/people/BAhpBMtkkT4=--avatar", + "can_manage_projects": true, + "can_manage_people": true + } } ] diff --git a/spec/fixtures/todolist_groups/get.json b/spec/fixtures/todolist_groups/get.json index 1536bea1e..44d8909af 100644 --- a/spec/fixtures/todolist_groups/get.json +++ b/spec/fixtures/todolist_groups/get.json @@ -11,6 +11,7 @@ "app_url": "https://3.basecamp.com/195539477/buckets/2085958500/todolists/1069479600", "bookmark_url": "https://3.basecampapi.com/195539477/my/bookmarks/BAh7CEkiCGdpZAY6BkVUSSIrZ2lkOi8vYmMzL1JlY29yZGluZy8xMDY5NDc5NjAwP2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIg1yZWFkYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0.json", "subscription_url": "https://3.basecampapi.com/195539477/buckets/2085958500/recordings/1069479600/subscription.json", + "bubble_up_url": "https://3.basecampapi.com/195539477/buckets/2085958500/recordings/1069479600/bubble_up.json", "comments_count": 0, "comments_url": "https://3.basecampapi.com/195539477/buckets/2085958500/recordings/1069479600/comments.json", "position": 1, diff --git a/spec/fixtures/todolist_groups/list.json b/spec/fixtures/todolist_groups/list.json index b608a0430..6efde70bc 100644 --- a/spec/fixtures/todolist_groups/list.json +++ b/spec/fixtures/todolist_groups/list.json @@ -12,6 +12,7 @@ "app_url": "https://3.basecamp.com/195539477/buckets/2085958500/todolists/1069479600", "bookmark_url": "https://3.basecampapi.com/195539477/my/bookmarks/BAh7CEkiCGdpZAY6BkVUSSIrZ2lkOi8vYmMzL1JlY29yZGluZy8xMDY5NDc5NjAwP2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIg1yZWFkYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0.json", "subscription_url": "https://3.basecampapi.com/195539477/buckets/2085958500/recordings/1069479600/subscription.json", + "bubble_up_url": "https://3.basecampapi.com/195539477/buckets/2085958500/recordings/1069479600/bubble_up.json", "comments_count": 0, "comments_url": "https://3.basecampapi.com/195539477/buckets/2085958500/recordings/1069479600/comments.json", "position": 1, @@ -70,6 +71,7 @@ "app_url": "https://3.basecamp.com/195539477/buckets/2085958500/todolists/1069479601", "bookmark_url": "https://3.basecampapi.com/195539477/my/bookmarks/BAh7CEkiCGdpZAY6BkVUSSIrZ2lkOi8vYmMzL1JlY29yZGluZy8xMDY5NDc5NjAxP2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIg1yZWFkYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0.json", "subscription_url": "https://3.basecampapi.com/195539477/buckets/2085958500/recordings/1069479601/subscription.json", + "bubble_up_url": "https://3.basecampapi.com/195539477/buckets/2085958500/recordings/1069479601/bubble_up.json", "comments_count": 0, "comments_url": "https://3.basecampapi.com/195539477/buckets/2085958500/recordings/1069479601/comments.json", "position": 2, diff --git a/spec/fixtures/todolists/get.json b/spec/fixtures/todolists/get.json index eee40ebf8..e38bd4831 100644 --- a/spec/fixtures/todolists/get.json +++ b/spec/fixtures/todolists/get.json @@ -11,6 +11,7 @@ "app_url": "https://3.basecamp.com/195539477/buckets/2085958500/todolists/1069479519", "bookmark_url": "https://3.basecampapi.com/195539477/my/bookmarks/BAh7CEkiCGdpZAY6BkVUSSIrZ2lkOi8vYmMzL1JlY29yZGluZy8xMDY5NDc5NTE5P2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIg1yZWFkYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--ebab26d1c66e726e17dd785d0e46bfc4e5cba3f5.json", "subscription_url": "https://3.basecampapi.com/195539477/buckets/2085958500/recordings/1069479519/subscription.json", + "bubble_up_url": "https://3.basecampapi.com/195539477/buckets/2085958500/recordings/1069479519/bubble_up.json", "comments_count": 0, "comments_url": "https://3.basecampapi.com/195539477/buckets/2085958500/recordings/1069479519/comments.json", "position": 1, diff --git a/spec/fixtures/todolists/list.json b/spec/fixtures/todolists/list.json index fd33019db..b2cd2c95b 100644 --- a/spec/fixtures/todolists/list.json +++ b/spec/fixtures/todolists/list.json @@ -12,6 +12,7 @@ "app_url": "https://3.basecamp.com/195539477/buckets/2085958500/todolists/1069479519", "bookmark_url": "https://3.basecampapi.com/195539477/my/bookmarks/BAh7CEkiCGdpZAY6BkVUSSIrZ2lkOi8vYmMzL1JlY29yZGluZy8xMDY5NDc5NTE5P2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIg1yZWFkYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--ebab26d1c66e726e17dd785d0e46bfc4e5cba3f5.json", "subscription_url": "https://3.basecampapi.com/195539477/buckets/2085958500/recordings/1069479519/subscription.json", + "bubble_up_url": "https://3.basecampapi.com/195539477/buckets/2085958500/recordings/1069479519/bubble_up.json", "comments_count": 0, "comments_url": "https://3.basecampapi.com/195539477/buckets/2085958500/recordings/1069479519/comments.json", "position": 1, @@ -73,6 +74,7 @@ "app_url": "https://3.basecamp.com/195539477/buckets/2085958500/todolists/1069479522", "bookmark_url": "https://3.basecampapi.com/195539477/my/bookmarks/BAh7CEkiCGdpZAY6BkVUSSIrZ2lkOi8vYmMzL1JlY29yZGluZy8xMDY5NDc5NTIyP2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIg1yZWFkYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--c4e2c9d5b4c3a2b1e0f9d8c7b6a5948372615141.json", "subscription_url": "https://3.basecampapi.com/195539477/buckets/2085958500/recordings/1069479522/subscription.json", + "bubble_up_url": "https://3.basecampapi.com/195539477/buckets/2085958500/recordings/1069479522/bubble_up.json", "comments_count": 0, "comments_url": "https://3.basecampapi.com/195539477/buckets/2085958500/recordings/1069479522/comments.json", "position": 2, diff --git a/spec/overlays/examples.smithy b/spec/overlays/examples.smithy index 9e62f1f5f..400ce6ef6 100644 --- a/spec/overlays/examples.smithy +++ b/spec/overlays/examples.smithy @@ -15,6 +15,7 @@ apply GetTodolistOrGroup @examples([ title: "Launch Tasks", inherits_status: true, type: "Todolist", description_attachments: [], url: "https://3.basecampapi.com/999/buckets/12345678/todolists/987654.json", app_url: "https://3.basecamp.com/999/buckets/12345678/todolists/987654", + bubble_up_url: "https://3.basecampapi.com/999/buckets/12345678/recordings/987654/bubble_up.json", creator: { id: 1, name: "Someone", created_at: "2025-01-01T00:00:00Z", updated_at: "2025-01-01T00:00:00Z" }, bucket: { id: 12345678, name: "My Project", type: "Project" }, parent: { id: 99999, title: "To-dos", type: "Todoset", url: "https://3.basecampapi.com/999/buckets/12345678/todosets/99999.json", app_url: "https://3.basecamp.com/999/buckets/12345678/todosets/99999" } @@ -30,6 +31,7 @@ apply GetTodolistOrGroup @examples([ title: "Q1 Milestones", inherits_status: true, type: "TodolistGroup", url: "https://3.basecampapi.com/999/buckets/12345678/todolists/111222.json", app_url: "https://3.basecamp.com/999/buckets/12345678/todolists/111222", + bubble_up_url: "https://3.basecampapi.com/999/buckets/12345678/recordings/111222/bubble_up.json", creator: { id: 1, name: "Someone", created_at: "2025-01-01T00:00:00Z", updated_at: "2025-01-01T00:00:00Z" }, bucket: { id: 12345678, name: "My Project", type: "Project" }, parent: { id: 99999, title: "To-dos", type: "Todoset", url: "https://3.basecampapi.com/999/buckets/12345678/todosets/99999.json", app_url: "https://3.basecamp.com/999/buckets/12345678/todosets/99999" } diff --git a/swift/Sources/Basecamp/Generated/Models/Recording.swift b/swift/Sources/Basecamp/Generated/Models/Recording.swift index 9ae7b5a61..14df3ed13 100644 --- a/swift/Sources/Basecamp/Generated/Models/Recording.swift +++ b/swift/Sources/Basecamp/Generated/Models/Recording.swift @@ -17,6 +17,7 @@ public struct Recording: Codable, Sendable { public var bookmarkUrl: String? public var boostsCount: Int32? public var boostsUrl: String? + public var bubbleUpUrl: String? public var category: RecordingCategory? public var commentsCount: Int32? public var commentsUrl: String? @@ -50,6 +51,7 @@ public struct Recording: Codable, Sendable { bookmarkUrl: String? = nil, boostsCount: Int32? = nil, boostsUrl: String? = nil, + bubbleUpUrl: String? = nil, category: RecordingCategory? = nil, commentsCount: Int32? = nil, commentsUrl: String? = nil, @@ -82,6 +84,7 @@ public struct Recording: Codable, Sendable { self.bookmarkUrl = bookmarkUrl self.boostsCount = boostsCount self.boostsUrl = boostsUrl + self.bubbleUpUrl = bubbleUpUrl self.category = category self.commentsCount = commentsCount self.commentsUrl = commentsUrl diff --git a/swift/Sources/Basecamp/Generated/Models/SearchResult.swift b/swift/Sources/Basecamp/Generated/Models/SearchResult.swift index 00e7f155b..580cf7628 100644 --- a/swift/Sources/Basecamp/Generated/Models/SearchResult.swift +++ b/swift/Sources/Basecamp/Generated/Models/SearchResult.swift @@ -10,6 +10,7 @@ public struct SearchResult: Codable, Sendable { public let type: String public let url: String public var bookmarkUrl: String? + public var bubbleUpUrl: String? public var bucket: RecordingBucket? public var contentAttachments: [RichTextAttachment]? public var createdAt: String? @@ -33,6 +34,7 @@ public struct SearchResult: Codable, Sendable { type: String, url: String, bookmarkUrl: String? = nil, + bubbleUpUrl: String? = nil, bucket: RecordingBucket? = nil, contentAttachments: [RichTextAttachment]? = nil, createdAt: String? = nil, @@ -55,6 +57,7 @@ public struct SearchResult: Codable, Sendable { self.type = type self.url = url self.bookmarkUrl = bookmarkUrl + self.bubbleUpUrl = bubbleUpUrl self.bucket = bucket self.contentAttachments = contentAttachments self.createdAt = createdAt @@ -79,6 +82,7 @@ public struct SearchResult: Codable, Sendable { case type case url case bookmarkUrl + case bubbleUpUrl case bucket case contentAttachments case createdAt @@ -104,6 +108,7 @@ public struct SearchResult: Codable, Sendable { self.type = try container.decode(String.self, forKey: .type) self.url = try container.decode(String.self, forKey: .url) self.bookmarkUrl = try container.decodeIfPresent(String.self, forKey: .bookmarkUrl) + self.bubbleUpUrl = try container.decodeIfPresent(String.self, forKey: .bubbleUpUrl) self.bucket = try container.decodeIfPresent(RecordingBucket.self, forKey: .bucket) self.contentAttachments = try container.decodeIfPresent([RichTextAttachment].self, forKey: .contentAttachments) self.createdAt = try container.decodeIfPresent(String.self, forKey: .createdAt) @@ -129,6 +134,7 @@ public struct SearchResult: Codable, Sendable { try container.encode(self.type, forKey: .type) try container.encode(self.url, forKey: .url) try container.encodeIfPresent(self.bookmarkUrl, forKey: .bookmarkUrl) + try container.encodeIfPresent(self.bubbleUpUrl, forKey: .bubbleUpUrl) try container.encodeIfPresent(self.bucket, forKey: .bucket) try container.encodeIfPresent(self.contentAttachments, forKey: .contentAttachments) try container.encodeIfPresent(self.createdAt, forKey: .createdAt) diff --git a/swift/Sources/Basecamp/Generated/Models/Todolist.swift b/swift/Sources/Basecamp/Generated/Models/Todolist.swift index b597878e7..ef25c506e 100644 --- a/swift/Sources/Basecamp/Generated/Models/Todolist.swift +++ b/swift/Sources/Basecamp/Generated/Models/Todolist.swift @@ -3,6 +3,7 @@ import Foundation public struct Todolist: Codable, Sendable { public let appUrl: String + public let bubbleUpUrl: String public let bucket: TodoBucket public let createdAt: String public let creator: Person @@ -33,6 +34,7 @@ public struct Todolist: Codable, Sendable { public init( appUrl: String, + bubbleUpUrl: String, bucket: TodoBucket, createdAt: String, creator: Person, @@ -62,6 +64,7 @@ public struct Todolist: Codable, Sendable { todosUrl: String? = nil ) { self.appUrl = appUrl + self.bubbleUpUrl = bubbleUpUrl self.bucket = bucket self.createdAt = createdAt self.creator = creator diff --git a/swift/Sources/Basecamp/Generated/Models/TodolistGroup.swift b/swift/Sources/Basecamp/Generated/Models/TodolistGroup.swift index 6ce534571..dfc34c984 100644 --- a/swift/Sources/Basecamp/Generated/Models/TodolistGroup.swift +++ b/swift/Sources/Basecamp/Generated/Models/TodolistGroup.swift @@ -3,6 +3,7 @@ import Foundation public struct TodolistGroup: Codable, Sendable { public let appUrl: String + public let bubbleUpUrl: String public let bucket: TodoBucket public let createdAt: String public let creator: Person @@ -28,6 +29,7 @@ public struct TodolistGroup: Codable, Sendable { public init( appUrl: String, + bubbleUpUrl: String, bucket: TodoBucket, createdAt: String, creator: Person, @@ -52,6 +54,7 @@ public struct TodolistGroup: Codable, Sendable { todosUrl: String? = nil ) { self.appUrl = appUrl + self.bubbleUpUrl = bubbleUpUrl self.bucket = bucket self.createdAt = createdAt self.creator = creator diff --git a/typescript/src/generated/metadata.ts b/typescript/src/generated/metadata.ts index 73c364b33..682354f42 100644 --- a/typescript/src/generated/metadata.ts +++ b/typescript/src/generated/metadata.ts @@ -37,7 +37,7 @@ export interface MetadataOutput { const metadata: MetadataOutput = { "$schema": "https://basecamp.com/schemas/sdk-metadata.json", "version": "1.0.0", - "generated": "2026-07-28T06:50:06.535Z", + "generated": "2026-07-28T07:10:08.052Z", "operations": { "GetAccount": { "retry": { diff --git a/typescript/src/generated/openapi-stripped.json b/typescript/src/generated/openapi-stripped.json index 616adad5f..13d9cf347 100644 --- a/typescript/src/generated/openapi-stripped.json +++ b/typescript/src/generated/openapi-stripped.json @@ -17278,6 +17278,7 @@ "description_attachments": [], "url": "https://3.basecampapi.com/999/buckets/12345678/todolists/987654.json", "app_url": "https://3.basecamp.com/999/buckets/12345678/todolists/987654", + "bubble_up_url": "https://3.basecampapi.com/999/buckets/12345678/recordings/987654/bubble_up.json", "creator": { "id": 1, "name": "Someone", @@ -17317,6 +17318,7 @@ "type": "TodolistGroup", "url": "https://3.basecampapi.com/999/buckets/12345678/todolists/111222.json", "app_url": "https://3.basecamp.com/999/buckets/12345678/todolists/111222", + "bubble_up_url": "https://3.basecampapi.com/999/buckets/12345678/recordings/111222/bubble_up.json", "creator": { "id": 1, "name": "Someone", @@ -26099,6 +26101,10 @@ "bookmark_url": { "type": "string" }, + "bubble_up_url": { + "type": "string", + "description": "URL of the Bubble Up record for this recording (BC5 addition). Optional\nhere because this is a polymorphic projection:\n`recordings/_recording.json.jbuilder` emits the key only when the caller\npasses `local_assigns[:bubbleupable]`, and `todolists/_todolist` is the\nonly partial that does. So a Todolist-shaped instance carries it and the\nother recording types do not." + }, "content": { "type": "string" }, @@ -26802,6 +26808,10 @@ "bookmark_url": { "type": "string" }, + "bubble_up_url": { + "type": "string", + "description": "URL of the Bubble Up record for this recording (BC5 addition). Optional\nhere because this is a polymorphic projection:\n`recordings/_recording.json.jbuilder` emits the key only when the caller\npasses `local_assigns[:bubbleupable]`, and `todolists/_todolist` is the\nonly partial that does. So a Todolist-shaped instance carries it and the\nother recording types do not." + }, "parent": { "$ref": "#/components/schemas/RecordingParent" }, @@ -27672,6 +27682,10 @@ "subscription_url": { "type": "string" }, + "bubble_up_url": { + "type": "string", + "description": "URL of the Bubble Up record for this recording (BC5 addition). Required:\n`todolists/_todolist.json.jbuilder` renders the shared recording partial\nwith `bubbleupable: true` unconditionally, and every list, show, and group\npath renders that partial — so the key is present on every projection of\nthis shape." + }, "comments_count": { "type": "integer", "format": "int32" @@ -27729,6 +27743,7 @@ }, "required": [ "app_url", + "bubble_up_url", "bucket", "created_at", "creator", @@ -27796,6 +27811,10 @@ "subscription_url": { "type": "string" }, + "bubble_up_url": { + "type": "string", + "description": "URL of the Bubble Up record for this recording (BC5 addition). Required:\n`todolists/_todolist.json.jbuilder` renders the shared recording partial\nwith `bubbleupable: true` unconditionally, and every list, show, and group\npath renders that partial — so the key is present on every projection of\nthis shape." + }, "comments_count": { "type": "integer", "format": "int32" @@ -27834,6 +27853,7 @@ }, "required": [ "app_url", + "bubble_up_url", "bucket", "created_at", "creator", diff --git a/typescript/src/generated/schema.d.ts b/typescript/src/generated/schema.d.ts index 8775fb936..731eca8fd 100644 --- a/typescript/src/generated/schema.d.ts +++ b/typescript/src/generated/schema.d.ts @@ -4494,6 +4494,15 @@ export interface components { url: string; app_url: string; bookmark_url?: string; + /** + * @description URL of the Bubble Up record for this recording (BC5 addition). Optional + * here because this is a polymorphic projection: + * `recordings/_recording.json.jbuilder` emits the key only when the caller + * passes `local_assigns[:bubbleupable]`, and `todolists/_todolist` is the + * only partial that does. So a Todolist-shaped instance carries it and the + * other recording types do not. + */ + bubble_up_url?: string; content?: string; /** * @description Rich-text companion arrays carried through the generic recording @@ -4750,6 +4759,15 @@ export interface components { url: string; app_url: string; bookmark_url?: string; + /** + * @description URL of the Bubble Up record for this recording (BC5 addition). Optional + * here because this is a polymorphic projection: + * `recordings/_recording.json.jbuilder` emits the key only when the caller + * passes `local_assigns[:bubbleupable]`, and `todolists/_todolist` is the + * only partial that does. So a Todolist-shaped instance carries it and the + * other recording types do not. + */ + bubble_up_url?: string; parent?: components["schemas"]["RecordingParent"]; bucket?: components["schemas"]["RecordingBucket"]; creator?: components["schemas"]["Person"]; @@ -5111,6 +5129,14 @@ export interface components { app_url: string; bookmark_url?: string; subscription_url?: string; + /** + * @description URL of the Bubble Up record for this recording (BC5 addition). Required: + * `todolists/_todolist.json.jbuilder` renders the shared recording partial + * with `bubbleupable: true` unconditionally, and every list, show, and group + * path renders that partial — so the key is present on every projection of + * this shape. + */ + bubble_up_url: string; /** Format: int32 */ comments_count?: number; comments_url?: string; @@ -5145,6 +5171,14 @@ export interface components { app_url: string; bookmark_url?: string; subscription_url?: string; + /** + * @description URL of the Bubble Up record for this recording (BC5 addition). Required: + * `todolists/_todolist.json.jbuilder` renders the shared recording partial + * with `bubbleupable: true` unconditionally, and every list, show, and group + * path renders that partial — so the key is present on every projection of + * this shape. + */ + bubble_up_url: string; /** Format: int32 */ comments_count?: number; comments_url?: string; From cdede67eedadcba546561854e6e806bf0008be54 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 28 Jul 2026 13:06:10 -0700 Subject: [PATCH 2/4] Make the invented Todolist fixtures self-consistent; fix the TS delay flake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both Todolist elements I added were built by cloning a Message element and overwriting only the URLs, so their identity fields disagreed: URLs said bucket 2085958500 while bucket.id said 2085958499, parent was a Message::Board rather than a Todoset, and the bookmark SGIDs still encoded the Message's recording id. Every identity-bearing field is now taken from the real todolists/get.json — one bucket, one recording id, a Todoset parent, and a bookmark SGID that decodes to the right recording. Separately, the TypeScript conformance runner captured request timestamps with Date.now(), which floors to whole milliseconds. Two floored reads bracketing a 2000ms sleep can legitimately differ by 1999, which is what turned retry.json's delayBetweenRequests assertion red. The Go runner uses nanosecond time.Time and never saw it. Switched to performance.now(); the assertion is unchanged and the SDK really does sleep the full interval. Three consecutive clean runs of the TypeScript runner. --- conformance/runner/typescript/runner.test.ts | 8 +++++++- spec/fixtures/recordings/list.json | 16 ++++++++-------- spec/fixtures/search/results.json | 18 +++++++++--------- 3 files changed, 24 insertions(+), 18 deletions(-) diff --git a/conformance/runner/typescript/runner.test.ts b/conformance/runner/typescript/runner.test.ts index 8a76c0b7d..aeeae33a5 100644 --- a/conformance/runner/typescript/runner.test.ts +++ b/conformance/runner/typescript/runner.test.ts @@ -468,7 +468,13 @@ function installMockHandlers(tc: TestCase): { ); const handler = http.all(originPattern, async ({ request }) => { count++; - times.push(Date.now()); + // performance.now(), not Date.now(): Date.now() is floored to whole + // milliseconds, so bracketing a 2000ms sleep can legitimately read 1999 + // when the two reads land either side of a millisecond boundary. That made + // retry.json's `delayBetweenRequests: min 2000` assertion flaky here while + // the Go runner — which uses nanosecond time.Time — never saw it. This is a + // measurement fix; the SDK really does sleep for the full interval. + times.push(performance.now()); const url = new URL(request.url); paths.push(url.pathname); methods.push(request.method); diff --git a/spec/fixtures/recordings/list.json b/spec/fixtures/recordings/list.json index acc727308..eaf179afc 100644 --- a/spec/fixtures/recordings/list.json +++ b/spec/fixtures/recordings/list.json @@ -129,23 +129,23 @@ "visible_to_clients": false, "created_at": "2022-10-28T15:21:46.169Z", "updated_at": "2022-10-28T15:21:46.169Z", - "title": "Launch Tasks", + "title": "Hardware", "inherits_status": true, "type": "Todolist", "url": "https://3.basecampapi.com/195539477/buckets/2085958500/todolists/1069479519.json", "app_url": "https://3.basecamp.com/195539477/buckets/2085958500/todolists/1069479519", - "bookmark_url": "https://3.basecampapi.com/195539477/my/bookmarks/BAh7CEkiCGdpZAY6BkVUSSIuZ2lkOi8vYmMzL1JlY29yZGluZy8xMDY5NDc5MzUxP2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIg1yZWFkYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--aabbccdd.json", + "bookmark_url": "https://3.basecampapi.com/195539477/my/bookmarks/BAh7CEkiCGdpZAY6BkVUSSIrZ2lkOi8vYmMzL1JlY29yZGluZy8xMDY5NDc5NTE5P2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIg1yZWFkYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--ebab26d1c66e726e17dd785d0e46bfc4e5cba3f5.json", "bubble_up_url": "https://3.basecampapi.com/195539477/buckets/2085958500/recordings/1069479519/bubble_up.json", "parent": { "id": 1069479338, - "title": "Message Board", - "type": "Message::Board", - "url": "https://3.basecampapi.com/195539477/buckets/2085958499/message_boards/1069479338.json", - "app_url": "https://3.basecamp.com/195539477/buckets/2085958499/message_boards/1069479338" + "title": "To-dos", + "type": "Todoset", + "url": "https://3.basecampapi.com/195539477/buckets/2085958500/todosets/1069479338.json", + "app_url": "https://3.basecamp.com/195539477/buckets/2085958500/todosets/1069479338" }, "bucket": { - "id": 2085958499, - "name": "The Leto Laptop", + "id": 2085958500, + "name": "The Leto Locator", "type": "Project" }, "creator": { diff --git a/spec/fixtures/search/results.json b/spec/fixtures/search/results.json index aee6801d8..1716fdeb9 100644 --- a/spec/fixtures/search/results.json +++ b/spec/fixtures/search/results.json @@ -195,26 +195,26 @@ "visible_to_clients": false, "created_at": "2022-10-29T10:30:00.000Z", "updated_at": "2022-10-29T10:30:00.000Z", - "title": "Launch Tasks", + "title": "Hardware", "inherits_status": true, "type": "Todolist", "url": "https://3.basecampapi.com/195539477/buckets/2085958500/todolists/1069479519.json", "app_url": "https://3.basecamp.com/195539477/buckets/2085958500/todolists/1069479519", - "bookmark_url": "https://3.basecampapi.com/195539477/my/bookmarks/BAh7CEkiCGdpZAY6BkVUSSIuZ2lkOi8vYmMzL1JlY29yZGluZy8xMDY5NDc5NDAwP2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIg1yZWFkYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--eeffgghh.json", + "bookmark_url": "https://3.basecampapi.com/195539477/my/bookmarks/BAh7CEkiCGdpZAY6BkVUSSIrZ2lkOi8vYmMzL1JlY29yZGluZy8xMDY5NDc5NTE5P2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIg1yZWFkYWJsZQY7AFRJIg9leHBpcmVzX2F0BjsAVDA=--ebab26d1c66e726e17dd785d0e46bfc4e5cba3f5.json", "bubble_up_url": "https://3.basecampapi.com/195539477/buckets/2085958500/recordings/1069479519/bubble_up.json", "content": null, "description": null, "plain_text_description": "Everything that has to ship before the Leto launch.", "parent": { - "id": 1069479390, - "title": "Hardware Design", - "type": "Todolist", - "url": "https://3.basecampapi.com/195539477/buckets/2085958499/todolists/1069479390.json", - "app_url": "https://3.basecamp.com/195539477/buckets/2085958499/todolists/1069479390" + "id": 1069479338, + "title": "To-dos", + "type": "Todoset", + "url": "https://3.basecampapi.com/195539477/buckets/2085958500/todosets/1069479338.json", + "app_url": "https://3.basecamp.com/195539477/buckets/2085958500/todosets/1069479338" }, "bucket": { - "id": 2085958499, - "name": "The Leto Laptop", + "id": 2085958500, + "name": "The Leto Locator", "type": "Project" }, "creator": { From cfd0ae7f50367346a581750f9dc82bd7e2adda76 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 28 Jul 2026 14:22:13 -0700 Subject: [PATCH 3/4] Todolist stubs: stop contradicting the now-required bubble_up_url MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ruby's todolists test_get hand-rolled { id, name, description_attachments }. Todolist has a dozen required members and that stub omits nearly all of them, so it was a payload BC3 cannot produce — it passed only because Ruby does not validate required fields at runtime. Repointed at the canonical spec/fixtures/todolists/get.json, which make check-fixture-coverage validates, so it can no longer drift from the contract. It now also asserts bubble_up_url is present. Python's todolists stubs belong to metadata/hook-identity tests where the payload is deliberately irrelevant, so rather than inflate them to full shape I factored a small _todolist() helper that carries the required bubble_up_url and says why it is there. TypeScript needed no stub change: todolists.test.ts already imports spec/fixtures/todolists/get.json directly, so it picked the field up with the fixture. Added an explicit assertion so that is provable rather than incidental, and so a fixture regression fails here too. todolistGroups constructs no Todolist-shaped payload at all — it covers create/reposition and the absence of get/update. --- .../tests/services/test_todolists_service.py | 34 ++++++++++++------- .../services/todolists_service_test.rb | 12 +++++-- typescript/tests/services/todolists.test.ts | 5 +++ 3 files changed, 37 insertions(+), 14 deletions(-) diff --git a/python/tests/services/test_todolists_service.py b/python/tests/services/test_todolists_service.py index badfd6efb..40e4638c9 100644 --- a/python/tests/services/test_todolists_service.py +++ b/python/tests/services/test_todolists_service.py @@ -15,6 +15,24 @@ BASE = "https://3.basecampapi.com/12345" +def _todolist(name: str) -> dict: + """Minimal Todolist payload for the metadata/hook tests. + + These tests are about operation identity and resource-id scoping, not + payload shape — but a stub must not contradict the contract either. + bubble_up_url is @required on Todolist (todolists/_todolist.json.jbuilder + always passes bubbleupable: true), so it belongs here even though nothing + below reads it. Full-shape coverage lives in spec/fixtures, which + `make check-fixture-coverage` validates. + """ + return { + "id": 2, + "name": name, + "description_attachments": [], + "bubble_up_url": "https://3.basecampapi.com/12345/buckets/1/recordings/2/bubble_up.json", + } + + class _RecordingHooks(BasecampHooks): def __init__(self) -> None: self.operations: list[OperationInfo] = [] @@ -85,9 +103,7 @@ async def test_reposition_not_found(self): class TestSyncTodolistMetadata: @respx.mock def test_get_scopes_resource_to_todolist_id(self): - respx.get(f"{BASE}/todolists/2").mock( - return_value=httpx.Response(200, json={"id": 2, "name": "Sprint Tasks", "description_attachments": []}) - ) + respx.get(f"{BASE}/todolists/2").mock(return_value=httpx.Response(200, json=_todolist("Sprint Tasks"))) hooks = _RecordingHooks() c = Client(access_token="test-token", hooks=hooks) @@ -102,9 +118,7 @@ def test_get_scopes_resource_to_todolist_id(self): @respx.mock def test_update_scopes_resource_to_todolist_id(self): - respx.put(f"{BASE}/todolists/2").mock( - return_value=httpx.Response(200, json={"id": 2, "name": "Updated List", "description_attachments": []}) - ) + respx.put(f"{BASE}/todolists/2").mock(return_value=httpx.Response(200, json=_todolist("Updated List"))) hooks = _RecordingHooks() c = Client(access_token="test-token", hooks=hooks) @@ -122,9 +136,7 @@ class TestAsyncTodolistMetadata: @pytest.mark.asyncio @respx.mock async def test_get_scopes_resource_to_todolist_id(self): - respx.get(f"{BASE}/todolists/2").mock( - return_value=httpx.Response(200, json={"id": 2, "name": "Sprint Tasks", "description_attachments": []}) - ) + respx.get(f"{BASE}/todolists/2").mock(return_value=httpx.Response(200, json=_todolist("Sprint Tasks"))) hooks = _RecordingHooks() c = AsyncClient(access_token="test-token", hooks=hooks) @@ -140,9 +152,7 @@ async def test_get_scopes_resource_to_todolist_id(self): @pytest.mark.asyncio @respx.mock async def test_update_scopes_resource_to_todolist_id(self): - respx.put(f"{BASE}/todolists/2").mock( - return_value=httpx.Response(200, json={"id": 2, "name": "Updated List", "description_attachments": []}) - ) + respx.put(f"{BASE}/todolists/2").mock(return_value=httpx.Response(200, json=_todolist("Updated List"))) hooks = _RecordingHooks() c = AsyncClient(access_token="test-token", hooks=hooks) diff --git a/ruby/test/basecamp/services/todolists_service_test.rb b/ruby/test/basecamp/services/todolists_service_test.rb index 52dd580b8..a6e21010f 100644 --- a/ruby/test/basecamp/services/todolists_service_test.rb +++ b/ruby/test/basecamp/services/todolists_service_test.rb @@ -36,15 +36,23 @@ def test_list_with_status assert_equal "archived", result.first["status"] end + # Uses the canonical fixture rather than a hand-rolled hash: Todolist has a + # dozen required members (bubble_up_url among them) and a minimal stub is a + # payload BC3 cannot produce. spec/fixtures is validated by + # `make check-fixture-coverage`, so this stub cannot drift from the contract. def test_get - response = { "id" => 2, "name" => "Sprint Tasks", "description_attachments" => [] } + response = load_fixture("todolists/get.json") # Generated service uses /todolists/{id} without .json stub_request(:get, %r{https://3\.basecampapi\.com/12345/todolists/\d+$}) .to_return(status: 200, body: response.to_json, headers: { "Content-Type" => "application/json" }) result = @account.todolists.get(id: 2) - assert_equal "Sprint Tasks", result["name"] + assert_equal response["name"], result["name"] + # bubble_up_url is @required on Todolist: todolists/_todolist.json.jbuilder + # renders the shared recording partial with bubbleupable: true + # unconditionally, so every projection of this shape carries it. + assert result.key?("bubble_up_url"), "required bubble_up_url must be present" end def test_create diff --git a/typescript/tests/services/todolists.test.ts b/typescript/tests/services/todolists.test.ts index bb268096a..cd363eb09 100644 --- a/typescript/tests/services/todolists.test.ts +++ b/typescript/tests/services/todolists.test.ts @@ -41,6 +41,11 @@ describe("TodolistsService", () => { const todolist = await client.todolists.get(id); expect(todolist.id).toBe(id); expect(todolist.name).toBe(todolistFixture.name); + // bubble_up_url is @required on Todolist: todolists/_todolist.json.jbuilder + // renders the shared recording partial with bubbleupable: true + // unconditionally, so every projection of this shape carries it. The stub + // is the canonical fixture, so this also proves the fixture carries it. + expect(todolist.bubble_up_url).toBeDefined(); }); it("should throw not_found for missing todolist", async () => { From 8365feabeea134330f693193fbdc97121c24d5aa Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 28 Jul 2026 14:46:57 -0700 Subject: [PATCH 4/4] Correct the delay-assertion fix: Node timers fire early, not just imprecisely MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit My previous commit blamed Date.now() truncation alone and claimed three clean runs proved it. That was wrong. With performance.now() the same assertion later failed at 1999.8753749999996ms — sub-millisecond, so not a rounding artifact: libuv rounds a timer's deadline down internally, so setTimeout(2000) can genuinely elapse in slightly under 2000ms of wall clock. Go's timers do not fire early, which is why only this runner sees it. performance.now() is still right (Date.now() truncation was a real second source of noise), but it needed a documented TIMER_SLACK_MS of 2ms on the comparison. That cannot mask a real regression: a dropped Retry-After halves the backoff (~1000ms off) and a missing delay misses by the whole interval. --- conformance/runner/typescript/runner.test.ts | 29 +++++++++++++++----- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/conformance/runner/typescript/runner.test.ts b/conformance/runner/typescript/runner.test.ts index aeeae33a5..b63cad8c6 100644 --- a/conformance/runner/typescript/runner.test.ts +++ b/conformance/runner/typescript/runner.test.ts @@ -63,6 +63,13 @@ interface TestCase { const __dirname = path.dirname(fileURLToPath(import.meta.url)); const TESTS_DIR = path.resolve(__dirname, "../../tests"); + +/** + * Allowance for Node timers firing early. libuv rounds a timer's deadline down + * internally, so `setTimeout(2000)` can elapse in ~1999.9ms of wall clock. + * Small enough that no real backoff regression fits inside it. + */ +const TIMER_SLACK_MS = 2; const TEST_ACCOUNT_ID = "999"; /** @@ -469,11 +476,9 @@ function installMockHandlers(tc: TestCase): { const handler = http.all(originPattern, async ({ request }) => { count++; // performance.now(), not Date.now(): Date.now() is floored to whole - // milliseconds, so bracketing a 2000ms sleep can legitimately read 1999 - // when the two reads land either side of a millisecond boundary. That made - // retry.json's `delayBetweenRequests: min 2000` assertion flaky here while - // the Go runner — which uses nanosecond time.Time — never saw it. This is a - // measurement fix; the SDK really does sleep for the full interval. + // milliseconds, so bracketing a 2000ms sleep can read 1999 purely from + // rounding. See TIMER_SLACK_MS for the second, separate reason this + // assertion needs care. times.push(performance.now()); const url = new URL(request.url); paths.push(url.pathname); @@ -713,10 +718,20 @@ function checkAssertions( if (times.length >= 2) { const delay = times[1]! - times[0]!; const minDelay = assertion.min ?? 0; + // Node's timers may fire marginally BEFORE the requested delay — + // libuv rounds the deadline down internally, so a 2000ms sleep can + // legitimately elapse in 1999.87ms. That is a runtime property, not + // an SDK behaviour: the SDK asked for the full interval. The Go + // runner never sees it because Go's timers do not fire early. + // + // A sub-millisecond allowance cannot mask a real regression, which + // would miss by hundreds of milliseconds (a dropped Retry-After + // means ~1000ms of backoff instead of 2000ms) or by the whole + // interval (no delay at all). expect( delay, - `[${tc.name}] expected delay >= ${minDelay}ms, got ${delay}ms`, - ).toBeGreaterThanOrEqual(minDelay); + `[${tc.name}] expected delay >= ${minDelay}ms (allowing ${TIMER_SLACK_MS}ms timer slack), got ${delay}ms`, + ).toBeGreaterThanOrEqual(minDelay - TIMER_SLACK_MS); } break; }