Timesheets: model the entry destroy; retire five phantom coverage gaps (#581, #582) - #626
Conversation
There was a problem hiding this comment.
💡 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".
There was a problem hiding this comment.
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 GoTimesheet.Destroywrapper mirroringTrash. - Adds a
modeled_asdisposition toscripts/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.
6c2668e to
d89dc6f
Compare
There was a problem hiding this comment.
💡 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".
There was a problem hiding this comment.
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]intypescript/scripts/generate-services.ts:1156,operation['description']&.lines&.firstinruby/scripts/generate-services.rb:395, andop.description.lines().first()inkotlin/generator/.../ServiceEmitter.kt:67). As a result, the generateddestroydocstrings intypescript/src/generated/services/timesheets.ts:299,ruby/lib/basecamp/generated/services/timesheets_service.rb:86, andkotlin/.../services/timesheets.kt:167all 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 inopenapi.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.
d89dc6f to
e75d69d
Compare
|
@codex review |
|
Codex Review: Didn't find any major issues. Already looking forward to the next diff. Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
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.
Sensitive Change Detection (shadow mode)This PR modifies control-plane files:
|
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.
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).
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).
…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.
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_modeleddispositions. One was a real gap. Five were not.The one real gap:
DELETE /timesheet_entries/:idNow
DestroyTimesheetEntry— 204,@idempotent,natural: true,retryOn: [429, 503],maxAttempts: 3to match its timesheet siblings. Surfaces astimesheets.destroy(entryId)in all six SDKs.Verified at the pinned provenance revision
2c0dafba13:config/routes.rb:187—resources :timesheet_entries, only: %i[ show update destroy ], controller: "timesheets/entries", flat, no bucket scopeTimesheets::EntriesController#destroyanswersformat.json { head :no_content }; the legitimate template-less escape hatch, not theGetRecordingtraphead :forbiddenwhenCurrent.person.can_archive_or_trash?is false — henceForbiddenErrorin the error set, and an empty-body 403 test in every SDK that has onedoc/api/sections/timesheets.md:688, "will return204 No Content"test/api/timesheets/entries_controller_api_test.rb— "destroy entry via flat route" asserts:no_content+reload.deleted?, plus anassert_recognizeson the unscoped routeTrashRecordingdid not already cover this: it trashes recoverably,#destroydeletes outright. Go keepsTrashand gainsDestroy, 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:
GET /projects/:id/gauge/needles/:idGetGaugeNeedle→GET /gauge_needles/{needleId}PUT /projects/:id/gauge/needles/:idUpdateGaugeNeedleDELETE /projects/:id/gauge/needles/:idDestroyGaugeNeedleGET /projects/:id/recordings/:id/timesheetGetRecordingTimesheetPOST /projects/:id/recordings/:id/timesheet/entriesCreateTimesheetEntryBoth draws in each pair name the same controller action (
routes.rb:186+:737for needles;:266/:745and:268/:747for timesheets). Both controllers branch on@bucketbeing nil and fall back toauthorize_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 needleGET/PUTplus both flat timesheet routes carry live example-markers.They surfaced in the direction-2 ledger only because
bucket_insensitive_keycollapses a leading/buckets/:idand nothing else. Atracking_issueon 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
ListForwardsandRepositionTodolistGroup, so the new disposition is verified, not asserted. The gate requires the named operationId to exist inopenapi.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 letGetProjectTimesheet(/projects/:id/timesheet) discharge the recording timesheet route by droppingrecordings/:idfrom 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:
NoSuchOperationListGaugeNeedlesfor needle showGetTimesheetEntryfor needle showGetProjectTimesheetfor recording timesheetListGaugesfor needle showCreateGaugeNeedlefor entry createVerification
make checkgreen at this head — REAL exit code captured to a log and grepped back,REAL_EXIT=0, withPRE_SHA == POST_SHA == e75d69de1and 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=verboseto confirm✓ … Timesheet entry destroy uses /timesheet_entries/{entryId} path.Nothing here is asserted without a red proof:
conformance-goexit 2 withExpected request path "/999/buckets/456/timesheet_entries/999" … got "/999/timesheet_entries/999".DestroyfromTrash— routingDestroythroughTrashRecordingWithResponsefails them withexpected method DELETE, got PUTandexpected path /12345/timesheet_entries/… got /12345/recordings/…/status/trashed.json.Execution, not cache: Swift produced real
Test Suiteoutput; Ruby went 1241 → 1243 runs, 29713 assertions, 0 failures, 0 errors, 0 skips; TS SDK 1285 passed across 80 files; Python 1050 passed. Kotlin'sjvmTestreportedUP-TO-DATEin the final pass, so it was re-run with--rerun-tasks—:basecamp-sdk:jvmTestand:generator:testboth executed,BUILD SUCCESSFUL, exit 0.make bc3-route-paritypasses and now reports15 dispositions (6 tracking_issue, 2 registry, 2 out_of_scope, 5 modeled_as). Both #581 and #582tracking_issueentries 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.mdandscripts/check-idempotency-parityupdated to match.Two smaller things worth calling out in review:
"Permanently delete a timesheet entry. Returns 403 when the caller may not"into the TS/Ruby/Kotlin service docs.DestroyTimesheetEntryneeded aRESOURCE_TYPE_OVERRIDESentry in the TS, Kotlin and Swift generators.Destroyis not a verb pattern, so resource-type inference falls through to a generic"resource"and would have filed this delete away from theget/create/updatesiblings that reporttimesheet_entry. Ruby and Python emit noresourceType, so they need nothing.Notes
Not train-blocked: Todolist polymorphic read repair: one truthful flat shape replaces Todolist/TodolistGroup/TodolistOrGroup #544 is an open issue with no PR, and no open PR touchesBoth halves of this are now false and it is superseded by the hold banner above. Todolist polymorphic read repair: one truthful flat shape replaces Todolist/TodolistGroup/TodolistOrGroup #544 shipped as One flat Todolist replaces Todolist, TodolistGroup and TodolistOrGroup #628, and Model the bare field-map error bodies, then cloud_files and google_documents (#550, #551) #629 also touchesspec/oropenapi.json.spec/. All three branched from the same base and must land in order (One flat Todolist replaces Todolist, TodolistGroup and TodolistOrGroup #628 → triad → Timesheets: model the entry destroy; retire five phantom coverage gaps (#581, #582) #626 → Model the bare field-map error bodies, then cloud_files and google_documents (#550, #551) #629), each with a rebase, a fullmake generate, and counts re-derived frombehavior-model.json.Separate finding — pre-existing, deliberately not fixed here
The same
RESOURCE_TYPE_OVERRIDESgap already ships in the gauge service, inside #581's own resource family:DestroyandToggleare 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 abug, and folding it into anenhancementPR would file it wrongly in the release notes. Happy to open it as a follow-up. Note also that nothing inmake checkcatches this class; a lint asserting every operation in a service family agrees onresourceTypewould.Also unfixed and unrelated: several per-SDK README service tables list stale method rosters —
typescript/README.md:479showsforRecording, forProject, report(missingget/create/update),go/README.md:460showsMyEntries, ProjectEntriesfor methods actually namedReport/ProjectReport/RecordingReport/Get/Create/Update/Trash. Adding onlydestroywould have made them half-true.