Skip to content

Stop the sparse schedule-entry PUT from erasing the join link, highlight and participants (#546, #547) - #632

Merged
jeremy merged 8 commits into
mainfrom
feat/schedule-entries-triad
Aug 4, 2026
Merged

jeremy merged 8 commits into
mainfrom
feat/schedule-entries-triad

Conversation

@jeremy

@jeremy jeremy commented Aug 4, 2026 •

Copy link
Copy Markdown
Member

Closes #546. Closes #547. Third child of #374, and the one that completes it.

PUT /{accountId}/schedule_entries/{entryId} is a full replace, and every SDK shipped a sparse update over it. UpdateScheduleEntry becomes ReplaceScheduleEntry — breaking, no deprecated alias, the ReplaceTodo/ReplaceDocument precedent — and a merge-safe updateEntry / editEntry triad lands in all six SDKs.

What makes this one different from #601 is that the server contract is not uniform across the writable fields. Three of them are carved out of the replacement, and the composites must therefore not resend them. That distinction is the whole PR.

The upstream half is already merged and deployed

bc3 #12502 (4dd2926f8af0) does two things, and both are load-bearing:

PRESERVED_ON_OMISSION = %i[ url highlighted ].freeze

def update_schedule_entry_params
  super.tap do |entry_params|
    PRESERVED_ON_OMISSION.each do |attribute|
      next if params[:schedule_entry]&.key?(attribute)
      entry_params[attribute] = @recording.recordable.public_send(attribute)
    end
  end
end

and it starts emitting them — highlighted, plus the join link as join_url, in api/schedules/entries/_entry.json.jbuilder and api/schedules/entries/occurrences/show.json.jbuilder. participant_ids was already guarded separately by update_participants? since #12425.

The occurrence view matters: ensure_non_recurring_event 302-redirects a recurring entry's show/update to its occurrence, so for a recurring entry the occurrence payload is the only place these fields are reachable. This route serves non-recurring entries only.

preservedOnOmission, and why it needed a gate

@basecampWriteSemantics gains preservedOnOmission, and ReplaceScheduleEntry declares:

@basecampWriteSemantics(mode: "replace", clearsOmitted: true, preservedOnOmission: ["participant_ids", "url", "highlighted"])

Behavior-model plumbing is not automatic. scripts/generate-behavior-model builds its write clause key by key, so a trait field nobody teaches it about is dropped silently: the OpenAPI extension is right, the behavior model is quietly short, and every SDK reads the behavior model. That is exactly how this would have shipped as a no-op.

scripts/check-write-semantics-parity compares the two artifacts in both directions and is wired into make check. Red proof — the generator change excised in a scratch copy, never in the tracked tree:

=== write clause produced ===
{
  "mode": "replace",
  "clearsOmitted": true
}
=== running the parity gate against it ===
ERROR: x-basecamp-write-semantics and behavior-model.json disagree.

This is usually scripts/generate-behavior-model not carrying a trait field
through. Diff (< openapi.json, > behavior-model.json):

8,13c8
<     "mode": "replace",
<     "preservedOnOmission": [
<       "participant_ids",
<       "url",
<       "highlighted"
<     ]
---
>     "mode": "replace"
REDPROOF_PARITY_EXIT_IS 1

Restored, the gate names every carve-out set so a reviewer sees them without opening either artifact:

write semantics parity holds for 4 operation(s)
  ReplaceDocument: mode=replace clearsOmitted=true
  ReplaceScheduleEntry: mode=replace clearsOmitted=true preservedOnOmission=participant_ids,url,highlighted
  ReplaceTodo: mode=replace clearsOmitted=true
  UpdateTodolistOrGroup: mode=replace clearsOmitted=true

SPEC §18: the bound that keeps this a Replace*

Replace* survives declared carve-outs only while the preserved set is limited to fields a client could not safely resend from a read-back — write-only, system-managed, or identity-colliding. Every readable-and-writable field still clears on omission; that is what distinguishes it from a merge, and why Cards stays Update*. Widen the set past that bound and the operation has become a merge wearing a replace's name.

Each of the three earns its place:

field why a client cannot resend it
url identity-colliding. On write it is the join link; on read, url is the entry's own Basecamp API URL — recordings/_recording.json.jbuilder writes that key first and the entry partial renders after it, so BC3 emits the join link as join_url. Echoing the response's url back writes the API URL into the join link.
highlighted was write-only. Accepted on write but never emitted until #12502, so no caller had a value to resend.
participant_ids system-managed on read-back. The response carries participants (objects, not IDs), and BC3 re-screens a submitted list through the bucket's reachable people, so a resent projection can silently drop a participant who has since become unreachable.

Request optionality and response requiredness are separate facts

#601's lesson, re-derived here from bc3 at the pinned revision rather than assumed:

field request response consequence
summary optional @required Schedule::Entry#summary is super.presence || "Untitled", so a healthy server can never render it blank. An absent/null/blank summary in a 2xx body is a malformed response — coalescing it to "" and PUTting it back would blank a real summary on a call that only moved the entry. Read with required_writable_string.
starts_at, ends_at @required @required Schedule::Entry presence-validates both and Recording validates the associated recordable on update, so omitting either is a 422, not a clear.
all_day optional, not carved out @required schedule_entries.all_day is NOT NULL default false. Omitting it on a replace converts an all-day entry into a midnight-to-midnight timed one, which is why the composites resend it. Its guard is the one that cannot be a truthiness test: false is the value it most needs to admit.
description optional optional absent/null is a genuinely empty description.
join_url, highlighted — / optional optional emitted unconditionally by the entry partial, absent from the reduced calendar partial GetUpcomingSchedule renders through the same schema.

starts_at/ends_at read back as a bare date ("2016-06-01") for an all-day entry and a full timestamp otherwise, because BC3 renders starts_at_date_or_time. ISO8601Timestamp is a plain string in this model — Kotlin/Swift map it to String, Go to date-tolerant FlexibleTime — so both shapes decode. Every composite round-trips the value verbatim rather than parsing and re-rendering it, which would rewrite an all-day entry's bounds. Swift pins this with testUpdateEntry_roundTripsAnAllDayBareDateVerbatim.

Migration

The rename moves the verbatim path to replaceEntry and reuses the updateEntry name for the merge-safe composite. Callers keep the name and get the safe behaviour plus one extra request; callers who genuinely wanted the sparse PUT must move to replaceEntry.

SDK was (sparse PUT) now: verbatim now: merge-safe now: read-modify-write
Go schedules.UpdateEntry(ctx, id, *UpdateScheduleEntryRequest) ReplaceEntry(ctx, id, *ReplaceScheduleEntryRequest) UpdateEntry(ctx, id, *UpdateScheduleEntryRequest) EditEntry(ctx, id, func(*ScheduleEntryFields) error)
Python schedules.update_entry(...) replace_entry(...) update_entry(...) edit_entry(...) (context manager)
Ruby schedules.update_entry(...) replace_entry(...) update_entry(...) edit_entry(...) { |e| ... }
TypeScript schedules.updateEntry(id, req) replaceEntry(id, req) updateEntry(id, req) editEntry(id, (e) => ...)
Kotlin schedules.updateEntry(...) replaceEntry(...) updateEntry(...) editEntry(...) { ... }
Swift schedules.updateEntry(...) replaceEntry(...) updateEntry(...) editEntry(...) { ... }

Wire-level: UpdateScheduleEntry → ReplaceScheduleEntry, no alias. ScheduleEntry gains join_url and highlighted; all_day/starts_at/ends_at become @required. ReplaceScheduleEntryInput gains url and highlighted and marks starts_at/ends_at @required.

Caller-addressedness is language-native, and an explicitly empty value is an address, not an absence: participant_ids: [] clears participants, url: "" clears the join link, highlighted: false removes the highlight. All three survive body compaction.

edit uses setter-invocation dirty tracking, not value comparison

Value comparison cannot express intent, so snapshot/diff is structurally rejected: conformance case edit-touched-carve-outs assigns exactly the values the GET returned and still requires them on the wire.

Per SDK: Python overrides __setattr__; Ruby's writers record into a touched set; TypeScript uses a Proxy; Kotlin uses custom property setters; Swift uses didSet observers, which do not fire during initialization — so seeding leaves every dirty bit clean without a separate seeding-mode flag a later refactor could forget to clear.

Conformance

conformance/tests/schedule_entries_write.json goes from 2 cases to 9, dispatched in all six runners. The two originals are rekeyed and subsumed, not dropped (update-omits-participant-ids → replace-omission-clears, update-empty-participant-ids → replace-clears-carve-outs).

replace-omission-clears · replace-clears-carve-outs · replace-single-request · update-merge · update-addresses-carve-outs · update-clears-carve-outs · edit-clear · edit-untouched-carve-outs · edit-touched-carve-outs

Shared guards

writable_boolean / writableBoolean joins the merge-safe helpers in the three languages that have no runtime decoder on any route (TypeScript's schema.d.ts is erased at build, Python returns dict[str, Any], Ruby raw Hash). Python and TypeScript had each written a byte-equivalent private adapter for the optional highlighted read; Ruby had none at all and seeded the value unguarded, so a wrong-typed highlighted would ride into the PUT if a block assigned it back. One helper, three call sites; the wrong-type branch delegates to required_writable_boolean so an optional boolean and a required one report a non-boolean identically.

Documents' private required_writable_string copies are deleted in favour of the shared helper in all three; the Documents suites pass unchanged.

Go, Kotlin and Swift get most of their guards from their decoders (json.Unmarshal, kotlinx.serialization, Codable) — making the other three structurally safe is #578, deferred past v0.13.0. But "it has a decoder" turned out to be too coarse a rule, in two ways:

  • Go's decoder is silent about a missing key. all_day, starts_at and ends_at are now non-pointer values on the generated model, so an absent or null key decodes to the zero value and encoding/json says nothing — exactly the hole required_writable_boolean fills in the dynamic SDKs. A response missing all_day would have un-all-dayed the entry on the next composite write. Go therefore carries a raw-body presence probe alongside the two-stage gen.GetScheduleEntry + ParseGetScheduleEntryResponse split, following the established todolists.go requireDescription/getWithBody precedent.
  • No decoder catches blankness. Kotlin and Swift add an isBlank() guard on summary, starts_at and ends_at, matching what required_writable_string does in the dynamic three. Swift goes one field wider than its decoder covers: Codable catches absence, null and wrong type but not blankness, so ScheduleEntryFields.init refuses ""/whitespace on summary, starts_at and ends_at, matching required_writable_string.

Red proofs

Every new test was run against deliberately un-fixed source, in a scratch copy of the tree rather than by mutating the tracked file. Counts are literal, from the capture files.

SDK carve-out echoed from read-back all_day via truthiness edit by value comparison guards removed / other
Python 2 failed, 104 passed 9 failed, 97 passed 3 failed, 103 passed summary optional guard: 7 failed, 99 passed
Ruby 7 failures (+3 conformance) 8 failures 3 failures (+1 conformance) summary optional guard: 6 failures
TypeScript 5 failed, 111 passed 9 failed, 107 passed 2 failed, 114 passed guards removed: 78 failed, 38 passed
Swift 4 failures — (decoder) 3 failures non-optional carve-outs: 7 failures
Go 3 tests failed — (decoder) 1 test failed zero-value carve-outs: 3 tests failed
Kotlin 20 tests, 4 failures — (decoder) 20 tests, 2 failures (+1 conformance) non-null carve-out defaults: 20 tests, 3 failures

The sharpest ones:

The API URL landing in the join-link member (Ruby, conformance):

  FAIL: update-merge: a summary-only update preserves the times, description and all-day flag
        Expected request body key "url" absent on request index 1, got "https://3.basecampapi.com/999/buckets/1/schedule_entries/1069479523.json"

The all-day → timed corruption (Ruby probe, mutated vs shipped):

=== probe_all_day.rb against the MUTATED (truthiness) read ===
NO ERROR RAISED — the PUT went out.
PUT bodies: [{"summary" => "Company Offsite 2026", "starts_at" => "2026-06-05", "ends_at" => "2026-06-05", "description" => "<div>All day.</div>", "all_day" => false}]
all_day on the wire: false

=== same probe against the SHIPPED (required_writable_boolean) read ===
REFUSED: Basecamp::ApiError: Schedule entry field "all_day" is required but the response carried NilClass nil
PUT bodies: []
all_day on the wire: (no PUT)

Value comparison silently dropping a deliberate write (Swift) — note this breaks update, not just edit: a caller who sets highlighted: true on an entry the GET already reported as highlighted gets silently dropped.

…:214: error: -[…testUpdateEntry_addressedJoinLinkUsesTheRequestSpelling] : XCTAssertEqual failed: ("nil") is not equal to ("Optional(true)")

Four things this PR found and did not fix

Each is pre-existing, out of scope here, and reported rather than absorbed.

  1. GetUpcomingSchedule cannot decode strictly. It renders the reduced api/schedules/calendar/_entry.json.jbuilder but shares the ScheduleEntry schema, and that partial omits six members already marked @required on main: created_at, updated_at, title, inherits_status, parent, description_attachments. Kotlin's non-null val and Swift's non-optional let would throw on a real upcoming-schedule response. This PR does not widen the hole — the three members it newly marks @required (all_day, starts_at, ends_at) are emitted by both partials.
  2. types.FlexibleTime is asymmetric. It accepts a bare date on decode but MarshalJSON re-renders through time.Time, so "2016-06-01" marshals back as "2016-06-01T00:00:00Z"; BC3 re-parses that in the account's zone, which west of UTC lands on the previous day and moves an all-day entry's bounds. Go's composites sidestep it by sourcing starts_at/ends_at from the raw response bytes, and TestFlexibleTimeMarshalRewritesABareDate pins the asymmetry (skipping itself if FlexibleTime ever learns to preserve source text, so the claim cannot rot). Any other caller that marshals a decoded schedule entry is still exposed.
  3. CreateScheduleEntry cannot express an all-day bare date. CreateScheduleEntryRequestContent.StartsAt is time.Time and the Go wrapper parses RFC3339 into it, so create and replace disagree about what an all-day entry's bounds look like.
  4. The branch was already red in three SDKs before this work, all from the same root cause — the rename deleted symbols that hand-written tests and wrappers still referenced. Go's schedules.go failed to compile, Swift's test target failed to compile (ScheduleEntryParticipantsTests), Ruby's suite errored (NoMethodError: undefined method 'update_entry'), and TypeScript's tsc --noEmit failed on a stale UpdateEntryScheduleRequest export. The conformance runners' UpdateScheduleEntry cases were likewise already broken rather than merely stale. All fixed here.

Recurrence is deliberately out of scope

recurrence_schedule is entirely unmodeled (0 occurrences in spec/basecamp.smithy and openapi.json) and its absorption stays deferred — tracked in spec/api-gaps/schedule-recurrence-writes.md, which this PR only renames the operation inside. time_zone_name and recurs_until likewise: BC3 forces all three to nil for a non-recurring entry, which is the only kind this route serves.

Provenance

Repin 2c0dafba13 → 4dd2926f8af0 (2026-08-03), with the range triage the api-gaps README's pin sentence requires. Six commits; #12502 is the only API-contract change, and the only one touching doc/api (8 lines in schedule_entries.md) or config/routes.rb (none), so spec/bc3-routes.json regenerates with no route delta. The other five are one mail-infrastructure map, two CSS-only, one account-calendar authorization reassessment, and one authentication-cache crash fix.

spec/doc-constants.json: the SPEC.md and folders-api.md unmarked-citation grants are dropped rather than renumbered — both described citations of 2c0dafba13, which is no longer the pin in force, so those sentences are now historical, exactly as their own reasons predicted.

Operation count

241, unchanged. Re-derived from behavior-model.json and cross-checked against openapi.json; never hand-incremented. The triad is a rename plus composites — it changes shape, not inventory.

Verification

make check green end to end, run detached and read back from its own marker file:

TRIAD_FULLCHECK_EXIT_IS 0
==> All checks passed

HEAD pinned either side of the run: PRE_SHA == POST_SHA == b7c3b8db5863a6bb52fb30dd4eb2a5493caaac0f. Zero FAIL/ERROR markers anywhere in the 5,900-line log.

suite result
Go conformance Passed: 177, Failed: 0, Skipped: 2
Kotlin conformance Passed: 178, Failed: 0, Skipped: 1
TypeScript conformance 221 passed, 2 skipped
Swift conformance Passed: 178, Failed: 0, Skipped: 1
Ruby unit 1358 runs, 30434 assertions, 0 failures, 0 errors
TypeScript unit 1393 passed (80 files)
Python unit 1170 passed, 4 skipped
Swift unit Executed 41 tests, with 0 failures (0 unexpected) — genuinely executed on macOS, not the SKIP line

The check: target is now 37 entries: main's 36 plus check-write-semantics-parity, resolved additively so no merged gate lost its wiring.

Kotlin is cited from CI (Kotlin Tests) rather than the local Gradle run, since UP-TO-DATE/FROM-CACHE is not execution evidence.

Copilot AI review requested due to automatic review settings August 4, 2026 01:27
@jeremy jeremy added breaking Breaking change to public API spec Changes to the Smithy spec or OpenAPI go kotlin python Pull requests that update the Python SDK ruby Pull requests that update the Ruby SDK swift typescript Pull requests that update TypeScript code conformance Conformance test suite labels Aug 4, 2026
Comment thread typescript/tests/services/schedules.test.ts Fixed
Comment thread typescript/tests/services/schedules.test.ts Fixed
Comment thread kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/services/SchedulesService.kt Dismissed

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b7c3b8db58

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread typescript/src/client.ts

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Human review recommended

It is a breaking wire-level rename spanning the Smithy spec, generated artifacts, hand-written composites, a new CI parity gate, and conformance across all six SDKs, whose pipeline/gate invariants cannot be validated without running the generation and test toolchain, so it warrants final human review.

Pull request overview

This PR closes the sparse-PUT data-loss defect for schedule entries — the third and final child of the #374 replace-semantics wave. PUT /schedule_entries/{entryId} is a full replace, yet every SDK shipped a sparse update over it that erased the join link, highlight, and participants on any call that omitted them. The wire operation UpdateScheduleEntry is renamed to ReplaceScheduleEntry (breaking, no deprecated alias, following the ReplaceTodo/ReplaceDocument precedent), and a carve-out-aware merge-safe updateEntry/editEntry/replaceEntry triad lands in all six SDKs.

What distinguishes this from the documents/todolists children is that the server contract is not uniform across writable fields: participant_ids, url, and highlighted are preserved on omission (so the composites must not resend them unless caller-addressed), while all_day/starts_at/ends_at must always be resent. This is expressed via a new preservedOnOmission extension to the @basecampWriteSemantics trait, protected by a new bidirectional parity gate.

Changes:

  • Renames UpdateScheduleEntry → ReplaceScheduleEntry; adds join_url/highlighted reads and marks all_day/starts_at/ends_at @required on ScheduleEntry; lands the merge-safe updateEntry/editEntry + verbatim replaceEntry triad across all six SDKs, using setter-invocation dirty tracking (not value comparison) for edit.
  • Adds the preservedOnOmission write-semantics trait field plus scripts/check-write-semantics-parity (wired into make check) and teaches scripts/generate-behavior-model to carry the field through the pipeline.
  • Expands conformance/tests/schedule_entries_write.json from 2 to 9 cases dispatched in all six runners; repins provenance 2c0dafba13 → 4dd2926f8af0 (2026-08-03) with range triage.

[!TIP]
If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.

File summaries
File Description
spec/basecamp.smithy, spec/overlays/*.smithy Rename operation, add preservedOnOmission trait field, model join_url/highlighted, tighten required fields
openapi.json, behavior-model.json Regenerated artifacts reflecting the spec change and the new write-semantics clause
scripts/check-write-semantics-parity New bidirectional gate asserting OpenAPI extension ↔ behavior-model parity
scripts/generate-behavior-model Carries preservedOnOmission through the jq write clause
go/pkg/basecamp/schedules.go ReplaceEntry/UpdateEntry/EditEntry + ScheduleEntryFields with raw-body presence probe for zero-value carve-outs
typescript/src/services/schedules-extensions.ts Merge-safe composites with Proxy/hasOwnProperty dirty tracking; seeds url from join_url
kotlin/.../services/SchedulesService.kt updateEntry/editEntry with custom-setter dirty tracking over ReplaceScheduleEntryBody
swift/Sources/Basecamp/SchedulesServiceExtensions.swift didSet-based dirty tracking; isBlank() guards on summary/starts_at/ends_at
ruby/.../services/schedules_extensions.rb, python/.../services/schedules.py Merge-safe composites with writable_boolean/__setattr__ dirty tracking
*/services/merge-safe.* Shared writableBoolean/writable_boolean guard added; Documents' private required_writable_string copies removed in favor of the shared helper
conformance/tests/schedule_entries_write.json + 6 runners 9 conformance cases dispatched across Go/TS/Ruby/Swift/Kotlin/Python
spec/api-provenance.json, spec/doc-constants.json, spec/api-gaps/*, docs Provenance repin, dropped stale unmarked-pin grants, range triage
Review details
  • Files reviewed: 62/83 changed files
  • Comments generated: 0
  • Review effort level: Balanced

We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.

Copilot AI review requested due to automatic review settings August 4, 2026 01:40
jeremy added 8 commits August 3, 2026 18:44
…rve-outs

PUT /{accountId}/schedule_entries/{entryId} is a full replace: BC3 builds a
brand-new Schedule::Entry from the permitted params and swaps the recordable
wholesale, so a writable field the request omits is cleared. The operation is
renamed to say so, breaking and without a deprecated alias — the ReplaceTodo
and ReplaceDocument precedent. An alias would keep the destructive method
reachable under the name that misdescribes it, which is the defect the rename
exists to remove.

Three fields are carved out of that swap server-side, and @basecampWriteSemantics
gains preservedOnOmission to declare them. bc3 #12502 added
PRESERVED_ON_OMISSION = %i[ url highlighted ] alongside the existing
update_participants? guard, and started emitting both — highlighted, and the
join link as join_url, under a key that does not collide with the recording's
own API url.

scripts/generate-behavior-model learns to carry preservedOnOmission through,
because that plumbing is not automatic: the jq program builds its write clause
key by key, so a trait field nobody teaches it about is dropped silently and
every SDK reads the behavior model rather than the OpenAPI extension.
scripts/check-write-semantics-parity compares the two artifacts in both
directions so neither can lead the other.

The five service generators and the Go client template take the method-name
override, so the raw single-PUT path is replaceEntry and the plain updateEntry
name is free for the merge-safe composite that follows.

ScheduleEntry gains join_url and highlighted, and marks all_day, starts_at and
ends_at @required — all three are emitted by both the entry partial and the
reduced calendar partial, so this does not narrow what GetUpcomingSchedule can
decode. ReplaceScheduleEntryInput gains url and highlighted and marks
starts_at/ends_at @required, which Schedule::Entry presence-validates.

conformance/tests/schedule_entries_write.json grows from 2 cases to 9. The two
originals are rekeyed and subsumed rather than dropped:
update-omits-participant-ids becomes replace-omission-clears, and
update-empty-participant-ids becomes replace-clears-carve-outs.
make generate settled the two metadata artifacts hand-resolved to main's side
during the rebase; every other generated tree auto-merged correctly, which the
regeneration confirms rather than assumes. Generation timestamps restored to
main's values (the drift gates normalize them).
SPEC section 18 gains the bound that keeps a carve-out from turning a Replace*
into a merge: the preserved set must be limited to fields a client could not
safely resend from a read-back — write-only, system-managed, or
identity-colliding. ReplaceScheduleEntry is the worked example, with the
per-field justification. The stale 'two shipped operations still named Update*'
paragraph drops to one, since this PR renames the other.

SPEC section 5 gains the Schedule Entries merge-safe surface: the
replaced-vs-carved-out split, why the composites must NOT resend the three
carve-outs, and the read-side requiredness findings.

Two spec doc comments record facts verified against bc3 at the pinned revision:
all_day is permitted but NOT carved out, so omitting it converts an all-day
entry into a midnight-to-midnight timed one (which is why the composites resend
it); and starts_at/ends_at read back as a bare date for an all-day entry,
because BC3 renders starts_at_date_or_time. ISO8601Timestamp is a plain string
in this model, so both shapes decode and the value must be round-tripped
verbatim rather than parsed and re-rendered.
The repin to 4dd2926f8a is the whole reason this PR exists, so the range gets
the triage the api-gaps README's pin sentence promises. BC3 #12502 is the only
API-contract change in it: the PRESERVED_ON_OMISSION carve-out plus the
emission of highlighted and join_url. It is also the only commit touching
doc/api (8 lines in schedule_entries.md) or config/routes.rb (none), so
spec/bc3-routes.json regenerates with no route delta. The other five are one
mail-infrastructure map, two CSS-only, one account-calendar authorization
reassessment, and one authentication-cache crash fix.

The previous range's paragraph moves down into the historical record rather
than being rewritten, per the file's own rule that a triage is a past-tense
claim about the repin that set its end.

doc-constants.json: the SPEC.md and folders-api.md unmarked-citation grants are
dropped rather than renumbered. Both described citations of 2c0dafba13, which
is no longer the current pin — the sentences are now historical, which is
exactly what their reasons predicted, and the gate counts only citations of the
pin in force. The README grant stays at 2 with the new range's two citations.
…nd Swift

Each SDK gains updateEntry (merge-safe) and editEntry (read-modify-write) over
the renamed raw replaceEntry. The five readable-and-writable fields are resent
from the read-back; participant_ids, url and highlighted go on the wire only
when the caller addressed them, because BC3 preserves them server-side and the
response spells the join link join_url — echoing the response's url would write
the entry's own API URL into the join link.

edit uses setter-invocation dirty tracking, not value comparison: assigning the
value the read already returned still sends it. Python overrides __setattr__,
Ruby's writers record into a touched set, TypeScript uses a Proxy, and Swift
uses didSet observers that do not fire during init, so seeding leaves every bit
clean without a separate seeding-mode flag.

A shared writable_boolean / writableBoolean joins the merge-safe guards. Python
and TypeScript had each written a byte-equivalent private adapter for the
optional highlighted read, and Ruby had none at all — it seeded the value
unguarded, so a wrong-typed highlighted would ride into the PUT if a block
assigned it back. One helper, three call sites, and the wrong-type branch
delegates to required_writable_boolean so an optional boolean and a required
one report a non-boolean identically.

The canonical spec/fixtures/schedules/entry_get.json gains join_url and
highlighted. check-fixture-coverage passed without them, but no lane's canonical
fixture exercised either new response member.

Documents' private required_writable_string copies are deleted in favour of the
shared helper in all three guard languages; the Documents suites pass unchanged.
Go's schedules.go is a hand-written wrapper and did not compile after the
rename: it still called UpdateScheduleEntryWithBodyWithResponse and deref-ed the
three response fields that became @required. UpdateEntry is repaired and renamed
ReplaceEntry, and the merge-safe UpdateEntry plus EditEntry land beside it.

UpdateScheduleEntryRequest takes pointers throughout rather than inheriting the
zero-value guards Documents settled for. The carve-outs make that mandatory:
highlighted=false, url="" and participant_ids=[] are all addresses the caller
must be able to express, and all three are zero values.

The composite decodes the GET into map[string]json.RawMessage and reads
starts_at/ends_at as raw strings rather than through the model's
types.FlexibleTime. FlexibleTime tolerates a bare date on the way in but
MarshalJSON re-renders through time.Time, so round-tripping an all-day entry
through the typed field would rewrite "2026-06-01" into a midnight timestamp
and shift the entry's bounds.

Kotlin seeds the carve-outs through custom property setters that record into a
touched set, and keeps every optional array as List<T>? = null so the
kt-check-optional-arrays-and-scalars gate's null/absent distinction holds.
…lass

The Kotlin composite lands as a hand-written subclass in
com.basecamp.sdk.services, which needs the generated class to be extensible and
the accessor to construct the subclass. Both are generator config, not
hand-edits: EXTENSIBLE_SERVICES drives ServiceEmitter's `open class`, and
HAND_WRITTEN_SERVICES makes ClientAccessorEmitter declare and construct
com.basecamp.sdk.services.SchedulesService so the composite methods are visible
on client.schedules with no caller import.

Registering it also means the generated ServiceAccessors.kt now reproduces the
Kotlin lane's edit byte-for-byte, so kt-check-drift has nothing to flag.
…he carve-out tests

AGENTS.md's hand-written-service inventory is load-bearing, not descriptive:
the sentence under it says service files in typescript/src/services/ and
ruby/lib/basecamp/services/ beyond the table are NOT loaded at runtime and
exist only as reference implementations. Six new schedules extension files were
missing from it, so the governing instructions described them as forbidden
reference code. Caught by review.

The two edit carve-out tests wrote `e.highlighted = e.highlighted`, which CodeQL
flags as a self assignment — correctly, on the syntax. The behaviour under test
is that only the *setter* marks a carve-out dirty, so assigning back the seeded
value is a deliberate write that must reach the wire. Destructuring the value
first says that, keeps the semantics identical, and stops the shape reading as
an accident. 116 TypeScript schedules tests still pass.
@jeremy
jeremy force-pushed the feat/schedule-entries-triad branch from 16fc80d to ab92ad0 Compare August 4, 2026 01:44

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Human review recommended

It is a breaking, no-alias wire rename spanning all six SDKs whose correctness depends on server-contract claims (preserved-on-omission fields, calendar-partial field emission) and a provenance repin that warrant final human verification.

Review details

Suppressed comments (1)

ruby/lib/basecamp/services/schedules_extensions.rb:276

  • This docstring says highlighted has "nothing to refuse a malformed value on behalf of," but fields_from_entry reads it through MergeSafe.writable_boolean (line 289), which does refuse a wrong-typed value (it delegates any non-nil value to required_writable_boolean). The rationale here is also the opposite of the one in writable_boolean's own docstring (merge_safe.rb:186-190): because a block can assign the seeded value straight back, a wrong-typed seed must be refused rather than coerced. As written, this comment contradicts the code and could mislead a maintainer into dropping the guard that this PR deliberately added for Ruby.
  • Files reviewed: 63/84 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.

Copilot AI review requested due to automatic review settings August 4, 2026 01:48

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Human review recommended

It is a breaking wire-operation rename with no deprecated alias across all six SDKs, combined with new @required response fields and a provenance repin, which warrants final human verification despite passing make check.

Review details
  • Files reviewed: 63/84 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.

@jeremy
jeremy merged commit 017a790 into main Aug 4, 2026
47 checks passed
@jeremy
jeremy deleted the feat/schedule-entries-triad branch August 4, 2026 02:02
jeremy added a commit that referenced this pull request Aug 4, 2026
v0.13.0 pointerized the optional fields across the Go surface (#560, #615,
#632) and shipped no way to build a pointer. `ptr[T any]` has been sitting
unexported in helpers.go the whole time, and go/README.md said nothing about
pointer fields, so every consumer hitting the migration writes their own
generic helper first.

This SDK's own test suite is the proof: schedules_test.go and
test_helpers_test.go hand-rolled strPtr, boolPtr, idsPtr and intPtr rather
than reach for the unexported one.

One generic Ptr rather than AWS-style typed constructors. The optional fields
span *string, *bool, *int, *int32, *int64, *time.Time and *[]int64 — a typed
set would need six names and still not cover ParticipantIDs *[]int64, where
a pointer to an empty slice is what removes every participant.

Deref covers the read direction, which is the more dangerous half. Go
auto-dereferences a value-receiver method call, so hc.UpdatedAt.IsZero()
still compiles against *time.Time and panics at run time on a chart that has
never moved. Deref is total: the zero value on nil.

The unexported ptr and deref stay as the internal vocabulary at hundreds of
conversion sites, but now forward to the exported pair, so the contract
callers get cannot drift from the one this package relies on.

Additive only: no existing exported signature changes.
jeremy added a commit that referenced this pull request Aug 4, 2026
Rebased onto 2afc977 and re-measured rather than incremented. Eight PRs merged
since the branch was last updated, not the seven that carried the breaking
label: #647 was on the "Not in this release" list and had landed.

Counts. 55 class A and 6 class B, 61 surviving a clean build, up from 47/4/51.
Per SDK the class split is Go 12/4, Swift 10/0, TypeScript 9/0, Python 8/0,
Ruby 10/1, Kotlin 6/1, and the breaking-change column moves to 33/22/18/16/20/17.
The body parses back to those numbers rather than agreeing with them by hand.
The root README's aggregate sentence is re-derived to match, and now states both
halves numerically instead of "most" and "a few". The operation inventory is
unchanged at 238 -> 247 with the same 14 added, 5 removed and 11 same-ID route
moves, computed from openapi.json at both ends. check-targets is 43, and the
derivation is inline where the gate count was previously only projected. The
release spans 67 merged PRs, 15 labelled breaking; the gh commands that produce
both are embedded in the as-of block, with the note that a labelled PR is not
the same unit as an entry, which is why the per-SDK columns exceed 15.

#658 is class B, not class A. It does to five wrapper timestamps exactly what
#615 did to five others: QuestionReminder.RemindAt, ClientApprovalResponse's
CreatedAt and UpdatedAt, TimelineEvent.CreatedAt and WebhookDelivery.CreatedAt
compile untouched through a value-receiver call and panic on nil. #615's own
check could not see them because it keyed on the omitempty tag and these five
did not carry one. The audit is ten fields, and the entry names the near-miss
siblings that did not move, ClientApproval's pair in particular.

#664 splits. The public CreateScheduleEntryRequest fields were already string
and still are, so the wrapper half is silent: the RFC3339 ErrUsage guard is gone,
a bare date now creates an all-day entry, and a malformed value reaches bc3
instead of failing locally. That is class A. The generated
CreateScheduleEntryRequestContent went time.Time to string, which is a compile
error for pkg/generated importers. ReplaceScheduleEntryRequestContent is not a
migration from v0.12.0 at all; #632 introduced it. TypeScript and Ruby are
doc-comment only.

#647 is folded in as merged, with two corrections to what was written when it
was still a branch. It touches no schema, so the claim that it had to go
Smithy-first is withdrawn; UpdateCardStepRequestContent.DueOn was pointerized by
#560. And the v0.12.0 preservation GET was conditional, taken only when the
caller left due_on unaddressed, so the request-count table is scoped to that
path rather than presented as universal.

#648 adds no silent break anywhere. bc3's body is byte-identical before and
after, so nothing that was populated stops being so; the assignable's title was
never sent and is now spelled content. Every rename and retype is caught
statically in Go, Swift, TypeScript and Kotlin and raised immediately in Python
and Ruby, so it is one compile-or-runtime entry per SDK.

Two corrections nobody asked for. The Go class list opened "Go carries every
class-B break in the release", which stopped being true when Ruby's decode
entry moved into class B; it now claims only the panic-shaped ones. And
todos_write.json carries three errorRaised cases, not two, because #660 added a
bare-scalar kill.

#660 is a Kotlin class-B entry, which is new. Removing the client-wide isLenient
means a present, populated, wrong-typed scalar throws SerializationException
where it used to coerce to a string, and no signature moved to announce it. It
throws in the response decode, so on a write the mutation has already landed,
and it is not a BasecampException outside todolists.

#656 is Ruby class A, scoped tightly: only max_retries 0, only an ungoverned GET,
which means get_absolute and the Launchpad fetch rather than any operation
lacking a policy. Every other configuration is bit-identical.

Not in this release is now empty, and says so.
jeremy added a commit that referenced this pull request Aug 4, 2026
* MIGRATING.md: the v0.13.0 upgrade guide, silent breaks first

v0.13.0 breaks all six SDKs and 35 of those breaks are silent — no compile
error, no exception, no decoder failure. Label-generated release notes list
what merged; they cannot say what a consumer must react to or what wrong
behaviour they get if they ignore it. That had no home in this repo.

Adds MIGRATING.md at the root, linked from the root README and all six
per-SDK READMEs. Silent breaks lead the document, then one section per SDK
ordered by severity, plus an operator checklist, a "coverage: corrected and
re-scoped" section for what did not ship, and known gaps.

No CHANGELOG is reintroduced. The hand-maintained ones were deleted in #115
as superseded by auto-generated notes, and every release body since is
machine-built. CONTRIBUTING records the resulting rule: label-generated notes
say what merged, MIGRATING says what to do about it.

Corrections to the source drafts, each re-derived rather than repeated:

- TrashTodo was not a 404. bc3 draws `resources :todos, only: %i[show edit
  update destroy]`; DELETE /todos/:id returned 204 and set status to
  "archived", so every caller was archiving. It is the one #619 removal that
  takes away a working call, and it now carries its own carve-out.
- #619 removed three operations, not nine. Nine were re-pathed. Fusing the
  two sets is what made the blanket 404 reassurance look safe.
- Hook operation identity differs by SDK: Go and Ruby emit a short verb,
  the other four emit the wire operation ID, where the todolist pair kept
  its names — so an allowlist holding UpdateTodolistOrGroup passes the write
  and denies the new read.
- 238 -> 241 measured at the v0.12.0 tag and at c95d81c, not assumed.
- Kotlin binary compatibility is already disclaimed in kotlin/README.md;
  Swift has no written policy. Both are now stated rather than left unsaid.

recordings.get is documented as a known gap with a list-and-filter recipe
and its honest cost. The Go recipe compiles against this tree.

#637, #629 and #635/#641 were open at the time of writing and are recorded
under "Not in this release" rather than described as shipped.

* Fix the Go pagination advice, cut the raw-wire workaround, absorb #637/#643

Addresses both P1 review threads on #642 and folds in the two PRs that landed
since the first draft.

Pagination (P1). Cross-SDK item 1 claimed `page` was a starting offset in every
SDK and told readers to drop it to restore the old walk. For Go that was
actively harmful: `git show v0.12.0:go/pkg/basecamp/bookmarks.go` returns before
followPagination whenever page > 0, so a positive Page already meant one
request, and dropping it converts a bounded call into a full account-wide
traversal. The item is now scoped to the five SDKs where it holds — re-checked
at the tag rather than assumed, since the universal claim had already failed
once — with a Go subsection splitting the two real cases: services where the
page number was already honored (Bookmarks, Drafts, Everything*, request
unchanged) and the fourteen carrying the "not yet honored" doc, which sent no
page at all and returned page 1's rows. Gauges is in neither; it had no page.

Raw wire (P1). The Forwards().CreateReply example built a path with fmt.Sprintf
and called the raw AccountClient.Post against a route with no upstream
coverage, which is what AGENTS.md "Never Do These" 4 and 5 forbid. Removed
rather than softened, and replaced with a known-gap section stating what a
hand-built path gives up. Swept the document: the one other hit documents a real
change to the raw client's error codes, so it stays, but its fabricated path is
gone and it now says it is not a suggestion to reach for the escape hatch.

#643 landed, so basecamp.Ptr and basecamp.Deref replace the hand-rolled ptr
helper throughout, the Go section opens with the 300-pointer census and a
command that reproduces it, and ParticipantIDs *[]int64 gets its own note: nil
leaves participants alone, a pointer to an empty slice removes every one.

#637 landed and does NOT add a break to any SDK. color and comments_app_url did
not exist on Todolist at v0.12.0 in any of the six — both arrived with #628
earlier in this same release — so from the guide's baseline nothing turned from
optional to required. Counts stay 27/20/16/14/16/14. Documented where it bites:
color is required-and-nullable so explicit null decodes, comments_app_url
rejects null and absence alike.

Also: kotlin/README's append-only source-compat promise contradicted this
release repeatedly, so it now describes documented pre-1.0 breaking correctness
releases; the binary-compat disclaimer is kept and sharpened. release-github.yml
links MIGRATING.md from every release body, guarded on the file, so the link
cannot be forgotten at tag time. "Silent" is defined as source/runtime-silent
against a live server, since a suite pinning request paths does catch some.

Counts are stated as-of 51d0d86 with derivations inline, and each in-flight
change names the numbers it invalidates so the pre-tag pass is arithmetic.

* Split silent breaks into no-signal and fails-at-runtime; absorb #629 and cards

Addresses the remaining P2 and a suppressed Copilot comment on #642, re-derives
every count against main, and writes the cards due-date change.

The P2 was right, and it was a contradiction with this guide's own definition
rather than loose wording: "silent" was defined as "does not raise" and then
used to file nil-pointer panics. The section is now "Breaks your compiler will
not catch" — the property all of it actually shares — split into class A, no
signal at all, and class B, compiles then panics or raises but only when a
particular field is absent, so it passes every test where that field is
populated. Applying the definition consistently moved four entries, not the
three flagged: the three Go pointerization panics plus Ruby's
Draft#scheduled_posting_at decode, which raises NoMethodError and TypeError and
had the same defect. Two moved entries carry real no-signal residue, kept as
sub-notes rather than double-counted. Per SDK: Go 8A/3B, Swift 9A, TypeScript
5A, Python 4A, Ruby 2A/1B, Kotlin 3A — 31 + 4 = 35, unchanged in total. Body
counts verified against the table by parsing the section, not by eye.

The Swift section claimed three new optional Todolist members and named one;
the other two are required. Now singular, matching TypeScript.

Counts re-derived at 9de44b2: the inventory is 238 -> 247, not 241, since
#629 merged. Added, removed and route-moved lists are computed from openapi.json
at both ends rather than hand-edited — 14 IDs added, 5 removed, 11 same-ID moves
— and the Folders operations are flagged as drawn at /stacks, not /folders.

Cards get their own section. The half that matters most is true in production
today and is not caused by upgrading: every released SDK encodes "clear a card
due date" as omission, bc3 stopped treating omission as a clear, so that call is
a silent no-op right now. That is a reason to upgrade rather than a hazard of
it, so it sits in the operator checklist. The SDK-side change is read from
bf43715 and marked unmerged: single PUT, "due_on": "" as the clear encoding,
UpdateStepRequest.DueOn becomes *string, and the GetCard preservation read goes
away. The hook collapse is written as the inverse of the {Todolists,Update}
split because it fails the opposite way — allowlists do not start denying, but a
denylist on {Cards,Get} silently stops blocking the write it used to take down.
Removing the preservation GET also removes three named errorRaised kill cases
from cards_write.json; the class stays pinned on Todos, which still does a real
read-modify-write, so that is said rather than filed as a redundant-GET cleanup.

* Audit class A across all six SDKs; add Ruby's missing download retry

Fourth review round on #642. Four findings, all upheld.

The allowlist framing was wrong in the direction that matters. I wrote that
fewer hook events are safe for an allowlist. True only if the allowlist named
both operations: one that names UpdateCard and deliberately omits GetCard used
to reject cards.update at its read, and after the collapse permits it end to
end. Both policy shapes now carry the warning, labelled, plus the observation
that they are the same hole seen twice — in each, the thing stopping the write
was the read, expressed once as an omission and once as an entry.

The class-A counting was inconsistent across all six SDKs, not the two flagged.
Python and Kotlin excluded changes their own prose called "no signal
whatsoever"; auditing every SDK against the definition moved the totals to 47
class A and 4 class B. The counting policy is now stated in the document so it
can be checked against a rule rather than an impression: one entry per distinct
change per SDK, counted where it bites; class A if any ordinary call-site shape
stays silent even when another is compile-caught; second faces annotated as
residue and counted once; raises-only-on-malformed-response is class B.

Two things fell out that were not counting problems. Ruby's #563 was missing
from the guide entirely — no mention of download_url anywhere in the chapter —
verified against source rather than prose: v0.12.0 http.get_no_retry, which
sent Accept: application/json and did not retry, became get_download calling
request_with_retry with retry_on: DOWNLOAD_RETRY_ON and accept: nil. Ruby now
has its own section. The same check confirmed Go's omission of #563 is correct,
because Go already retried at v0.12.0. Separately, the Go note claiming the
compiler catches only the pkg/generated half of Schedules().UpdateEntry was
false: UpdateScheduleEntryRequest's fields became pointers, so any pkg/basecamp
call site that set a field fails to build.

The class-B definition described only half its own membership. It said the
trigger is an absent field, but Ruby's entry fires only when the field is
populated. It now says both, and says plainly that class B is a property of a
call plus a response rather than of the call — the same method against the
other shape is not a break at all. Class A has no such dependency.

Stale counts in the chapter intros are fixed. The Go intro still said eleven
silent and two panics, which is the first thing a #go link shows, and Swift
claimed the most no-signal breaks, which stopped being true at Go ten.

Also folds in #652 (projected-example gate, stacked on #648, takes check-targets
to 43), moves #648 out of draft at cb438ce, and records that #647 is being
reworked Smithy-first because the generated UpdateCardStepRequestContent.DueOn
is *types.Date and cannot express "". The consumer-facing card shape is
unaffected by that rework. Re-derived against #648: 238 -> 247 with 14 added,
5 removed and 11 same-ID route moves survives unchanged.

* Correct four claims in the v0.13.0 guide that do not match the source

The opening warning said the runtime failures need a payload where a field is
absent. That holds for the three Go entries; Ruby's single class-B entry has the
opposite trigger. Draft#scheduled_posting_at and MyNote#created_at/#updated_at
run through parse_datetime, which returns nil for nil and a Time otherwise, so
.start_with? and Time.parse raise only when the field is populated. A reader
following the old text builds the wrong fixture and concludes they are
unaffected. Both directions are now named, here and in the root README.

Class A was described as breaking on every response. Most of it does, but two
groups do not: the error-message and validation entries need an error status to
reach the code at all, and the field-map half needs a body of a particular
shape; downloadURL's hop-1 retry changes nothing until a network error or one of
429/502/503/504 occurs. Stated as preconditions rather than as a blanket claim.

The Go pointer example said only the field selector panics. types.Date.String
has a value receiver, so Go rewrites t.DueOn.String() to (*t.DueOn).String() and
the nil dereference panics before String is entered. The same holds for IsZero,
Before, After and Weekday on Date and for Format, Sub, Unix and Year on
time.Time. The summary bullet already said both panic; the example contradicted
it.

The Accept-header note credited only Python. Ruby dropped it on the same hop:
get_download passes accept: nil, and request_headers sets the header only when
accept is truthy. Both are named, with the observation that the other four never
sent it on that hop at v0.12.0 either.

No counts are touched.

* Re-derive every count against the final release commit

Rebased onto 2afc977 and re-measured rather than incremented. Eight PRs merged
since the branch was last updated, not the seven that carried the breaking
label: #647 was on the "Not in this release" list and had landed.

Counts. 55 class A and 6 class B, 61 surviving a clean build, up from 47/4/51.
Per SDK the class split is Go 12/4, Swift 10/0, TypeScript 9/0, Python 8/0,
Ruby 10/1, Kotlin 6/1, and the breaking-change column moves to 33/22/18/16/20/17.
The body parses back to those numbers rather than agreeing with them by hand.
The root README's aggregate sentence is re-derived to match, and now states both
halves numerically instead of "most" and "a few". The operation inventory is
unchanged at 238 -> 247 with the same 14 added, 5 removed and 11 same-ID route
moves, computed from openapi.json at both ends. check-targets is 43, and the
derivation is inline where the gate count was previously only projected. The
release spans 67 merged PRs, 15 labelled breaking; the gh commands that produce
both are embedded in the as-of block, with the note that a labelled PR is not
the same unit as an entry, which is why the per-SDK columns exceed 15.

#658 is class B, not class A. It does to five wrapper timestamps exactly what
#615 did to five others: QuestionReminder.RemindAt, ClientApprovalResponse's
CreatedAt and UpdatedAt, TimelineEvent.CreatedAt and WebhookDelivery.CreatedAt
compile untouched through a value-receiver call and panic on nil. #615's own
check could not see them because it keyed on the omitempty tag and these five
did not carry one. The audit is ten fields, and the entry names the near-miss
siblings that did not move, ClientApproval's pair in particular.

#664 splits. The public CreateScheduleEntryRequest fields were already string
and still are, so the wrapper half is silent: the RFC3339 ErrUsage guard is gone,
a bare date now creates an all-day entry, and a malformed value reaches bc3
instead of failing locally. That is class A. The generated
CreateScheduleEntryRequestContent went time.Time to string, which is a compile
error for pkg/generated importers. ReplaceScheduleEntryRequestContent is not a
migration from v0.12.0 at all; #632 introduced it. TypeScript and Ruby are
doc-comment only.

#647 is folded in as merged, with two corrections to what was written when it
was still a branch. It touches no schema, so the claim that it had to go
Smithy-first is withdrawn; UpdateCardStepRequestContent.DueOn was pointerized by
#560. And the v0.12.0 preservation GET was conditional, taken only when the
caller left due_on unaddressed, so the request-count table is scoped to that
path rather than presented as universal.

#648 adds no silent break anywhere. bc3's body is byte-identical before and
after, so nothing that was populated stops being so; the assignable's title was
never sent and is now spelled content. Every rename and retype is caught
statically in Go, Swift, TypeScript and Kotlin and raised immediately in Python
and Ruby, so it is one compile-or-runtime entry per SDK.

Two corrections nobody asked for. The Go class list opened "Go carries every
class-B break in the release", which stopped being true when Ruby's decode
entry moved into class B; it now claims only the panic-shaped ones. And
todos_write.json carries three errorRaised cases, not two, because #660 added a
bare-scalar kill.

#660 is a Kotlin class-B entry, which is new. Removing the client-wide isLenient
means a present, populated, wrong-typed scalar throws SerializationException
where it used to coerce to a string, and no signature moved to announce it. It
throws in the response decode, so on a write the mutation has already landed,
and it is not a BasecampException outside todolists.

#656 is Ruby class A, scoped tightly: only max_retries 0, only an ungoverned GET,
which means get_absolute and the Launchpad fetch rather than any operation
lacking a policy. Every other configuration is bit-identical.

Not in this release is now empty, and says so.

* State the schedule-entry clear value per field instead of universally

The Swift Behavioural bullet said an explicit "" clears any of the five
full-state fields. Only description does. "" on summary is accepted and
reads back "Untitled"; starts_at and ends_at are under
validates_presence_of in Schedule::Entry, so "" is rejected rather than
cleared; allDay is a boolean in every SDK, so "" does not typecheck at
all. The carve-out half grouped notify with the three clearable fields
even though it is a send directive with no state to clear.

* Re-derive the per-SDK README banners against the final class A/B table

The six SDK README banners still carried the counts from before the Go
reclassification and the recount that followed it, summing to 51 where
MIGRATING.md and the root README say 61. Each banner now matches its row
in the class A/B table: Go 12+4, Swift 10, TypeScript 9, Python 8, Ruby
10+1, Kotlin 6+1. Kotlin also gains the runtime clause it was missing,
since its one class B entry throws on a present field carrying a JSON
number or boolean where the model declares a string.

* Correct the merged-PR count and the two claims the reviewers caught

The release spans 55 merged pull requests, not 67. The 67 came from comparing
GitHub's Z-formatted mergedAt against a git timestamp formatted with a local
offset, using jq's string >, which is lexicographic rather than temporal; it
wrongly swept in twelve PRs merged in the hours before the v0.12.0 tag instant.
The derivation embedded in the guide taught that same broken comparison, so it
now uses %ct and fromdateiso8601 and says why. The breaking count of fifteen is
unchanged, since all fifteen merged after the tag, so the class A/B split, the
per-SDK tables and the six README banners are untouched.

The header no longer calls 2afc977 the commit the release is cut from. That
commit is the last of the release content and the baseline the counts were
measured against, but it predates this guide; the tag is cut from main after
this merges, on a tree that contains the file the release body links to.

The release-body teaser claimed the guide covers only breaks with no exception
and no decoder failure. The guide documents six breaks that do fail at runtime,
including Ruby and Kotlin raises and a Kotlin decoder failure, so the teaser now
names both the silent class and the runtime one.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

breaking Breaking change to public API conformance Conformance test suite go kotlin python Pull requests that update the Python SDK ruby Pull requests that update the Ruby SDK spec Changes to the Smithy spec or OpenAPI swift typescript Pull requests that update TypeScript code

Projects

None yet

3 participants