From 02117971fbeb53f101c885df1ea04ddc0b41717d Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 28 Jul 2026 14:42:54 -0700 Subject: [PATCH 1/2] Repin provenance to current bc3 HEAD; document and pin participant_ids semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit basecamp/bc3#12425 merged as dffa7e11b3, fixing the schedule-entry participant wipe behind #477. Repin follows the sync-time rule rather than pinning the fix commit: the pin records the upstream HEAD the spec was synced against, which is dffa7e11b3 (2026-07-28). The #12425 merge is an ancestor of it — verified with git merge-base --is-ancestor — and is recorded here as fix evidence, not as the pin value. API_VERSION tracks the pin date, 2026-07-26 to 2026-07-28. Triaged all 116 commits from the old pin c308693171. Six touch the API surface: the #12425 fix itself, the three bubble_up_url commits absorbed by the parent PR, and two that together add a NEW Everything aggregate — e4bf93c3c2 added everything/assignments, then f697d8604d renamed it to everything/tasks before it ever appeared under a pin. That aggregate is unmodeled, so it is registered as spec/api-gaps/everything-tasks-aggregate.md rather than silently carried across the repin. The entry states explicitly that this is NOT a break of my/assignments: the commit subject "Move /assignments to /tasks" reads alarming against an SDK shipping GetMyAssignments, but the moved routes are the Everything ones, and /my/assignments.json is untouched across the whole drift range. UpdateScheduleEntryInput.participant_ids now documents the contract — omission preserves, an empty array clears — with the history of why that guarantee is recent. Only the update input carries it; on create there is nothing to preserve. conformance/tests/schedule_entries_write.json pins both directions and is dispatched by all five executable runners, with a Swift mirror. The SDK's job here is narrow but load-bearing: conformance proves wire presence, and BC3's own request tests prove the server-side preserve/clear. Kotlin initially SKIPped both cases because my mock body lacked required ScheduleEntry members — the same impossible-payload defect being fixed elsewhere in this stack. Fixed rather than left as a skip. Closes #477. --- conformance/runner/go/main.go | 16 ++ conformance/runner/python/runner.py | 8 + conformance/runner/ruby/runner.rb | 11 + conformance/runner/typescript/runner.test.ts | 11 + conformance/tests/schedule_entries_write.json | 192 ++++++++++++++++++ go/pkg/basecamp/api-provenance.json | 4 +- go/pkg/basecamp/version.go | 2 +- go/pkg/generated/client.gen.go | 18 +- .../com/basecamp/sdk/conformance/Main.kt | 14 ++ .../kotlin/com/basecamp/sdk/BasecampConfig.kt | 2 +- openapi.json | 5 +- python/src/basecamp/_version.py | 2 +- ruby/lib/basecamp/generated/metadata.json | 2 +- .../generated/services/schedules_service.rb | 10 +- ruby/lib/basecamp/generated/types.rb | 2 +- ruby/lib/basecamp/version.rb | 2 +- spec/api-gaps/everything-tasks-aggregate.md | 121 +++++++++++ spec/api-provenance.json | 4 +- spec/basecamp.smithy | 11 +- swift/Sources/Basecamp/BasecampConfig.swift | 2 +- .../ScheduleEntryParticipantsTests.swift | 141 +++++++++++++ typescript/src/client.ts | 2 +- typescript/src/generated/metadata.ts | 2 +- .../src/generated/openapi-stripped.json | 5 +- typescript/src/generated/schema.d.ts | 11 + .../src/generated/services/schedules.ts | 10 +- 26 files changed, 586 insertions(+), 24 deletions(-) create mode 100644 conformance/tests/schedule_entries_write.json create mode 100644 spec/api-gaps/everything-tasks-aggregate.md create mode 100644 swift/Tests/BasecampTests/ScheduleEntryParticipantsTests.swift diff --git a/conformance/runner/go/main.go b/conformance/runner/go/main.go index d06d35fa9..8439f9077 100644 --- a/conformance/runner/go/main.go +++ b/conformance/runner/go/main.go @@ -538,6 +538,22 @@ func executeOperation(ctx context.Context, account *basecamp.AccountClient, tc T _, err := account.Todos().Update(ctx, todoID, req) return operationResult{err: err} + case "UpdateScheduleEntry": + // Participants are presence-bearing: absent means "leave them alone" + // (BC3 preserves only because the key is missing), an empty non-nil + // slice means "remove everyone". + entryID := getInt64Param(tc.PathParams, "entryId") + req := &basecamp.UpdateScheduleEntryRequest{ + Summary: getStringParam(tc.RequestBody, "summary"), + StartsAt: getStringParam(tc.RequestBody, "starts_at"), + EndsAt: getStringParam(tc.RequestBody, "ends_at"), + } + if ids, ok := getInt64SliceParam(tc.RequestBody, "participant_ids"); ok { + req.ParticipantIDs = ids + } + _, err := account.Schedules().UpdateEntry(ctx, entryID, req) + return operationResult{err: err} + case "UpdateCard": // Merge-safe composite: GET then PUT, resending the fetched due_on. _, err := account.Cards().Update(ctx, getInt64Param(tc.PathParams, "cardId"), cardUpdateRequest(tc.RequestBody)) diff --git a/conformance/runner/python/runner.py b/conformance/runner/python/runner.py index 7a98cd503..73e3259f1 100644 --- a/conformance/runner/python/runner.py +++ b/conformance/runner/python/runner.py @@ -26,6 +26,7 @@ # Wire keys for todo write operations; identical to the Python kwarg / # edit-attribute names, so fixtures map onto the SDK surface directly. _TODO_WRITE_FIELDS = ("content", "description", "assignee_ids", "completion_subscriber_ids", "due_on", "starts_on", "notify") +_SCHEDULE_ENTRY_WRITE_FIELDS = ("summary", "starts_at", "ends_at", "description", "participant_ids", "all_day", "notify") _CARD_WRITE_FIELDS = ("title", "content", "due_on", "assignee_ids") # Sentinel distinguishing "key absent from the JSON body" from a present None. @@ -102,6 +103,13 @@ def __call__(self, operation: str, *, path_params: dict, query_params: dict, bod todo_id=path_params["todoId"], **{k: body[k] for k in _TODO_WRITE_FIELDS if k in body}, ) + case "UpdateScheduleEntry": + # Only pass keys the fixture carries: _compact strips None, so + # an absent participant_ids stays off the wire while [] survives. + return self._account.schedules.update_entry( + entry_id=path_params["entryId"], + **{k: body[k] for k in _SCHEDULE_ENTRY_WRITE_FIELDS if k in body}, + ) case "UpdateCard": # Merge-safe composite: GET then PUT, resending the fetched due_on. return self._account.cards.update( diff --git a/conformance/runner/ruby/runner.rb b/conformance/runner/ruby/runner.rb index 1385821d4..94ba320cb 100644 --- a/conformance/runner/ruby/runner.rb +++ b/conformance/runner/ruby/runner.rb @@ -156,6 +156,10 @@ def call(operation, path_params: {}, query_params: {}, body: nil, path: "") todo_id: path_params["todoId"], **todo_write_kwargs(body) ) + when "UpdateScheduleEntry" + # Only pass keys the fixture carries: compact_params strips nil, so an + # absent participant_ids stays off the wire while [] survives. + @account.schedules.update_entry(entry_id: path_params["entryId"], **schedule_entry_write_kwargs(body)) when "UpdateCard" # Merge-safe composite: GET then PUT, resending the fetched due_on. @account.cards.update(card_id: path_params["cardId"], **card_write_kwargs(body)) @@ -217,6 +221,13 @@ def call(operation, path_params: {}, query_params: {}, body: nil, path: "") content description assignee_ids completion_subscriber_ids due_on starts_on notify ].freeze + SCHEDULE_ENTRY_WRITE_KEYS = %w[summary starts_at ends_at description participant_ids all_day notify].freeze + + def schedule_entry_write_kwargs(body) + SCHEDULE_ENTRY_WRITE_KEYS.select { |key| (body || {}).key?(key) } \ + .to_h { |key| [key.to_sym, body[key]] } + end + CARD_WRITE_KEYS = %w[title content due_on assignee_ids].freeze def card_write_kwargs(body) diff --git a/conformance/runner/typescript/runner.test.ts b/conformance/runner/typescript/runner.test.ts index 4f19df9fc..6e87dcccd 100644 --- a/conformance/runner/typescript/runner.test.ts +++ b/conformance/runner/typescript/runner.test.ts @@ -234,6 +234,17 @@ async function executeOperation( await client.todos.update(Number(params.todoId), mapTodoWireFields(body)); break; + case "UpdateScheduleEntry": + // Only spread participantIds when the fixture carries the key: an + // absent key must not become [] or null on the wire. + await client.schedules.updateEntry(Number(params.entryId), { + ...(body.summary !== undefined ? { summary: String(body.summary) } : {}), + ...(body.participant_ids !== undefined + ? { participantIds: body.participant_ids as number[] } + : {}), + }); + break; + case "UpdateCard": // Merge-safe composite: GET then PUT, resending the fetched due_on. await client.cards.update(Number(params.cardId), { diff --git a/conformance/tests/schedule_entries_write.json b/conformance/tests/schedule_entries_write.json new file mode 100644 index 000000000..3e66ba708 --- /dev/null +++ b/conformance/tests/schedule_entries_write.json @@ -0,0 +1,192 @@ +[ + { + "name": "update-omits-participant-ids: an unaddressed participant list stays off the wire", + "description": "BC3 preserves the entry's participants only because the key is absent \u2014 until basecamp/bc3#12425 the controller called replace_participants unconditionally, so an omitted key wiped every participant and notified them. The SDK's job is narrow but load-bearing: when the caller says nothing about participants, participant_ids must not appear in the body at all. A compactor that emitted null, or a default that emitted [], would clear the list on the server.", + "operation": "UpdateScheduleEntry", + "method": "PUT", + "path": "/schedule_entries/{entryId}", + "pathParams": { + "entryId": 1069479523 + }, + "requestBody": { + "summary": "Team Meeting" + }, + "mockResponses": [ + { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "id": 1069479523, + "status": "active", + "visible_to_clients": false, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + "title": "Team Meeting", + "inherits_status": true, + "type": "Schedule::Entry", + "url": "https://3.basecampapi.com/999/buckets/1/schedule_entries/1069479523.json", + "app_url": "https://3.basecamp.com/999/buckets/1/schedule_entries/1069479523", + "summary": "Team Meeting", + "all_day": false, + "starts_at": "2026-06-05T06:00:00Z", + "ends_at": "2026-06-05T08:30:00Z", + "participants": [ + { + "id": 1049715914, + "name": "Victor Cooper" + }, + { + "id": 1049715915, + "name": "Annie Bryan" + } + ], + "description_attachments": [], + "parent": { + "id": 1069479521, + "title": "Schedule", + "type": "Schedule", + "url": "https://3.basecampapi.com/999/buckets/1/schedules/1069479521.json", + "app_url": "https://3.basecamp.com/999/buckets/1/schedules/1069479521" + }, + "bucket": { + "id": 1, + "name": "The Leto Laptop", + "type": "Project" + }, + "creator": { + "id": 1049715914, + "name": "Victor Cooper", + "email_address": "victor@honchodesign.com", + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z" + } + } + } + ], + "assertions": [ + { + "type": "requestCount", + "expected": 1 + }, + { + "type": "requestMethod", + "expected": "PUT", + "index": 0 + }, + { + "type": "requestBody", + "path": "summary", + "expected": "Team Meeting", + "index": 0 + }, + { + "type": "requestBodyAbsent", + "path": "participant_ids", + "index": 0 + }, + { + "type": "noError" + } + ], + "tags": [ + "schedule-entries", + "participants", + "omission" + ] + }, + { + "name": "update-empty-participant-ids: an explicit empty list reaches the wire", + "description": "The counterpart to omission: passing an explicitly empty list means \"remove everyone\" and must be sent as participant_ids: []. Ruby's compact_params and Python's _compact strip nil/None but not empty collections, so this distinguishes \"not addressed\" from \"addressed and cleared\" \u2014 the whole point of the BC3 guard.", + "operation": "UpdateScheduleEntry", + "method": "PUT", + "path": "/schedule_entries/{entryId}", + "pathParams": { + "entryId": 1069479523 + }, + "requestBody": { + "summary": "Team Meeting", + "participant_ids": [] + }, + "mockResponses": [ + { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "body": { + "id": 1069479523, + "status": "active", + "visible_to_clients": false, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + "title": "Team Meeting", + "inherits_status": true, + "type": "Schedule::Entry", + "url": "https://3.basecampapi.com/999/buckets/1/schedule_entries/1069479523.json", + "app_url": "https://3.basecamp.com/999/buckets/1/schedule_entries/1069479523", + "summary": "Team Meeting", + "all_day": false, + "starts_at": "2026-06-05T06:00:00Z", + "ends_at": "2026-06-05T08:30:00Z", + "participants": [ + { + "id": 1049715914, + "name": "Victor Cooper" + }, + { + "id": 1049715915, + "name": "Annie Bryan" + } + ], + "description_attachments": [], + "parent": { + "id": 1069479521, + "title": "Schedule", + "type": "Schedule", + "url": "https://3.basecampapi.com/999/buckets/1/schedules/1069479521.json", + "app_url": "https://3.basecamp.com/999/buckets/1/schedules/1069479521" + }, + "bucket": { + "id": 1, + "name": "The Leto Laptop", + "type": "Project" + }, + "creator": { + "id": 1049715914, + "name": "Victor Cooper", + "email_address": "victor@honchodesign.com", + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z" + } + } + } + ], + "assertions": [ + { + "type": "requestCount", + "expected": 1 + }, + { + "type": "requestMethod", + "expected": "PUT", + "index": 0 + }, + { + "type": "requestBody", + "path": "participant_ids", + "expected": [], + "index": 0 + }, + { + "type": "noError" + } + ], + "tags": [ + "schedule-entries", + "participants", + "clear" + ] + } +] diff --git a/go/pkg/basecamp/api-provenance.json b/go/pkg/basecamp/api-provenance.json index 83295cf85..b6ce27f98 100644 --- a/go/pkg/basecamp/api-provenance.json +++ b/go/pkg/basecamp/api-provenance.json @@ -1,8 +1,8 @@ { "bc3": { "branch": "master", - "revision": "c308693171680e8ec9d3ae9e68181ef2ea80f077", - "date": "2026-07-26" + "revision": "dffa7e11b3337b17454d9a82301be3e94226a858", + "date": "2026-07-28" }, "compatibility": { "bc3-four": { diff --git a/go/pkg/basecamp/version.go b/go/pkg/basecamp/version.go index 2aa6e4af6..7d6cd140a 100644 --- a/go/pkg/basecamp/version.go +++ b/go/pkg/basecamp/version.go @@ -4,4 +4,4 @@ package basecamp const Version = "0.9.0" // APIVersion is the Basecamp API version this SDK targets. -const APIVersion = "2026-07-26" +const APIVersion = "2026-07-28" diff --git a/go/pkg/generated/client.gen.go b/go/pkg/generated/client.gen.go index b596c64d3..1144f1072 100644 --- a/go/pkg/generated/client.gen.go +++ b/go/pkg/generated/client.gen.go @@ -2953,10 +2953,20 @@ type UpdateQuestionResponseContent = Question // UpdateScheduleEntryRequestContent defines model for UpdateScheduleEntryRequestContent. type UpdateScheduleEntryRequestContent struct { - AllDay *bool `json:"all_day,omitempty"` - Description string `json:"description,omitempty"` - EndsAt time.Time `json:"ends_at,omitempty"` - Notify *bool `json:"notify,omitempty"` + AllDay *bool `json:"all_day,omitempty"` + Description string `json:"description,omitempty"` + EndsAt time.Time `json:"ends_at,omitempty"` + Notify *bool `json:"notify,omitempty"` + + // ParticipantIds Replaces the entry's participants. + // + // Omitting this member preserves the current participants; sending an empty + // array clears them. That guarantee is BC3-side and recent: until + // basecamp/bc3#12425, `Schedules::EntriesController#update` called + // `replace_participants` unconditionally, so any update omitting the key — + // including the shape in BC3's own "Update a schedule entry" doc example — + // silently removed every participant and notified each one. The controller + // now guards on the request actually addressing participants. ParticipantIds []int64 `json:"participant_ids,omitempty"` StartsAt time.Time `json:"starts_at,omitempty"` Summary string `json:"summary,omitempty"` diff --git a/kotlin/conformance/src/main/kotlin/com/basecamp/sdk/conformance/Main.kt b/kotlin/conformance/src/main/kotlin/com/basecamp/sdk/conformance/Main.kt index 675b0f3d3..c1da394e7 100644 --- a/kotlin/conformance/src/main/kotlin/com/basecamp/sdk/conformance/Main.kt +++ b/kotlin/conformance/src/main/kotlin/com/basecamp/sdk/conformance/Main.kt @@ -690,6 +690,20 @@ private suspend fun dispatchOperation(tc: TestCase, account: AccountClient): Dis DispatchResult() } + // Participants are presence-bearing: an absent key must not become an + // empty list on the wire, or BC3 clears the participants. + "UpdateScheduleEntry" -> { + val entryId = tc.pathParams.longParam("entryId") + val rb = tc.requestBody + account.schedules.updateEntry(entryId, UpdateScheduleEntryBody( + summary = rb?.get("summary")?.jsonPrimitive?.contentOrNull, + startsAt = rb?.get("starts_at")?.jsonPrimitive?.contentOrNull, + endsAt = rb?.get("ends_at")?.jsonPrimitive?.contentOrNull, + participantIds = rb?.get("participant_ids")?.jsonArray?.map { it.jsonPrimitive.long }, + )) + DispatchResult() + } + // Merge-safe composite: GET then PUT, resending the fetched due_on. "UpdateCard" -> { val cardId = tc.pathParams.longParam("cardId") diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/BasecampConfig.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/BasecampConfig.kt index f1d1bfc01..f6198abf9 100644 --- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/BasecampConfig.kt +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/BasecampConfig.kt @@ -29,7 +29,7 @@ data class BasecampConfig( ) { companion object { const val VERSION = "0.9.0" - const val API_VERSION = "2026-07-26" + const val API_VERSION = "2026-07-28" const val DEFAULT_BASE_URL = "https://3.basecampapi.com" const val DEFAULT_USER_AGENT = "basecamp-sdk-kotlin/$VERSION (api:$API_VERSION)" const val DEFAULT_MAX_RETRIES = 3 diff --git a/openapi.json b/openapi.json index 37f71dc66..c779765cd 100644 --- a/openapi.json +++ b/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "Basecamp", - "version": "2026-07-26", + "version": "2026-07-28", "description": "Basecamp API", "contact": { "name": "Basecamp", @@ -31069,7 +31069,8 @@ "items": { "type": "integer", "format": "int64" - } + }, + "description": "Replaces the entry's participants.\n\nOmitting this member preserves the current participants; sending an empty\narray clears them. That guarantee is BC3-side and recent: until\nbasecamp/bc3#12425, `Schedules::EntriesController#update` called\n`replace_participants` unconditionally, so any update omitting the key —\nincluding the shape in BC3's own \"Update a schedule entry\" doc example —\nsilently removed every participant and notified each one. The controller\nnow guards on the request actually addressing participants." }, "all_day": { "type": "boolean", diff --git a/python/src/basecamp/_version.py b/python/src/basecamp/_version.py index f0db63589..5985be1b4 100644 --- a/python/src/basecamp/_version.py +++ b/python/src/basecamp/_version.py @@ -1,2 +1,2 @@ VERSION = "0.9.0" -API_VERSION = "2026-07-26" +API_VERSION = "2026-07-28" diff --git a/ruby/lib/basecamp/generated/metadata.json b/ruby/lib/basecamp/generated/metadata.json index 470b54131..e4b92c8cc 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-28T07:10:09Z", + "generated": "2026-07-28T21:34:14Z", "operations": { "GetAccount": { "retry": { diff --git a/ruby/lib/basecamp/generated/services/schedules_service.rb b/ruby/lib/basecamp/generated/services/schedules_service.rb index c9e77a000..6d81442bf 100644 --- a/ruby/lib/basecamp/generated/services/schedules_service.rb +++ b/ruby/lib/basecamp/generated/services/schedules_service.rb @@ -22,7 +22,15 @@ def get_entry(entry_id:) # @param starts_at [String, nil] starts at (RFC3339 (e.g., 2024-12-15T09:00:00Z)) # @param ends_at [String, nil] ends at (RFC3339 (e.g., 2024-12-15T09:00:00Z)) # @param description [String, nil] description - # @param participant_ids [Array, nil] participant ids + # @param participant_ids [Array, nil] Replaces the entry's participants. + # + # Omitting this member preserves the current participants; sending an empty + # array clears them. That guarantee is BC3-side and recent: until + # basecamp/bc3#12425, `Schedules::EntriesController#update` called + # `replace_participants` unconditionally, so any update omitting the key — + # including the shape in BC3's own "Update a schedule entry" doc example — + # silently removed every participant and notified each one. The controller + # now guards on the request actually addressing participants. # @param all_day [Boolean, nil] all day # @param notify [Boolean, nil] notify # @return [Hash] response data diff --git a/ruby/lib/basecamp/generated/types.rb b/ruby/lib/basecamp/generated/types.rb index 03757bc3b..ebea1db13 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-28T07:10:09Z +# Generated: 2026-07-28T21:34:14Z require "json" require "time" diff --git a/ruby/lib/basecamp/version.rb b/ruby/lib/basecamp/version.rb index 72f4347de..a96aa3ee3 100644 --- a/ruby/lib/basecamp/version.rb +++ b/ruby/lib/basecamp/version.rb @@ -2,5 +2,5 @@ module Basecamp VERSION = "0.9.0" - API_VERSION = "2026-07-26" + API_VERSION = "2026-07-28" end diff --git a/spec/api-gaps/everything-tasks-aggregate.md b/spec/api-gaps/everything-tasks-aggregate.md new file mode 100644 index 000000000..62acd0b4d --- /dev/null +++ b/spec/api-gaps/everything-tasks-aggregate.md @@ -0,0 +1,121 @@ +--- +gap: everything-tasks-aggregate +status: partial-coverage +detected: 2026-07-28 +sdk_demand: medium +bc3_refs: + introduced_in: five + routes: + - "GET /:account_id/tasks.json → everything/assignments#index" + - "GET /:account_id/tasks/results.json → everything/assignments/results#index" + controllers: + - "Everything::AssignmentsController" + - "Everything::Assignments::ResultsController" + related_existing_api: + - "GetEverythingOpenTodos / GetEverythingOverdueTodos / … (the modeled Everything aggregates)" + - "GetMyAssignments / GetMyDueAssignments / GetMyCompletedAssignments (my/assignments.json — unchanged)" +--- + +# Everything "tasks" aggregate + +## What's missing + +BC3 gained a new account-wide aggregate under the `scope module: :everything` +block — the same block that produces the already-modeled `/todos/open.json`, +`/cards/completed.json`, and friends: + +```ruby +get "tasks" => "assignments#index", as: :assignments +namespace :assignments, path: "tasks" do + get "results" => "results#index" +end +``` + +Neither `GET /:account_id/tasks.json` nor `GET /:account_id/tasks/results.json` +is modeled in `spec/basecamp.smithy`. + +## Provenance + +Surfaced while triaging drift from `c308693171` to `dffa7e11b3` for the #477 +repin. Two commits are involved and the net effect is one new aggregate, not a +rename of anything the SDK already ships: + +- `e4bf93c3c2` "All tasks & Everything::Assignments" **added** + `get "assignments" => "assignments#index"` plus an `assignments/results` + namespace. +- `f697d8604d` "Move /assignments to /tasks" then **renamed the new routes** + to `/tasks` before they ever appeared under a provenance pin. + +## This is NOT a break of `my/assignments` + +Worth stating explicitly, because the commit subject "Move /assignments to +/tasks" reads alarming against an SDK that ships `GetMyAssignments`. The moved +routes are the **Everything** ones inside `scope module: :everything`. The +SDK's assignment operations use `/:account_id/my/assignments.json` and its +`completed`/`due` variants, which live under a different scope and are +untouched across the whole drift range (`git diff c308693171..dffa7e11b3 -- +config/routes.rb` shows only the two `tasks` additions). + +## Why it matters + +The Everything aggregates are how a cross-project consumer avoids enumerating +projects. `tasks` is the assignment-shaped member of that family, and its +absence means the only account-wide assignment view the SDK exposes is the +*current user's* (`my/assignments`), not the account's. + +## Suggested API shape + +Follows the sibling aggregates exactly — same flat account-scoped path, same +pagination: + +``` +GET /:account_id/tasks.json +GET /:account_id/tasks/results.json +``` + +```json +[ + { + "id": 1069479520, + "type": "Todo", + "title": "Ship the thing", + "due_on": "2026-08-01", + "assignees": [ { "id": 1049715914, "name": "Victor Cooper" } ], + "bucket": { "id": 2085958499, "name": "The Leto Laptop", "type": "Project" }, + "url": "https://3.basecampapi.com/195539477/buckets/2085958499/todos/1069479520.json", + "app_url": "https://3.basecamp.com/195539477/buckets/2085958499/todos/1069479520" + } +] +``` + +The element shape above is **provisional** — it is the union the controller +appears to span (Todos and Kanban::Cards, plus their Steps), not a shape read +off a captured response. Pin it before modeling. + +## Implementation notes for BC3 + +Nothing is required of BC3 — the routes exist and are serving. This entry +records that the SDK has not caught up, not that the API is incomplete. + +If anything, a doc entry would help: the sibling Everything aggregates are +documented under `doc/api/`, and `tasks` currently is not, which is part of why +it went unnoticed through a pin bump. + +## SDK absorption plan when this lands + +1. Pin the response shape. `Everything::AssignmentsController#index` renders an + assignment projection; its element type has to be checked against the + existing `Todo` and `Kanban::Card` structures before a Smithy shape is + written, since an aggregate spanning both may need a polymorphic projection + in the style of `Recording`/`SearchResult` rather than a new concrete type. +2. Decide whether `tasks/results` is a distinct paginated resource or a filtered + view of the same collection — that is the difference between one operation + and two. +3. Model the pagination and filter parameters against the sibling aggregates, + which take `page` plus type-specific filters. +4. Follow the Everything naming convention already in the spec: + `GetEverythingTasks` (and `GetEverythingTaskResults` if it is a second + operation), landing on the `everything` service alongside + `GetEverythingOpenTodos` and friends. +5. Regenerate all six SDKs and add a fixture plus a manifest target, since the + aggregate returns a list shape the fixture-coverage guard will want covered. diff --git a/spec/api-provenance.json b/spec/api-provenance.json index 83295cf85..b6ce27f98 100644 --- a/spec/api-provenance.json +++ b/spec/api-provenance.json @@ -1,8 +1,8 @@ { "bc3": { "branch": "master", - "revision": "c308693171680e8ec9d3ae9e68181ef2ea80f077", - "date": "2026-07-26" + "revision": "dffa7e11b3337b17454d9a82301be3e94226a858", + "date": "2026-07-28" }, "compatibility": { "bc3-four": { diff --git a/spec/basecamp.smithy b/spec/basecamp.smithy index c55a68634..70f39cc35 100644 --- a/spec/basecamp.smithy +++ b/spec/basecamp.smithy @@ -51,7 +51,7 @@ use basecamp.traits#basecampAuthRoutableUrl /// Basecamp API @restJson1 service Basecamp { - version: "2026-07-26" + version: "2026-07-28" rename: { "smithy.api#Document": "JsonDocument" } @@ -2718,6 +2718,15 @@ structure UpdateScheduleEntryInput { starts_at: ISO8601Timestamp ends_at: ISO8601Timestamp description: ScheduleEntryDescription + /// Replaces the entry's participants. + /// + /// Omitting this member preserves the current participants; sending an empty + /// array clears them. That guarantee is BC3-side and recent: until + /// basecamp/bc3#12425, `Schedules::EntriesController#update` called + /// `replace_participants` unconditionally, so any update omitting the key — + /// including the shape in BC3's own "Update a schedule entry" doc example — + /// silently removed every participant and notified each one. The controller + /// now guards on the request actually addressing participants. participant_ids: PersonIdList all_day: Boolean notify: Boolean diff --git a/swift/Sources/Basecamp/BasecampConfig.swift b/swift/Sources/Basecamp/BasecampConfig.swift index 48ffe464e..7ce0d3897 100644 --- a/swift/Sources/Basecamp/BasecampConfig.swift +++ b/swift/Sources/Basecamp/BasecampConfig.swift @@ -27,7 +27,7 @@ public struct BasecampConfig: Sendable { public static let version = "0.9.0" /// Basecamp API version this SDK targets. - public static let apiVersion = "2026-07-26" + public static let apiVersion = "2026-07-28" /// Default User-Agent header value. public static let defaultUserAgent = "basecamp-sdk-swift/\(version) (api:\(apiVersion))" diff --git a/swift/Tests/BasecampTests/ScheduleEntryParticipantsTests.swift b/swift/Tests/BasecampTests/ScheduleEntryParticipantsTests.swift new file mode 100644 index 000000000..017f380bc --- /dev/null +++ b/swift/Tests/BasecampTests/ScheduleEntryParticipantsTests.swift @@ -0,0 +1,141 @@ +import XCTest +@testable import Basecamp + +/// Swift mirror of `conformance/tests/schedule_entries_write.json`. +/// +/// Swift is not part of `make conformance`, so these carry the same two +/// assertions the five executable runners make. +/// +/// The contract is BC3-side and recent. Until basecamp/bc3#12425, +/// `Schedules::EntriesController#update` called `replace_participants` +/// unconditionally, so an update omitting `participant_ids` removed every +/// participant and notified each one — including the shape in BC3's own +/// "Update a schedule entry" doc example. The controller now guards on the +/// request actually addressing participants, which makes the SDK's job narrow +/// but load-bearing: an unaddressed list must stay *off the wire entirely*. +/// Emitting `null`, or defaulting to `[]`, would clear it on the server. +private final class ScheduleRequestLog: @unchecked Sendable { + private let lock = NSLock() + private var _putBody: [String: Any]? + + var putBody: [String: Any]? { lock.withLock { _putBody } } + + func record(_ request: URLRequest) { + guard request.httpMethod == "PUT" else { return } + lock.withLock { + if let data = request.httpBody ?? request.scheduleBodyStreamData() { + _putBody = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] + } + } + } +} + +extension URLRequest { + fileprivate func scheduleBodyStreamData() -> Data? { + guard let stream = httpBodyStream else { return nil } + stream.open() + defer { stream.close() } + var data = Data() + let bufferSize = 4096 + let buffer = UnsafeMutablePointer.allocate(capacity: bufferSize) + defer { buffer.deallocate() } + while stream.hasBytesAvailable { + let read = stream.read(buffer, maxLength: bufferSize) + if read <= 0 { break } + data.append(buffer, count: read) + } + return data + } +} + +private func scheduleEntryJSON(id: Int = 1069479523) -> [String: Any] { + [ + "id": id, + "status": "active", + "visible_to_clients": false, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + "title": "Team Meeting", + "inherits_status": true, + "type": "Schedule::Entry", + "url": "https://3.basecampapi.com/999/buckets/1/schedule_entries/\(id).json", + "app_url": "https://3.basecamp.com/999/buckets/1/schedule_entries/\(id)", + "summary": "Team Meeting", + "all_day": false, + "starts_at": "2026-06-05T06:00:00Z", + "ends_at": "2026-06-05T08:30:00Z", + "description_attachments": [] as [Any], + "participants": [ + ["id": 1049715914, "name": "Victor Cooper"], + ["id": 1049715915, "name": "Annie Bryan"], + ], + "parent": [ + "id": 1069479521, + "title": "Schedule", + "type": "Schedule", + "url": "https://3.basecampapi.com/999/buckets/1/schedules/1069479521.json", + "app_url": "https://3.basecamp.com/999/buckets/1/schedules/1069479521", + ], + "bucket": ["id": 1, "name": "The Leto Laptop", "type": "Project"], + "creator": [ + "id": 1049715914, + "name": "Victor Cooper", + "email_address": "victor@honchodesign.com", + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + ], + ] +} + +final class ScheduleEntryParticipantsTests: XCTestCase { + private func makeClient(log: ScheduleRequestLog) throws -> AccountClient { + let data = try JSONSerialization.data(withJSONObject: scheduleEntryJSON()) + let transport = MockTransport { request in + log.record(request) + return ( + data, + makeHTTPResponse( + url: request.url!.absoluteString, + statusCode: 200, + headers: ["Content-Type": "application/json"] + ) + ) + } + return makeTestAccountClient(transport: transport) + } + + func testOmittedParticipantIdsStayOffTheWire() async throws { + let log = ScheduleRequestLog() + let account = try makeClient(log: log) + + _ = try await account.schedules.updateEntry( + entryId: 1069479523, + req: UpdateScheduleEntryRequest(summary: "Team Meeting") + ) + + let body = try XCTUnwrap(log.putBody) + XCTAssertEqual(body["summary"] as? String, "Team Meeting") + XCTAssertNil( + body["participant_ids"], + "an unaddressed participant list must not reach the wire — BC3 preserves participants only because the key is absent" + ) + XCTAssertFalse(body.keys.contains("participant_ids")) + } + + func testExplicitEmptyParticipantIdsReachTheWire() async throws { + let log = ScheduleRequestLog() + let account = try makeClient(log: log) + + _ = try await account.schedules.updateEntry( + entryId: 1069479523, + req: UpdateScheduleEntryRequest(participantIds: [], summary: "Team Meeting") + ) + + let body = try XCTUnwrap(log.putBody) + let ids = try XCTUnwrap( + body["participant_ids"] as? [Any], + "an explicitly empty list means 'remove everyone' and must be sent" + ) + XCTAssertTrue(ids.isEmpty) + } +} diff --git a/typescript/src/client.ts b/typescript/src/client.ts index d44965d05..676afd8f5 100644 --- a/typescript/src/client.ts +++ b/typescript/src/client.ts @@ -233,7 +233,7 @@ export interface BasecampClientOptions { } export const VERSION = "0.9.0"; -export const API_VERSION = "2026-07-26"; +export const API_VERSION = "2026-07-28"; const DEFAULT_USER_AGENT = `basecamp-sdk-ts/${VERSION} (api:${API_VERSION})`; /** diff --git a/typescript/src/generated/metadata.ts b/typescript/src/generated/metadata.ts index 682354f42..819575b7e 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-28T07:10:08.052Z", + "generated": "2026-07-28T21:34:13.284Z", "operations": { "GetAccount": { "retry": { diff --git a/typescript/src/generated/openapi-stripped.json b/typescript/src/generated/openapi-stripped.json index 13d9cf347..502d0d67f 100644 --- a/typescript/src/generated/openapi-stripped.json +++ b/typescript/src/generated/openapi-stripped.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "Basecamp", - "version": "2026-07-26", + "version": "2026-07-28", "description": "Basecamp API", "contact": { "name": "Basecamp", @@ -28493,7 +28493,8 @@ "items": { "type": "integer", "format": "int64" - } + }, + "description": "Replaces the entry's participants.\n\nOmitting this member preserves the current participants; sending an empty\narray clears them. That guarantee is BC3-side and recent: until\nbasecamp/bc3#12425, `Schedules::EntriesController#update` called\n`replace_participants` unconditionally, so any update omitting the key —\nincluding the shape in BC3's own \"Update a schedule entry\" doc example —\nsilently removed every participant and notified each one. The controller\nnow guards on the request actually addressing participants." }, "all_day": { "type": "boolean", diff --git a/typescript/src/generated/schema.d.ts b/typescript/src/generated/schema.d.ts index 731eca8fd..2ed943328 100644 --- a/typescript/src/generated/schema.d.ts +++ b/typescript/src/generated/schema.d.ts @@ -5382,6 +5382,17 @@ export interface components { starts_at?: string; ends_at?: string; description?: string; + /** + * @description Replaces the entry's participants. + * + * Omitting this member preserves the current participants; sending an empty + * array clears them. That guarantee is BC3-side and recent: until + * basecamp/bc3#12425, `Schedules::EntriesController#update` called + * `replace_participants` unconditionally, so any update omitting the key — + * including the shape in BC3's own "Update a schedule entry" doc example — + * silently removed every participant and notified each one. The controller + * now guards on the request actually addressing participants. + */ participant_ids?: number[]; all_day?: boolean; notify?: boolean; diff --git a/typescript/src/generated/services/schedules.ts b/typescript/src/generated/services/schedules.ts index 3b464cff2..a4c293b6d 100644 --- a/typescript/src/generated/services/schedules.ts +++ b/typescript/src/generated/services/schedules.ts @@ -31,7 +31,15 @@ export interface UpdateEntryScheduleRequest { endsAt?: string; /** Rich text description (HTML) */ description?: string; - /** Participant ids */ + /** Replaces the entry's participants. + +Omitting this member preserves the current participants; sending an empty +array clears them. That guarantee is BC3-side and recent: until +basecamp/bc3#12425, `Schedules::EntriesController#update` called +`replace_participants` unconditionally, so any update omitting the key — +including the shape in BC3's own "Update a schedule entry" doc example — +silently removed every participant and notified each one. The controller +now guards on the request actually addressing participants. */ participantIds?: number[]; /** All day */ allDay?: boolean; From d54f4141ba15c0441d51441bca9828e16779cc4d Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 28 Jul 2026 15:23:24 -0700 Subject: [PATCH 2/2] Reclassify the everything/tasks routes: web view, not an API resource MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I filed this as partial-coverage with a provisional response shape and an SDK absorption plan. That was wrong, and the shape was invented. At the pinned dffa7e11b3 both controllers are HTML-only: Everything::AssignmentsController#index is an empty action, and the results controller declares layout "turbo_rails/frame" — it exists to serve a Turbo Frame for the filter UI. Every view under app/views/everything/assignments is .html.erb, there is no respond_to, no JSON branch, no jbuilder template, and nothing in doc/api. A Rails route tolerating a .json suffix does not create a JSON representation. So the correct status is confirmed-not-api-resource. The entry now records a NEGATIVE result — its purpose is to stop these routes being re-flagged at the next provenance sync, since sitting inside scope module: :everything makes them look like siblings of the modeled JSON aggregates. Dropped the suggested API shape and the absorption plan rather than leaving a sketch of a payload that does not exist. Kept the note that this is not a break of my/assignments, which remains the useful part. --- spec/api-gaps/everything-tasks-aggregate.md | 141 +++++++++----------- 1 file changed, 60 insertions(+), 81 deletions(-) diff --git a/spec/api-gaps/everything-tasks-aggregate.md b/spec/api-gaps/everything-tasks-aggregate.md index 62acd0b4d..5a9f9bda8 100644 --- a/spec/api-gaps/everything-tasks-aggregate.md +++ b/spec/api-gaps/everything-tasks-aggregate.md @@ -1,28 +1,31 @@ --- gap: everything-tasks-aggregate -status: partial-coverage +status: confirmed-not-api-resource detected: 2026-07-28 -sdk_demand: medium +sdk_demand: none bc3_refs: introduced_in: five + bc3_plan_section: "Not in API scope — web-only 'All tasks' page (bc3 e4bf93c3c2 + f697d8604d @ dffa7e11b3); HTML/Turbo Frame controllers, no respond_to :json, no jbuilder, no doc/api section" routes: - - "GET /:account_id/tasks.json → everything/assignments#index" - - "GET /:account_id/tasks/results.json → everything/assignments/results#index" + - "GET /:account_id/tasks → everything/assignments#index (HTML only)" + - "GET /:account_id/tasks/results → everything/assignments/results#index (Turbo Frame, HTML only)" controllers: - "Everything::AssignmentsController" - "Everything::Assignments::ResultsController" related_existing_api: - - "GetEverythingOpenTodos / GetEverythingOverdueTodos / … (the modeled Everything aggregates)" + - "GetEverythingOpenTodos / GetEverythingOverdueTodos / … (the modeled Everything JSON aggregates)" - "GetMyAssignments / GetMyDueAssignments / GetMyCompletedAssignments (my/assignments.json — unchanged)" --- -# Everything "tasks" aggregate +# Everything "tasks" — a web view, not an API resource ## What's missing -BC3 gained a new account-wide aggregate under the `scope module: :everything` -block — the same block that produces the already-modeled `/todos/open.json`, -`/cards/completed.json`, and friends: +Nothing. This entry exists to record a **negative** result, so the same routes +don't get re-flagged at the next provenance sync. + +BC3 gained routes under the `scope module: :everything` block — the same block +that produces the modeled `/todos/open.json` and `/cards/completed.json`: ```ruby get "tasks" => "assignments#index", as: :assignments @@ -31,91 +34,67 @@ namespace :assignments, path: "tasks" do end ``` -Neither `GET /:account_id/tasks.json` nor `GET /:account_id/tasks/results.json` -is modeled in `spec/basecamp.smithy`. - -## Provenance +Sitting in that block makes them *look* like siblings of the JSON aggregates. +They are not. At the pinned `dffa7e11b3` both controllers are HTML-only: -Surfaced while triaging drift from `c308693171` to `dffa7e11b3` for the #477 -repin. Two commits are involved and the net effect is one new aggregate, not a -rename of anything the SDK already ships: +```ruby +class Everything::AssignmentsController < ApplicationController + include Everything::Assignments::Filters + def index + end +end -- `e4bf93c3c2` "All tasks & Everything::Assignments" **added** - `get "assignments" => "assignments#index"` plus an `assignments/results` - namespace. -- `f697d8604d` "Move /assignments to /tasks" then **renamed the new routes** - to `/tasks` before they ever appeared under a provenance pin. +class Everything::Assignments::ResultsController < ApplicationController + include Assignments::SlicedBuckets, Everything::Assignments::Filters + layout "turbo_rails/frame" + def index + @assignments = Everything::Assignments.new(Current.person, **assignment_filter_params) + @page = sliced_buckets @assignments.recordings, buckets: @assignments.sorted_buckets, viewer: Current.person + end +end +``` -## This is NOT a break of `my/assignments` +- no `respond_to`, no JSON branch; +- every view under `app/views/everything/assignments/` is `.html.erb`; +- no `.json.jbuilder` template anywhere; +- `results` declares `layout "turbo_rails/frame"` — it exists to serve a Turbo + Frame for the filter UI; +- nothing in `doc/api/`. -Worth stating explicitly, because the commit subject "Move /assignments to -/tasks" reads alarming against an SDK that ships `GetMyAssignments`. The moved -routes are the **Everything** ones inside `scope module: :everything`. The -SDK's assignment operations use `/:account_id/my/assignments.json` and its -`completed`/`due` variants, which live under a different scope and are -untouched across the whole drift range (`git diff c308693171..dffa7e11b3 -- -config/routes.rb` shows only the two `tasks` additions). +**A Rails route that tolerates a `.json` suffix does not create a JSON +representation.** Requesting these with `.json` gets the HTML template or a +missing-template error, not a payload. ## Why it matters -The Everything aggregates are how a cross-project consumer avoids enumerating -projects. `tasks` is the assignment-shaped member of that family, and its -absence means the only account-wide assignment view the SDK exposes is the -*current user's* (`my/assignments`), not the account's. - -## Suggested API shape - -Follows the sibling aggregates exactly — same flat account-scoped path, same -pagination: +Only as a guard against a false positive. The provenance-drift triage from +`c308693171` to `dffa7e11b3` surfaced two commits — `e4bf93c3c2` added +`everything/assignments`, `f697d8604d` renamed it to `everything/tasks` — and +the naming makes them read as an unmodeled API aggregate. They are the +web-only "All tasks" page. -``` -GET /:account_id/tasks.json -GET /:account_id/tasks/results.json -``` +**This is also not a break of `my/assignments`.** The commit subject "Move +/assignments to /tasks" reads alarming against an SDK that ships +`GetMyAssignments`, but the moved routes are the Everything ones. The SDK's +assignment operations use `/:account_id/my/assignments.json` and its +`completed`/`due` variants, which live under a different scope and are +untouched across the whole drift range — `git diff c308693171..dffa7e11b3 -- +config/routes.rb` contains only the two `tasks` additions. -```json -[ - { - "id": 1069479520, - "type": "Todo", - "title": "Ship the thing", - "due_on": "2026-08-01", - "assignees": [ { "id": 1049715914, "name": "Victor Cooper" } ], - "bucket": { "id": 2085958499, "name": "The Leto Laptop", "type": "Project" }, - "url": "https://3.basecampapi.com/195539477/buckets/2085958499/todos/1069479520.json", - "app_url": "https://3.basecamp.com/195539477/buckets/2085958499/todos/1069479520" - } -] -``` +## Suggested API shape -The element shape above is **provisional** — it is the union the controller -appears to span (Todos and Kanban::Cards, plus their Steps), not a shape read -off a captured response. Pin it before modeling. +None. Deliberately not proposed: there is no captured response to model, and +sketching one would invite modeling a payload that does not exist. If an +account-wide assignments JSON aggregate is ever wanted, it needs a BC3-side +JSON representation first — at which point this entry should be reopened as a +real gap with a shape read off an actual response. ## Implementation notes for BC3 -Nothing is required of BC3 — the routes exist and are serving. This entry -records that the SDK has not caught up, not that the API is incomplete. - -If anything, a doc entry would help: the sibling Everything aggregates are -documented under `doc/api/`, and `tasks` currently is not, which is part of why -it went unnoticed through a pin bump. +None required. The routes serve the web UI as intended. ## SDK absorption plan when this lands -1. Pin the response shape. `Everything::AssignmentsController#index` renders an - assignment projection; its element type has to be checked against the - existing `Todo` and `Kanban::Card` structures before a Smithy shape is - written, since an aggregate spanning both may need a polymorphic projection - in the style of `Recording`/`SearchResult` rather than a new concrete type. -2. Decide whether `tasks/results` is a distinct paginated resource or a filtered - view of the same collection — that is the difference between one operation - and two. -3. Model the pagination and filter parameters against the sibling aggregates, - which take `page` plus type-specific filters. -4. Follow the Everything naming convention already in the spec: - `GetEverythingTasks` (and `GetEverythingTaskResults` if it is a second - operation), landing on the `everything` service alongside - `GetEverythingOpenTodos` and friends. -5. Regenerate all six SDKs and add a fixture plus a manifest target, since the - aggregate returns a list shape the fixture-coverage guard will want covered. +Not applicable — there is nothing to absorb. Reopen this entry only if BC3 adds +a JSON representation for these controllers; until then the correct SDK action +is to do nothing.