Skip to content

Timesheets: model the entry destroy; retire five phantom coverage gaps (#581, #582) - #626

Merged
jeremy merged 2 commits into
mainfrom
feat/gauge-needles-timesheets
Aug 3, 2026
Merged

jeremy merged 2 commits into
mainfrom
feat/gauge-needles-timesheets

Conversation

@jeremy

@jeremy jeremy commented Aug 3, 2026 •

Copy link
Copy Markdown
Member

🛑 HELD — draft on purpose, do not merge

Spec-train order is enforced: #628 → ScheduleEntries triad (#546/#547) → #626 → #629 → recurrence absorption.

#626, #628 and #629 all touch spec/basecamp.smithy and the six generated trees, and all branched from the same base. Merging any two in parallel conflicts across every generated tree. Each needs a rebase and a full make generate and counts re-derived from behavior-model.json (never hand-incremented) after the one ahead of it lands.

This PR returns to ready when its turn arrives and it has been rebased.


Lane B2a of the v0.13.0 coverage work: #581 (gauge needle show/update/destroy) and #582 (recording timesheet read + entry create/delete). Six routes were carried as bc3_routes_not_modeled dispositions. One was a real gap. Five were not.

The one real gap: DELETE /timesheet_entries/:id

Now DestroyTimesheetEntry — 204, @idempotent, natural: true, retryOn: [429, 503], maxAttempts: 3 to match its timesheet siblings. Surfaces as timesheets.destroy(entryId) in all six SDKs.

Verified at the pinned provenance revision 2c0dafba13:

evidence
drawn config/routes.rb:187 — resources :timesheet_entries, only: %i[ show update destroy ], controller: "timesheets/entries", flat, no bucket scope
renderable Timesheets::EntriesController#destroy answers format.json { head :no_content }; the legitimate template-less escape hatch, not the GetRecording trap
403 path the same action answers head :forbidden when Current.person.can_archive_or_trash? is false — hence ForbiddenError in the error set, and an empty-body 403 test in every SDK that has one
documented doc/api/sections/timesheets.md:688, "will return 204 No Content"
API-tested test/api/timesheets/entries_controller_api_test.rb — "destroy entry via flat route" asserts :no_content + reload.deleted?, plus an assert_recognizes on the unscoped route

TrashRecording did not already cover this: it trashes recoverably, #destroy deletes outright. Go keeps Trash and gains Destroy, each documented against the other.

The five that were not gaps

bc3 draws each of these actions twice, and the SDK already models the flat spelling of every one:

bc3 route (ledger) already modeled as
GET /projects/:id/gauge/needles/:id GetGaugeNeedle → GET /gauge_needles/{needleId}
PUT /projects/:id/gauge/needles/:id UpdateGaugeNeedle
DELETE /projects/:id/gauge/needles/:id DestroyGaugeNeedle
GET /projects/:id/recordings/:id/timesheet GetRecordingTimesheet
POST /projects/:id/recordings/:id/timesheet/entries CreateTimesheetEntry

Both draws in each pair name the same controller action (routes.rb:186+:737 for needles; :266/:745 and :268/:747 for timesheets). Both controllers branch on @bucket being nil and fall back to authorize_recording, so the flat form is the written path, not an accident. bc3 documents both spellings, its API tests exercise both — "unscoped and scoped routes return identical data for needle" asserts it outright — and the flat needle GET/PUT plus both flat timesheet routes carry live example-markers.

They surfaced in the direction-2 ledger only because bucket_insensitive_key collapses a leading /buckets/:id and nothing else. A tracking_issue on them was a standing promise to ship five duplicate operations across six SDKs for no reachable capability, so they get a truthful disposition instead.

modeled_as, and why it is checked

"We already model that at the other spelling" is exactly the claim that produced ListForwards and RepositionTodolistGroup, so the new disposition is verified, not asserted. The gate requires the named operationId to exist in openapi.json, share the verb, sit at a different path (an identical one means the entry is stale), have bc3's own route table document that path, and have that path be this route with a leading scope removed.

That last rule is a suffix test over _-flattened segments, not a subset test. Subset was the first version and it let GetProjectTimesheet (/projects/:id/timesheet) discharge the recording timesheet route by dropping recordings/:id from the middle — a different endpoint, not a spelling. Flattening is what lets it survive bc3's renames: /gauge_needles/:id → [gauge, needles, :id] is a suffix of [projects, :id, gauge, needles, :id].

Six adversarial substitutions were run against the finished check; all six are rejected with distinct messages:

substitution rejected because
NoSuchOperation names no operation
ListGaugeNeedles for needle show collection cannot stand in for member
GetTimesheetEntry for needle show different resource
GetProjectTimesheet for recording timesheet interior segment dropped
ListGauges for needle show different noun
CreateGaugeNeedle for entry create different terminal

Verification

make check green at this head — REAL exit code captured to a log and grepped back, REAL_EXIT=0, with PRE_SHA == POST_SHA == e75d69de1 and matching tree hashes, ending on a clean working tree.

All six runners dispatch the new conformance fixture and pass it: Go, Kotlin, Ruby, Python and Swift print PASS: Timesheet entry destroy…; TypeScript's default reporter hides case names, so it was re-run with --reporter=verbose to confirm ✓ … Timesheet entry destroy uses /timesheet_entries/{entryId} path.

Nothing here is asserted without a red proof:

  • The conformance fixture is not vacuous — mutating its expected path to a bucket-scoped spelling makes conformance-go exit 2 with Expected request path "/999/buckets/456/timesheet_entries/999" … got "/999/timesheet_entries/999".
  • The Go unit tests discriminate Destroy from Trash — routing Destroy through TrashRecordingWithResponse fails them with expected method DELETE, got PUT and expected path /12345/timesheet_entries/… got /12345/recordings/…/status/trashed.json.

Execution, not cache: Swift produced real Test Suite output; Ruby went 1241 → 1243 runs, 29713 assertions, 0 failures, 0 errors, 0 skips; TS SDK 1285 passed across 80 files; Python 1050 passed. Kotlin's jvmTest reported UP-TO-DATE in the final pass, so it was re-run with --rerun-tasks — :basecamp-sdk:jvmTest and :generator:test both executed, BUILD SUCCESSFUL, exit 0.

make bc3-route-parity passes and now reports 15 dispositions (6 tracking_issue, 2 registry, 2 out_of_scope, 5 modeled_as). Both #581 and #582 tracking_issue entries are gone.

Counts were re-derived from behavior-model.json, not hand-edited: total 240→241, idempotent 78→79, union 201→202, DELETEs 23→24, and SPEC.md's retry-ceiling paragraph 198→199 / 190→191 / 201→202. SPEC.md, SECURITY.md, AGENTS.md and scripts/check-idempotency-parity updated to match.

Two smaller things worth calling out in review:

  • The Smithy doc is deliberately one complete sentence on one line. Generators truncate method docs to the first line, so a two-line comment shipped "Permanently delete a timesheet entry. Returns 403 when the caller may not" into the TS/Ruby/Kotlin service docs.
  • DestroyTimesheetEntry needed a RESOURCE_TYPE_OVERRIDES entry in the TS, Kotlin and Swift generators. Destroy is not a verb pattern, so resource-type inference falls through to a generic "resource" and would have filed this delete away from the get/create/update siblings that report timesheet_entry. Ruby and Python emit no resourceType, so they need nothing.

Notes

Separate finding — pre-existing, deliberately not fixed here

The same RESOURCE_TYPE_OVERRIDES gap already ships in the gauge service, inside #581's own resource family:

GetGaugeNeedle      → resourceType: "gauge_needle"
UpdateGaugeNeedle   → resourceType: "gauge_needle"
DestroyGaugeNeedle  → resourceType: "resource"   ← inconsistent
ToggleGauge         → resourceType: "resource"   ← inconsistent
ListGaugeNeedles    → resourceType: "gauge_needle"
CreateGaugeNeedle   → resourceType: "gauge_needle"

Destroy and Toggle are both absent from the verb-pattern list. The fix is two more override lines in each of the three generators. I left it out because it changes an observability string on already-shipped operations — that is a bug, and folding it into an enhancement PR would file it wrongly in the release notes. Happy to open it as a follow-up. Note also that nothing in make check catches this class; a lint asserting every operation in a service family agrees on resourceType would.

Also unfixed and unrelated: several per-SDK README service tables list stale method rosters — typescript/README.md:479 shows forRecording, forProject, report (missing get/create/update), go/README.md:460 shows MyEntries, ProjectEntries for methods actually named Report/ProjectReport/RecordingReport/Get/Create/Update/Trash. Adding only destroy would have made them half-true.

Copilot AI review requested due to automatic review settings August 3, 2026 21:34
@jeremy jeremy added the enhancement New feature or request label Aug 3, 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 conformance Conformance test suite python Pull requests that update the Python SDK labels Aug 3, 2026

@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: 6c2668ef90

ℹ️ 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 spec/basecamp.smithy

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This PR is Lane B2a of the v0.13.0 coverage work. It models one genuinely missing endpoint — DELETE /timesheet_entries/{entryId} as the new DestroyTimesheetEntry operation — across all six SDKs (Go, TypeScript, Ruby, Swift, Kotlin, Python), following the Smithy-first generation pipeline. It also retires five bc3_routes_not_modeled dispositions from issues #581/#582 that were false gaps (bc3 draws those routes under both a project-scoped and a flat spelling, and the SDK already models the flat form), introducing a new checked modeled_as disposition in the route-parity gate to prove that claim rather than assert it.

Changes:

  • Adds DestroyTimesheetEntry (DELETE, 204, @idempotent, natural: true, retry [429,503] max 3) to the Smithy spec and regenerates all downstream artifacts (openapi, schema, metadata, per-SDK services, Go generated client), plus a hand-written Go Timesheet.Destroy wrapper mirroring Trash.
  • Adds a modeled_as disposition to scripts/check-bc3-route-parity (suffix-over-flattened-segments verification) and marks the five #581/#582 routes as covered by their flat spellings.
  • Adds a conformance test enforcing the flat /timesheet_entries/{entryId} path, wires it into all six runners, and updates operation counts (240→241, idempotent 78→79, DELETEs 23→24) in SPEC.md, SECURITY.md, AGENTS.md, and the idempotency-parity script.

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.

Reviewed changes

Copilot reviewed 25 out of 39 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
spec/basecamp.smithy Defines the new DestroyTimesheetEntry operation; first-line of /// doc trails off mid-sentence (see comment)
spec/overlays/tags.smithy Tags the operation into the Schedule service group
openapi.json / typescript/src/generated/openapi-stripped.json / schema.d.ts / path-mapping.ts / metadata.ts Regenerated OpenAPI + TS artifacts for the new op
go/pkg/generated/client.gen.go / url-routes.json / go/pkg/basecamp/timesheet.go Generated Go client method + route entry; hand-written Destroy wrapper mirroring Trash
ruby / python / kotlin / swift generated services, metadata, generator configs Per-SDK destroy methods, retry/idempotency metadata, service-split + method-name overrides
conformance/tests/paths.json + 6 runners New flat-path conformance test dispatched by all six SDK runners
scripts/check-bc3-route-parity New modeled_as disposition with suffix-check verification and disposition breakdown output
spec/bc3-route-allowlist.yml Retires 5 phantom gaps as modeled_as, removes the real DELETE gap
SPEC.md / SECURITY.md / AGENTS.md / scripts/check-idempotency-parity Operation-count updates; SPEC.md §7 counts were not updated (see comment)
behavior-model.json Retry/idempotency entry for the new op

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread spec/basecamp.smithy Outdated
Comment thread SPEC.md
Copilot AI review requested due to automatic review settings August 3, 2026 21:42
@jeremy
jeremy force-pushed the feat/gauge-needles-timesheets branch from 6c2668e to d89dc6f Compare August 3, 2026 21:42

@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: d89dc6fd4f

ℹ️ 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/generated/services/timesheets.ts Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 25 out of 39 changed files in this pull request and generated no new comments.

Suppressed comments (1)

spec/basecamp.smithy:3082

  • The first line of this operation's description ends mid-clause ("...Returns 403 when the caller may not"). The TypeScript, Ruby, and Kotlin service generators emit only the first line of the description as the method doc comment (op.description.split("\n")[0] in typescript/scripts/generate-services.ts:1156, operation['description']&.lines&.first in ruby/scripts/generate-services.rb:395, and op.description.lines().first() in kotlin/generator/.../ServiceEmitter.kt:67). As a result, the generated destroy docstrings in typescript/src/generated/services/timesheets.ts:299, ruby/lib/basecamp/generated/services/timesheets_service.rb:86, and kotlin/.../services/timesheets.kt:167 all read "Permanently delete a timesheet entry. Returns 403 when the caller may not" and drop "archive or trash the entry", leaving an incomplete sentence in the public API docs. Reflowing so the first line is a complete sentence fixes all three (the full text is still preserved in openapi.json/schema.d.ts). Regenerate the downstream artifacts after editing the spec.
/// Permanently delete a timesheet entry. Returns 403 when the caller may not
/// archive or trash the entry.

#582's DELETE /timesheet_entries/:id was real and is now DestroyTimesheetEntry
(204, naturally idempotent). bc3 draws it flat only, at config/routes.rb:187
`resources :timesheet_entries, only: %i[ show update destroy ]`, and
Timesheets::EntriesController#destroy answers `head :no_content` — or
`head :forbidden` when the caller cannot archive or trash the entry, which is
why ForbiddenError is in the error set. doc/api/sections/timesheets.md:688
documents it and test/api/timesheets/entries_controller_api_test.rb exercises
both the scoped and flat spellings. Go keeps Trash (recoverable) alongside the
new Destroy (not).

The other five routes in #581/#582 are not coverage gaps. bc3 draws each of
them twice and the SDK already models the flat spelling of every one:

  GET/PUT/DELETE /projects/:id/gauge/needles/:id   Get/Update/DestroyGaugeNeedle
  GET  /projects/:id/recordings/:id/timesheet      GetRecordingTimesheet
  POST /projects/:id/recordings/:id/timesheet/...  CreateTimesheetEntry

Both draws in each pair name the same controller action, bc3's own docs carry
both spellings, and its API tests assert the two return identical data. They
surfaced in the direction-2 ledger only because bucket_insensitive_key collapses
a leading /buckets/:id and nothing else. A tracking_issue on them was a promise
to ship six duplicate operations across six SDKs for no reachable capability.

So they get a `modeled_as:` disposition instead — and it is checked rather than
asserted, because "we already model that at the other spelling" is precisely the
claim that produced ListForwards. The gate requires the named operationId to
exist, to share the verb, to sit elsewhere, for bc3's own route table to
document that elsewhere, and for its path to be this one with a leading scope
removed. That last rule is a suffix test, not a subset test: subset let
GetProjectTimesheet discharge the *recording* timesheet route by dropping
recordings/:id from the middle, which is a different endpoint, not a spelling.
Six adversarial substitutions were run against it and all six are rejected.

Counts move with the operation: idempotent 78 -> 79, union 201 -> 202, total
240 -> 241, DELETEs 23 -> 24.

SPEC §7's per-operation retry-ceiling distributions move with them, re-derived
from behavior-model.json rather than incremented: across all operations
retry.max 3 goes 198 -> 199 (42 at 2 unchanged), and within the retry-eligible
set 190 -> 191 at 3 (11 at 2 unchanged, 201 -> 202 in total). Those figures are
prose: doc-constants-check gates API_VERSION, the bc3 pin and the §19 table, not
operation counts, so only check-idempotency-parity's 79/202 is enforced.

The doc comment is one line now. The TypeScript, Ruby and Kotlin service
generators emit only the description's first line, so a sentence split across
two lines reached autocomplete as "...when the caller may not"; Go, Python and
Swift emit no method doc for this operation at all, which is why the second line
was reaching nobody.

Service tests cover the new operation's success and 403 paths in TypeScript,
Python, Ruby and Go — TypeScript and Python had no timesheets service test file
at all. Kotlin and Swift have none to extend. Resource-type inference has no
"Destroy" verb pattern, so the delete reported the generic "resource" while its
get/update siblings report "timesheet_entry"; an override in the TypeScript,
Kotlin and Swift generators lines it up.
Copilot AI review requested due to automatic review settings August 3, 2026 22:13
@jeremy
jeremy force-pushed the feat/gauge-needles-timesheets branch from d89dc6f to e75d69d Compare August 3, 2026 22:13

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 30 out of 44 changed files in this pull request and generated no new comments.

@jeremy

jeremy commented Aug 3, 2026

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Already looking forward to the next diff.

Reviewed commit: e75d69de1c

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

… cannot check

`modeled_as` makes the strongest claim in spec/bc3-route-allowlist.yml — "we
already model that bc3 route at its other spelling" — which is exactly the claim
that shipped ListForwards and RepositionTodolistGroup as 404s. It was checked for
structure and its adversarial proofs lived in a PR description, so the next person
to add an alias inherited the rule and none of the reasoning.

scripts/test-check-bc3-route-parity.rb runs those proofs in CI instead. It drives
adversarial allowlists through the real checker via a new BC3_ROUTE_ALLOWLIST
override and asserts each is rejected with its own message: 1 positive control and
9 negative cases. openapi.json and spec/bc3-routes.json are deliberately NOT
overridable — every case substitutes a REAL operationId for a REAL route, so the
suite says something about the routes we ship rather than about invented ones.

The case that has to survive forever is the interior segment. The gate's first
design tested flattened segments as a SUBSET, and that let GetProjectTimesheet
(/projects/:id/timesheet) discharge the RECORDING timesheet route by dropping
`recordings/:id` from the middle — a different endpoint, not a spelling. The rule
is a SUFFIX test, because a flat spelling is the scoped one with a leading scope
removed and nothing else.

Two cases beyond the six from review: ListGauges for the needle show route, and
CreateGaugeNeedle for entry create. The second is why the terminal-segment message
changed — both of those paths are collections, so "a collection and a member are
not the same route" was telling a future reader the wrong thing about their own
failure. It now says the terminal segment has to match, and why.

The half that cannot be mechanized is now demanded rather than left implied.
spec/bc3-routes.json is extracted from doc/api/sections/*.md and carries no
controller or action, so "both draws reach the same controller action" is not
derivable here — two routes can satisfy every structural check and still be served
by different controllers. Every `modeled_as` entry therefore needs a non-empty
`routes_rb:`, and the comment block states what a reviewer must confirm by hand:
both draws with their enclosing `resources` blocks, the bc3 API test covering the
pair, and the near miss it is not. That last one is not hypothetical —
config/routes.rb:740 draws /projects/:id/timesheet five lines from :745's
/projects/:id/recordings/:id/timesheet. The five existing entries carry that
evidence, verified at the pinned revision 2c0dafba13, and the timesheet pair now
names the neighbouring draw it is not.

Wired into `make check` and the spec-gates job beside the gate it tests, matching
how check-readme-env-vars carries its own self-test.
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Sensitive Change Detection (shadow mode)

This PR modifies control-plane files:

  • .github/workflows/test.yml

Shadow mode — this check is informational only. When activated, changes to these paths will require approval from a maintainer.

@github-actions github-actions Bot added the github-actions Pull requests that update GitHub Actions label Aug 3, 2026
@jeremy
jeremy marked this pull request as ready for review August 3, 2026 23:54
Copilot AI review requested due to automatic review settings August 3, 2026 23:54
@jeremy
jeremy merged commit 289644a into main Aug 3, 2026
47 checks passed
@jeremy
jeremy deleted the feat/gauge-needles-timesheets branch August 3, 2026 23:54

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

jeremy added a commit that referenced this pull request Aug 4, 2026
Checkpointed mid-implementation so the work is recoverable as a branch rather
than a patch file. The lane died on a session limit after fanning out to six
per-SDK sub-lanes.

Based on 620c80b, which is now one commit behind main: #626 landed
DestroyTimesheetEntry (240 -> 241) and regenerated the same trees, so every
generated artifact in this commit is stale by construction and must be
regenerated after the rebase. Do not trust the generated output here.
jeremy added a commit that referenced this pull request Aug 4, 2026
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).
jeremy added a commit that referenced this pull request Aug 4, 2026
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).
jeremy added a commit that referenced this pull request Aug 4, 2026
…ght and participants (#546, #547) (#632)

* Rename UpdateScheduleEntry to ReplaceScheduleEntry and declare its carve-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.

* Regenerate after rebase onto #626

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).

* Document the ScheduleEntry replace contract in SPEC 5/18 and the spec

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.

* Repin triage: 2c0dafba13..4dd2926f8a, six commits, one API change

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.

* Carve-out-aware update/edit composites for Python, Ruby, TypeScript and 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.

* Carve-out-aware update/edit composites for Go and Kotlin

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.

* Emit Kotlin's SchedulesService as an open class and register the subclass

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.

* Register the schedules composites in AGENTS.md; read-then-assign in the 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 restored the feat/gauge-needles-timesheets branch August 4, 2026 04:13
@jeremy
jeremy deleted the feat/gauge-needles-timesheets branch August 4, 2026 04:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

conformance Conformance test suite enhancement New feature or request github-actions Pull requests that update GitHub Actions 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

Development

Successfully merging this pull request may close these issues.

2 participants