Skip to content

feat(subtasks): add the Subtasks service and repin to bc3 5daa0911d3 - #883

Merged
robzolkos merged 6 commits into
mainfrom
feat/subtasks
Sep 25, 2026
Merged

robzolkos merged 6 commits into
mainfrom
feat/subtasks

Conversation

@zachasme

@zachasme zachasme commented Sep 15, 2026 •

Copy link
Copy Markdown
Contributor

bc3 #12659 documented subtasks as a first-class resource with flat canonical routes, shaped after comments: a to-do or card advertises subtasks_count, subtasks_completed_count and subtasks_url, and the index paginates. This models it as a Subtasks service in all seven SDKs — list, get, create, update, complete, uncomplete, reposition, delete — reusing the CardStep structure, because the wire shape did not change: type stays "Kanban::Step", only the routes and the parent's accounting are new. Todo, Card and the generic Recording carry the three accounting members. CardSteps stays as it was; bc3 keeps the card-scoped /steps spellings served indefinitely as aliases of the same records.

Absorbing documented routes means moving the provenance pin, so this advances it from c680233ba0e to 5daa0911d33 and triages the range in spec/api-gaps/README.md. Client users had already been absorbed ahead of the repin. The three other documented additions — the unscoped recording show (#10158), backlinks (#13121) and bulk enrollment (#9962) — are registered as addressed-in-bc3-pr briefs for follow-up absorption rather than modelled here, with matching dispositions in the route allowlist, and the client-route waivers the repin makes redundant are deleted.

The breaking label is for one behaviour change, in Go: CardStepsService.Reposition now rejects 0. bc3's card_table_steps.md called the reposition position "Zero indexed" until #12659 fixed it to 1-based; the server always counted from 1, so a caller sending 0 was outside the documented range all along, and the member doc and Go's lower bound now say so (MIGRATING has the note). A second correction rode along but changes nothing at runtime: PUT /my/unreads refuses batches over 500 readables with a 422, so MarkAsRead declares ValidationError and a @length(max: 500). No generator enforces the length on the client, so callers get the same 422 from the server they always did; that part is documentation.

Copilot AI balanced review requested due to automatic review settings September 15, 2026 08:24
@zachasme zachasme added enhancement New feature or request claude-code-assisted breaking Breaking change to public API labels Sep 15, 2026
@github-actions github-actions Bot added typescript Pull requests that update TypeScript code ruby Pull requests that update the Ruby SDK go kotlin swift spec Changes to the Smithy spec or OpenAPI python Pull requests that update the Python SDK rust Rust SDK labels Sep 15, 2026
@zachasme

Copy link
Copy Markdown
Contributor Author

Verification notes, for the reviewer rather than the release notes.

make -k check-targets ran locally with every gate green except two the machine cannot run: swift-check (the Swift package uses CoreFoundation and builds only on macOS; the generator itself ran in a swift:6.1 container and its output is what is committed, so swift-check-drift in CI is the real verification) and rs-deny (no cargo-deny installed). Kotlin built and tested through Gradle, the conformance runners replayed 215 cases, TypeScript 1750 tests, Ruby 1537 runs, Go, Python and Rust suites all pass, and route parity reports 270 SDK routes against 398 bc3 routes at the new pin.

Design notes. The shape is deliberately still CardStep: renaming it Subtask across seven SDKs is a breaking rename with no wire change behind it, so it is a separate decision. The Go wrapper's Update hand-marshals its body the way CardStepsService.Update does, because "due_on": "" is the only spelling of a clear the generated *types.Date cannot express (SPEC §18 rule 1). DefaultSubtaskLimit is 100 to match the 100 a parent embeds under steps, so a default listing never returns less than the parent already showed.

The range triage counted eleven non-merge commits touching doc/api or app/views/api; the table grew by exactly the sixteen routes the entries above account for. Circle creation (#9949) gained API views but no documentation, so it is recorded in the triage and not registered — a documented contract is the trigger.

@zachasme

Copy link
Copy Markdown
Contributor Author

Two red checks are inherited from main, not this branch: cargo deny (Rust SDK) and the Rust stable (1.98.1) job both stop at the cargo-deny stage on RUSTSEC-2026-0285 (a rustls TLS 1.3 advisory in a transitive dependency). main at f2f900f fails the same two, and this branch touches no Rust dependencies; the Rust fmt, lint, tests and docs stages pass before deny runs. A dependency bump clears both, and belongs in its own PR.

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.

🟡 Changes recommended

Unresolved critical Go position-overflow and moderate Rust due-date-clearing findings remain.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Adds first-class Subtasks services across the seven SDKs and repins the API contract to BC3 revision 5daa0911d3.

Changes:

  • Adds Subtasks operations, routes, models, pagination, client wiring, and tests.
  • Adds subtask accounting fields to parent resources.
  • Updates repositioning, unread limits, provenance, fixtures, and migration documentation.
File summaries
File Review scope
typescript/tests/services/subtasks.test.ts Subtasks service tests
typescript/src/index.ts Public exports
typescript/src/generated/services/subtasks.ts Generated Subtasks service
typescript/src/generated/services/my-notifications.ts Notification service updates
typescript/src/generated/services/index.ts Generated service exports
typescript/src/generated/services/card-steps.ts Card-step service updates
typescript/src/generated/path-mapping.ts Route mappings
typescript/src/generated/metadata.ts Generated metadata
typescript/src/client.ts Client wiring
typescript/scripts/generate-services.ts Service generation mappings
swift/Sources/BasecampGenerator/ServiceGrouper.swift Service grouping generation
swift/Sources/BasecampGenerator/MethodNaming.swift Method naming generation
swift/Sources/Basecamp/Generated/Services/SubtasksService.swift Generated Swift service
swift/Sources/Basecamp/Generated/Models/UpdateSubtaskRequest.swift Update request model
swift/Sources/Basecamp/Generated/Models/Todo.swift Todo accounting fields
swift/Sources/Basecamp/Generated/Models/RepositionSubtaskRequest.swift Reposition request model
swift/Sources/Basecamp/Generated/Models/Recording.swift Recording accounting fields
swift/Sources/Basecamp/Generated/Models/CreateSubtaskRequest.swift Create request model
swift/Sources/Basecamp/Generated/Models/Card.swift Card accounting fields
swift/Sources/Basecamp/Generated/Metadata.swift Generated metadata
swift/Sources/Basecamp/Generated/AccountClient+Services.swift Service accessors
swift/Sources/Basecamp/BasecampConfig.swift SDK configuration
swift/README.md Swift SDK documentation
spec/overlays/tags.smithy Operation tagging
spec/fixtures/todos/get.json Todo fixture fields
spec/fixtures/subtasks/list.json Subtasks list fixture
spec/fixtures/subtasks/get.json Subtask detail fixture
spec/fixtures/manifest.yaml Fixture registration
spec/fixtures/cards/get.json Card fixture fields
spec/bc3-routes.json BC3 route inventory
spec/bc3-route-allowlist.yml Route allowlist
spec/api-provenance.json API provenance pin
spec/api-gaps/subtasks-canonical-rename.md Subtasks route gap
spec/api-gaps/recording-show-unscoped.md Recording route gap
spec/api-gaps/recording-backlinks.md Backlinks route gap
spec/api-gaps/README.md API gap tracking
spec/api-gaps/account-people-enrollment.md Enrollment route gap
SPEC.md Specification and architecture documentation
SECURITY.md Security documentation context
scripts/check-idempotency-parity Idempotency parity checks
rust/generator/src/naming.rs Rust naming generation
rust/generator/names.toml Rust naming configuration
rust/basecamp-sdk/tests/services.rs Rust service tests
rust/basecamp-sdk/tests/guarantees.rs Rust guarantees tests
rust/basecamp-sdk/src/generated/types.rs Generated Rust types
rust/basecamp-sdk/src/generated/services/subtasks.rs Generated Rust service
rust/basecamp-sdk/src/generated/services/my_notifications.rs Notification service updates
rust/basecamp-sdk/src/generated/services/mod.rs Rust service modules
rust/basecamp-sdk/src/generated/routes.rs Rust routes
rust/basecamp-sdk/src/generated/mod.rs Rust generated module
rust/basecamp-sdk/src/generated/metadata.rs Rust metadata
rust/basecamp-sdk/src/generated/accessors.rs Rust accessors
ruby/test/basecamp/services/subtasks_service_test.rb Ruby service tests
ruby/scripts/generate-services.rb Ruby service generation
ruby/lib/basecamp/version.rb Ruby version
ruby/lib/basecamp/generated/types.rb Generated Ruby types
ruby/lib/basecamp/generated/services/subtasks_service.rb Generated Ruby service
ruby/lib/basecamp/generated/services/my_notifications_service.rb Notification service updates
ruby/lib/basecamp/generated/services/card_steps_service.rb Card-step service updates
ruby/lib/basecamp/generated/metadata.json Ruby metadata
ruby/lib/basecamp/client.rb Ruby client wiring
python/tests/services/test_subtasks_service.py Python service tests
python/src/basecamp/generated/types.py Generated Python types
python/src/basecamp/generated/services/subtasks.py Generated Python service
python/src/basecamp/generated/services/my_notifications.py Notification service updates
python/src/basecamp/generated/services/card_steps.py Card-step service updates
python/src/basecamp/generated/services/__init__.py Python service exports
python/src/basecamp/generated/metadata.json Python metadata
python/src/basecamp/client.py Synchronous client wiring
python/src/basecamp/async_client.py Asynchronous client wiring
python/src/basecamp/_version.py Python version
python/scripts/generate_services.py Python service generation
python/README.md Python SDK documentation
MIGRATING.md Migration guidance
kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/Types.kt Kotlin service types
kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/services/subtasks.kt Generated Kotlin service
kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/ServiceAccessors.kt Kotlin service accessors
kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/options-param-order.json Parameter ordering metadata
kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/Todo.kt Todo accounting fields
kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/Recording.kt Recording accounting fields
kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/Card.kt Card accounting fields
kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/Metadata.kt Kotlin metadata
kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/BasecampConfig.kt Kotlin SDK configuration
kotlin/README.md Kotlin SDK documentation
kotlin/generator/src/main/kotlin/com/basecamp/sdk/generator/Config.kt Kotlin generator configuration
go/pkg/basecamp/version.go Go version
go/pkg/basecamp/url-routes.json Go route metadata
go/pkg/basecamp/todos.go Todo model and service updates
go/pkg/basecamp/recordings.go Recording model updates
go/pkg/basecamp/client.go Go client wiring
go/pkg/basecamp/cards.go Card model and reposition updates
go/pkg/basecamp/api-provenance.json Go API provenance
go/grouped-client-inventory.yml Go operation inventory
COORDINATION.md Coordination documentation
behavior-model.json Operation behavior metadata
AGENTS.md Repository contribution guidance
Review details

Suppressed comments (4)

go/pkg/basecamp/cards.go:1337

  • position is an int, but the generated request field is int32. On 64-bit Go, values above math.MaxInt32 pass this check and are narrowed here; for example, math.MaxInt32+1 becomes a negative position, while math.MaxInt32+2^32+1 can wrap to position 1. Reject values above math.MaxInt32 before the cast, as the existing todolist reposition wrapper does.
	if position < 1 {
		err = ErrUsage("position must be at least 1")
		return err
	}

	body := generated.RepositionCardStepJSONRequestBody{
		SourceId: stepID,
		Position: int32(position), // #nosec G115 -- position is validated and bounded by API

go/pkg/basecamp/subtasks.go:279

  • This introduces another explicit-body carve-out, but SPEC §18's allowlist still names only CardsService.UpdateVerbatim and CardStepsService.Update for date clears. Add SubtasksService.Update to that documented exception (and its conformance/architecture inventory) so this generated-client bypass remains auditable and does not violate the repository's hard rule by omission.
	// Hand-marshaled map, not generated.UpdateSubtaskRequestContent — the
	// SPEC §18 rule 1 carve-out CardStepsService.Update also takes: the
	// "due_on": "" clear is unreachable through *types.Date.

go/pkg/basecamp/subtasks_test.go:379

  • This comparison is now false: the PR changed CardStepsService.Reposition and its generated documentation to a 1-based position, and the Go implementation rejects values below 1. The stale “0-indexed” wording will mislead maintainers about the contract.
// Positions are 1-based, unlike the 0-indexed card-step reposition.

spec/basecamp.smithy:12772

  • The new contract says that sending null clears due_on, but this conflicts with SPEC §18: the SDKs compact null/None and the cross-SDK clear encoding is an explicit empty string ("due_on": ""). The generated Python/TypeScript/Kotlin/Swift/Ruby APIs cannot send a JSON null here either, so documenting it as supported is misleading; describe only the empty-string clear and regenerate the derived documentation.
  • Files reviewed: 58/103 changed files
  • Comments generated: 1
  • Review effort level: Lite (auto)

Note

Copilot is running an experiment and ran this review at Lite.


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread go/pkg/basecamp/subtasks.go Outdated
@zachasme zachasme changed the title Add the Subtasks service and repin to bc3 5daa0911d3 feat(subtasks): add the Subtasks service and repin to bc3 5daa0911d3 Sep 15, 2026
@zachasme

Copy link
Copy Markdown
Contributor Author

Codex adversarial review (read-only, against the local bc3 checkout at 5daa0911d3) and what came of it, folded into the one commit:

Fixed. Go's Reposition in both SubtasksService and CardStepsService cast int to int32 after checking only the lower bound, so 2147483648 would have wrapped negative and landed the item at the top instead of the bottom. Both now refuse anything above math.MaxInt32, with a test. The account-people-enrollment brief's suggested error list omitted the documented 507 Insufficient Storage for the account user limit, which bc3 raises from both the preflight and the enrollment path; the brief now names it, modelled like StorageLimitError.

Also from CONTRIBUTING, not Codex: the commit and title now follow Conventional Commits, and the root, TypeScript, Ruby and Go READMEs gained the Subtasks row their service tables were missing.

Noted, not changed. Rust cannot express bc3's due-date clear (null or "") on UpdateSubtask because the generated Option<Date> omits on None — the same standing limitation UpdateCardStep and every generated date field carry, and the reason the merge-safe composites exist; a Rust composite for subtasks would be a separate PR. Go's UpdateSubtaskRequest.Title treats "" as "leave unchanged", which mirrors UpdateStepRequest and is documented on the field; a pointer would let a caller send an empty title on purpose, but I kept the two request types symmetrical.

Not done: CONTRIBUTING step 5 asks for conformance fixtures for new operations. Neither Boosts nor CardSteps has one today, and a new fixture means dispatch arms in all seven runners, including Swift, which this machine cannot compile. Happy to add one if you want it in this PR.

@zachasme
zachasme force-pushed the feat/subtasks branch 2 times, most recently from 621fe96 to 77b2f21 Compare September 21, 2026 13:46
bc3 #12659 documented subtasks as a first-class resource with flat canonical
routes, shaped after comments: the parent advertises subtasks_count,
subtasks_completed_count and subtasks_url, and the index paginates. Model it as
a Subtasks service — ListSubtasks, GetSubtask, CreateSubtask, UpdateSubtask,
CompleteSubtask, UncompleteSubtask, RepositionSubtask, DeleteSubtask — reusing
the CardStep structure, because the wire shape did not change: type stays
"Kanban::Step", only the routes and the parent's accounting are new. Todo, Card
and the generic Recording projection carry the three accounting members. The
CardSteps operations stay as they were; bc3 serves the card-scoped /steps
spellings indefinitely as aliases of the same records.

The route-parity gate compares the spec against bc3's docs at the provenance
pin, so absorbing documented routes means moving the pin. Advance it from
c680233ba0e to 5daa0911d33 and triage the 249-commit range in the gap
registry: client users were absorbed ahead of the repin, and the three other
documented additions — the unscoped recording show, backlinks, and bulk
enrollment — are registered as addressed-in-bc3-pr entries for later
absorption, with matching dispositions in the route allowlist. The stale
client-route waivers that this repin makes redundant are deleted.

Two contract corrections rode along. bc3's card_table_steps.md said the
reposition position was "Zero indexed" until #12659 corrected it to 1-based;
the server always counted from 1, so RepositionCardStep's member doc and the
Go wrapper's lower bound move with it (MIGRATING). And PUT /my/unreads now
refuses batches over 500 readables with 422, so MarkAsRead declares
ValidationError and a @Length(max: 500).

Fixtures for the two subtask reads come from the documented examples and are
validated as CardStep; todos/get.json and cards/get.json gain the accounting
keys. Every SDK has tests for the eight operations, and the pinned counts in
check-idempotency-parity, the Rust guarantees and the grouped-client inventory
are restated for 270 operations.
Output of make generate plus make bc3-routes at the 5daa0911d3 pin, on top of the hand-written commit: openapi.json, behavior-model.json, the bc3 route table, url-routes, and the generated clients, services, metadata and types for Go, TypeScript, Ruby, Python, Kotlin, Swift and Rust, with the API version constants synced to 2026-09-15.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot review overview

🟡 Changes recommended

Rust cannot encode the documented due-date clear operation, and several required error-path tests are missing.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 1 High severity · 3 Low severity

Open (4)

Comment thread python/tests/services/test_subtasks_service.py
Comment thread ruby/test/basecamp/services/subtasks_service_test.rb
Comment thread typescript/tests/services/subtasks.test.ts
…cases per operation

Build MaxInt32+1 at runtime in the two reposition range tests: the constant
expression overflows int on 32-bit and fails to compile there, which
page_param_test.go already documents.

subtasks_count, subtasks_completed_count and subtasks_url on Todo and Card
take omitempty like every neighbouring optional field, so a Todo decoded from
a server that renders none of them re-marshals without asserting zero
subtasks and an empty URL.

Add the error case Copilot asked for on every subtask operation that lacked
one: update and reposition 422 in TypeScript, Ruby and Python; complete and
uncomplete 404 in TypeScript and Ruby; uncomplete 404 in Python; get 404 in
Ruby.
@zachasme
zachasme requested a balanced review from Copilot September 22, 2026 08:05

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot review overview

🔵 Needs a closer look

Rust cannot express the documented due-date clear, and the new generic Recording fields lack a field-bearing fixture.

Review effort: Balanced
Findings: None

Resolved since last review (4)
Previously missed (1)

In code that hasn't changed since last review

Low severity Add fixture covering new generic Recording fields

spec/​basecamp.smithy:8206

The new fields on the generic Recording projection have no field-bearing fixture. The manifest's Recording representatives are a Message and a Todolist, and neither contains these keys; only the separate Todo and Card schemas were updated. Add a to-do/card-shaped Recording fixture entry containing all three members so this advertised generic projection is verified across generators.

@zachasme
zachasme requested a review from robzolkos September 22, 2026 08:13

@robzolkos robzolkos left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 This review was drafted with AI assistance.

Hey Zach, this is a really solid piece of work. You modelled all eight operations across seven SDKs, absorbed the repin and triaged its range, and handled the Copilot feedback cleanly. The gap brief write-up and the verification notes made this much easier to review. Thank you.

I checked the routes, status codes and parameters against bc3's subtasks.md and the controller at 5daa0911d33, and they all line up. A few small things before I approve:

1. A fixture for the new Recording fields

subtasks_count, subtasks_completed_count and subtasks_url are new on the generic Recording, but neither of the manifest's Recording examples includes them. One is a Message, the other a Todolist. We hit the same thing with bubble_up_url, and the recording-list-todolist entry is the pattern that fixed it. Could you add a to-do- or card-shaped element that carries all three keys and register it in spec/fixtures/manifest.yaml? Then check-fixture-coverage will guard those fields like the rest.

2. due_on clear wording on UpdateSubtask

The doc comment says to send "due_on": "" to clear the date. That works: SubtasksController passes it to assign_attributes on a date column, which casts "" to nil. But bc3 documents "due_on": null for this endpoint, and that's also what its test covers ("API: update with an explicit null due date clears it"). Since this comment ends up in every SDK's docs, could it lead with null and mention "" as also accepted? Something like:

Clearing a value takes an explicit send — "due_on": null clears the due date (an empty string is also accepted), "assignee_ids": [] removes every assignee.

3. The breaking label

I think the label is fair, but the description makes it sound bigger than it is. The MarkAsRead change adds @length(max: 500) and a ValidationError, but no generator enforces the length on the client. So callers get the same 422 from the server they always would have, and that part only changes documentation. The one real behaviour change is Go's CardStepsService.Reposition rejecting 0. Could you tweak the description so someone skimming the release notes knows that's the only thing to check?

Two other things came up during review that aren't from this PR, so I've filed them separately instead of adding to your list:

  • CardStep doesn't model the completion object bc3 sends for completed steps and subtasks. Its completed_at and completer fields are never populated.
  • The Rust generator can't send an explicit null on optional request fields, so Rust users can't clear a due date on any update. UpdateSubtask just inherits that.

Neither needs to block this.

Thanks again, this is close. Happy to re-review as soon as those land. 🙏

…ar doc

Review follow-ups from Rob. A Todo-shaped element joins recordings/list.json and is registered as recording-list-todo, so check-fixture-coverage guards subtasks_count, subtasks_completed_count and subtasks_url on the generic Recording the way recording-list-todolist guards bubble_up_url; the Go list test asserts the three fields ride only on that element. The UpdateSubtask doc now leads with the null clear bc3 documents and tests, and says the empty string is what the Ruby, Python and TypeScript SDKs send because they drop nil from the body.
The UpdateSubtask doc string carried into openapi.json, the generated Python, Ruby, Rust and TypeScript services and metadata, and the tool catalog.
@zachasme

Copy link
Copy Markdown
Contributor Author

Thanks for reviewing, @robzolkos 🙏 I believe your 3 points are addressed in a3310d5 (and the updated PR body).

@zachasme
zachasme requested a review from robzolkos September 23, 2026 07:17
bc3 answers PUT /subtasks/:id with an empty body with 400: the controller's
require(:subtask) raises ParameterMissing once wrap_parameters has nothing to
wrap. Verified against a local bc3 (subtasks_controller.rb unchanged since
5daa0911d33). UpdateSubtask's doc now says to send at least one parameter,
and Go's SubtasksService.Update refuses an all-empty request with a usage
error before it reaches the wire.

The TypeScript "422 on update" test sent dueOn: "not-a-date", which the
generated method rejects locally, so the msw handler never ran and the test
passed on client-side validation alone. It now sends a well-formed update,
asserts the handler was reached, and drops the due_on error body bc3 never
emits (a malformed due_on is cast to nil and answered 200).
@robzolkos
robzolkos merged commit d5f0e3b into main Sep 25, 2026
55 checks passed
@robzolkos
robzolkos deleted the feat/subtasks branch September 25, 2026 15:00
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 claude-code-assisted enhancement New feature or request go kotlin python Pull requests that update the Python SDK ruby Pull requests that update the Ruby SDK rust Rust SDK spec Changes to the Smithy spec or OpenAPI swift typescript Pull requests that update TypeScript code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants