Skip to content

Plain generated reads return a person's string id as a string in Ruby, Python and TypeScript - #917

Merged
jorgemanrubia merged 2 commits into
mainfrom
typed-decode-person-ids
Sep 16, 2026
Merged

jorgemanrubia merged 2 commits into
mainfrom
typed-decode-person-ids

Conversation

@jorgemanrubia

@jorgemanrubia jorgemanrubia commented Sep 16, 2026

Copy link
Copy Markdown
Member

In Ruby, Python and TypeScript, a plain generated read returned a person's string id as a string. If the person had no personable_type and its id came as "7", comments.get gave back "7". In TypeScript that string sat in a field typed number. Go gives 7, because its generated Person.Id is types.FlexibleInt64 and every Go service decodes through the generated types first.

Tracked in Plain generated reads leave an untagged person's string id unconverted in Ruby, Python and TypeScript.

The obvious fix is to widen the normalizer, and it is wrong. The keys it would match (creator, assignees, person, …) also hold people whose id is a plain int64 in Go: UpcomingSchedulePerson, MyAssignmentAssignee, OutOfOfficePerson and TemplateLibraryConfirmationPerson. A string at any of those is a decode error in Go. That is why Seven SDKs read a person id seven ways, and Go disagreed with itself narrowed the walk. Merge-safe writes refuse assignee ids the API really sends then closed the write path, which left the plain read.

So the fix goes where Go's fix is: typed decode, at exactly the fields Go reads as FlexibleInt64.

  • Audit. FlexibleInt64 is on exactly one generated field, Person.id. No hand-written Go wrapper uses it. Every Go service for the affected operations runs the generated Parse<Op>Response before anything else. On the gauge and notification surfaces, the positional normalizer covers the only person sites those operations have, so the result is the same. In total 161 operations reach Person in a 2xx response, at 359 sites.

  • Table. Each port's generator walks the OpenAPI response schemas and selects on the x-go-type marker, never on a key name. It emits a per-operation table of those sites, so the drift checks guard it.

  • Decode. The generated read path applies the table after the normalizer, on every body it decodes: single reads, lists, and every page of paginated and wrapped listings, followed pages included. Ruby's generated services now pass the operation id to the body decode; before, mutations passed none. At a site, a person's id reads as FlexibleInt64 does:

    • a parseable string becomes the number;
    • a non-numeric string becomes 0;
    • an out-of-range string, a float, an out-of-range number, null, a boolean, an array or an object fails the read.

    A null or non-object person, a missing id, and a malformed container are left alone. Go zero-fills or refuses those as part of whole-body decode, and these ports do that for no field.

  • Followed pages. A followed page is decoded only as far as Go decodes it, and that is less than the first page:

    • A capped list decodes only the items the cap keeps.
    • A later GetPersonProgress page decodes only its events.
    • Later pages of gauges, needles and bubble-ups read a null id, because Go decodes those pages into hand-written types with a plain int64 id.

    An adversarial review found each of these as a read Go accepts and this branch refused.

  • Behaviour change. A person id Go refuses now fails the generated read. That includes the reads the merge-safe composites make before writing, so those composites now refuse at the read, as Go does. No write is sent either way.

  • SPEC. SPEC §10 gains "Person Ids at Typed Decode", and the "plain read is still open" note is gone.

Audit

The measurement: the card-42 id-shape corpus at every site, 13,239 cases, first run through Go's real Parse<Op>Response (the oracle), then through each port's response path, on main and on this branch. Each cell shows divergences from Go as rows Go reads as a value / rows Go refuses, main → branch. Every site's Go type is the one field Person.id.

Field Sites Go type Ruby Python TypeScript
creator 187 types.FlexibleInt64 2816/2057 → 0/0 2816/2057 → 0/0 3036/2057 → 748/561
assignees 54 types.FlexibleInt64 918/594 → 0/0 918/594 → 0/0 972/594 → 216/162
completer 39 types.FlexibleInt64 624/429 → 0/0 624/429 → 0/0 663/429 → 156/117
completion_subscribers 25 types.FlexibleInt64 425/275 → 0/0 425/275 → 0/0 450/275 → 100/75
subscribers 12 types.FlexibleInt64 204/132 → 0/0 204/132 → 0/0 216/132 → 48/36
participants 11 types.FlexibleInt64 85/121 → 0/0 85/121 → 0/0 114/121 → 44/33
person 8 types.FlexibleInt64 128/88 → 0/0 128/88 → 0/0 136/88 → 32/24
the body, or a body element (GetPerson, ListPeople, …) 7 types.FlexibleInt64 117/77 → 0/0 117/77 → 0/0 124/77 → 28/21
booster 5 types.FlexibleInt64 80/55 → 0/0 80/55 → 0/0 85/55 → 20/15
performed_by 5 types.FlexibleInt64 80/55 → 0/0 80/55 → 0/0 85/55 → 20/15
granted 2 types.FlexibleInt64 34/22 → 0/0 34/22 → 0/0 36/22 → 8/6
revoked 2 types.FlexibleInt64 34/22 → 0/0 34/22 → 0/0 36/22 → 8/6
approver 2 types.FlexibleInt64 32/22 → 0/0 32/22 → 0/0 34/22 → 8/6
total 359 5577/3949 → 0/0 5577/3949 → 0/0 5987/3949 → 1436/1077
UpcomingSchedulePerson, MyAssignmentAssignee, OutOfOfficePerson, TemplateLibraryConfirmationPerson people not sites int64 untouched untouched untouched

Every remaining TypeScript divergence comes from the JavaScript number limit (waiver 1B.6). That is 4 corpus rows and 3 corpus rows at every site:

  • Value rows: Go reads an id outside ±(2^53−1). TypeScript keeps it as a string if it arrived as one, and JSON.parse has already rounded it if it arrived as a number.
  • Refusal rows: Go refuses 1024.0, 1e3 and the number 2^63, but JSON.parse has turned each into an integer before any code sees it.

In all three ports the 1,805 null, absent, non-object and container-shape cases are unchanged.

Per port, each new test was run with its runtime change reverted. The tests went red for the right reason: '7' against 7, no error raised, followed pages not decoded, and a key-name sweep breaking the strict-site tests. They went green again once the change was restored. make ts-check py-check rb-check and conformance-{typescript,python,ruby} pass locally. Kotlin, Swift and Rust are not touched here; this is rebased on Swift and Rust refuse person shapes the reference writes back, and SPEC §10 keeps both sides.

Copilot AI balanced review requested due to automatic review settings September 16, 2026 14:59
@github-actions github-actions Bot added typescript Pull requests that update TypeScript code ruby Pull requests that update the Ruby SDK python Pull requests that update the Python SDK labels Sep 16, 2026
Copilot AI previously approved these changes Sep 16, 2026

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.

🟢 Approved

The marker-derived tables agree across all three SDKs, all response paths are covered, and no unresolved correctness issues were found.

Pull request overview

Aligns Ruby, Python, and TypeScript person-ID decoding with Go’s FlexibleInt64 behavior using generated, operation-specific response paths.

Changes:

  • Generates consistent person-ID site tables from x-go-type.
  • Applies decoding to single, mutation, paginated, and wrapped responses.
  • Adds cross-SDK tests and documents the decoding contract.

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

File summaries
File Description
SPEC.md Defines typed person-ID decoding.
typescript/package.json Adds site-table generation.
typescript/scripts/extract-person-id-sites.ts Generates TypeScript response paths.
typescript/src/generated/person-id-sites.ts Contains generated TypeScript sites.
typescript/src/services/base.ts Decodes IDs across response paths.
typescript/tests/services/person-id-decode.test.ts Tests typed decoding comprehensively.
typescript/tests/services/person-id-normalization.test.ts Updates normalization expectations.
typescript/tests/services/my-notifications.test.ts Tests range refusal.
typescript/tests/services/schedules.test.ts Updates read-failure expectations.
typescript/tests/services/todos.test.ts Updates composite read expectations.
ruby/scripts/generate-metadata.rb Generates Ruby response sites.
ruby/scripts/generate-services.rb Passes operation IDs to decoding.
ruby/lib/basecamp/generated/metadata.json Stores generated Ruby sites.
ruby/lib/basecamp/person_id_sites.rb Implements Ruby typed decoding.
ruby/lib/basecamp/http.rb Decodes single and paginated bodies.
ruby/test/basecamp/person_id_sites_test.rb Tests Ruby decoding paths.
ruby/test/basecamp/services/schedules_service_test.rb Updates schedule expectations.
ruby/test/basecamp/services/todos_service_test.rb Updates todo expectations.
ruby/lib/basecamp/generated/services/account_service.rb Supplies decode operation IDs.
ruby/lib/basecamp/generated/services/attachments_service.rb Supplies decode operation IDs.
ruby/lib/basecamp/generated/services/automation_service.rb Supplies decode operation IDs.
ruby/lib/basecamp/generated/services/bookmarks_service.rb Supplies decode operation IDs.
ruby/lib/basecamp/generated/services/boosts_service.rb Supplies decode operation IDs.
ruby/lib/basecamp/generated/services/calendars_service.rb Supplies decode operation IDs.
ruby/lib/basecamp/generated/services/campfires_service.rb Supplies decode operation IDs.
ruby/lib/basecamp/generated/services/card_columns_service.rb Supplies decode operation IDs.
ruby/lib/basecamp/generated/services/card_steps_service.rb Supplies decode operation IDs.
ruby/lib/basecamp/generated/services/card_tables_service.rb Supplies decode operation IDs.
ruby/lib/basecamp/generated/services/cards_service.rb Supplies decode operation IDs.
ruby/lib/basecamp/generated/services/checkins_service.rb Supplies decode operation IDs.
ruby/lib/basecamp/generated/services/client_approvals_service.rb Supplies decode operation IDs.
ruby/lib/basecamp/generated/services/client_correspondences_service.rb Supplies decode operation IDs.
ruby/lib/basecamp/generated/services/client_replies_service.rb Supplies decode operation IDs.
ruby/lib/basecamp/generated/services/client_visibility_service.rb Supplies decode operation IDs.
ruby/lib/basecamp/generated/services/cloud_files_service.rb Supplies decode operation IDs.
ruby/lib/basecamp/generated/services/comments_service.rb Supplies decode operation IDs.
ruby/lib/basecamp/generated/services/documents_service.rb Supplies decode operation IDs.
ruby/lib/basecamp/generated/services/event_feed_service.rb Supplies decode operation IDs.
ruby/lib/basecamp/generated/services/everything_service.rb Supplies decode operation IDs.
ruby/lib/basecamp/generated/services/folders_service.rb Supplies decode operation IDs.
ruby/lib/basecamp/generated/services/forwards_service.rb Supplies decode operation IDs.
ruby/lib/basecamp/generated/services/gauges_service.rb Supplies decode operation IDs.
ruby/lib/basecamp/generated/services/google_documents_service.rb Supplies decode operation IDs.
ruby/lib/basecamp/generated/services/hill_charts_service.rb Supplies decode operation IDs.
ruby/lib/basecamp/generated/services/message_boards_service.rb Supplies decode operation IDs.
ruby/lib/basecamp/generated/services/message_types_service.rb Supplies decode operation IDs.
ruby/lib/basecamp/generated/services/messages_service.rb Supplies decode operation IDs.
ruby/lib/basecamp/generated/services/my_assignments_service.rb Supplies decode operation IDs.
ruby/lib/basecamp/generated/services/my_notes_service.rb Supplies decode operation IDs.
ruby/lib/basecamp/generated/services/my_notifications_service.rb Supplies decode operation IDs.
ruby/lib/basecamp/generated/services/people_service.rb Supplies decode operation IDs.
ruby/lib/basecamp/generated/services/projects_service.rb Supplies decode operation IDs.
ruby/lib/basecamp/generated/services/recordings_service.rb Supplies decode operation IDs.
ruby/lib/basecamp/generated/services/reports_service.rb Supplies decode operation IDs.
ruby/lib/basecamp/generated/services/schedules_service.rb Supplies decode operation IDs.
ruby/lib/basecamp/generated/services/search_service.rb Supplies decode operation IDs.
ruby/lib/basecamp/generated/services/subscriptions_service.rb Supplies decode operation IDs.
ruby/lib/basecamp/generated/services/templates_service.rb Supplies decode operation IDs.
ruby/lib/basecamp/generated/services/timesheets_service.rb Supplies decode operation IDs.
ruby/lib/basecamp/generated/services/todolist_groups_service.rb Supplies decode operation IDs.
ruby/lib/basecamp/generated/services/todolists_service.rb Supplies decode operation IDs.
ruby/lib/basecamp/generated/services/todos_service.rb Supplies decode operation IDs.
ruby/lib/basecamp/generated/services/todosets_service.rb Supplies decode operation IDs.
ruby/lib/basecamp/generated/services/tools_service.rb Supplies decode operation IDs.
ruby/lib/basecamp/generated/services/uploads_service.rb Supplies decode operation IDs.
ruby/lib/basecamp/generated/services/vaults_service.rb Supplies decode operation IDs.
ruby/lib/basecamp/generated/services/webhooks_service.rb Supplies decode operation IDs.
ruby/lib/basecamp/generated/services/wormholes_service.rb Supplies decode operation IDs.
python/scripts/generate_services.py Generates Python response sites.
python/src/basecamp/_person_id.py Implements Python typed decoding.
python/src/basecamp/generated/services/_person_id_sites.py Contains generated Python sites.
python/src/basecamp/generated/services/_base.py Applies synchronous decoding.
python/src/basecamp/generated/services/_async_base.py Applies asynchronous decoding.
python/tests/test_person_id.py Updates normalization behavior tests.
python/tests/test_person_id_sites.py Tests Python decoding comprehensively.
python/tests/services/test_notifications.py Tests notification range refusal.
python/tests/services/test_todos.py Updates composite read expectations.
Review details
  • Files reviewed: 22/77 changed files
  • Comments generated: 0
  • Review effort level: Balanced

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

Ruby, Python and TypeScript handed an untagged person's string id ("7")
back as the string on a plain generated read, where Go's decoder reads 7
because generated Person.Id is types.FlexibleInt64. Each generator now
emits, per operation, the response sites whose schema reaches Person,
selected on the x-go-type marker rather than on key names, and the
generated read path decodes the id there as FlexibleInt64 after the
normalizer: value converts, syntax refusal reads 0, range refusal and
non-int64 values fail the read. Plain-int64 people (upcoming schedule,
my assignments, out of office, template-library confirmation) are not
sites and stay strict.
Go trims a followed page to the list cap before decoding its items, reads
only the events of a later GetPersonProgress page, and decodes later pages
of gauges, needles and bubble-ups into hand-written types whose plain int64
id reads null as 0. The ports decoded every followed page whole, so each of
those reads failed where Go and main read them.
@jorgemanrubia
jorgemanrubia force-pushed the typed-decode-person-ids branch from 2935fea to 96bf69c Compare September 16, 2026 15:27
@jorgemanrubia

Copy link
Copy Markdown
Member Author

🤖 Adversarial review. A separate Claude Opus agent reviewed this PR, not Codex and not a human. It was briefed to find a strict int64 site this change loosens and a FlexibleInt64 site it missed, and to back every finding with a reproduction run against the real Go SDK.

Round 1, at 2935fea: three findings. In each, Go and main read the body and all three ports refused it. All three came from followed pages, which Go decodes less than the first page:

  • items past a list cap;
  • a later GetPersonProgress page's person;
  • a null id on later pages of gauges, needles and bubble-ups, which Go decodes into hand-written plain-int64 types.

Round 2, at 96bf69c: nothing real remains.

  • All three round-1 reproductions now read.
  • 16 cap and wrapped-page boundary cases match Go in every port, including Ruby re-enumeration and Python async.
  • The null exemption differs from Go only where Go's own pages differ, and it does not leak to page 1, to the page option, or to other operations.
  • A reflection walk over Go's 265 generated response types gives the same 161 operations and 359 sites as the generated tables.
  • For all 57 operations Go follows pages on, the followed-page sites match.
  • The plain-int64 people are untouched.

Two differences remain, and both are declared rather than fixed:

  • Ruby fetches pages lazily, so an early stop leaves unreached items undecoded.
  • TypeScript still has the JavaScript number waiver.

@jorgemanrubia
jorgemanrubia requested a balanced review from Copilot September 16, 2026 15:49
Copilot AI dismissed their stale review, a newer Copilot review was requested September 16, 2026 15:49

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.

🔵 Needs a closer look

It changes central response and pagination paths across three SDKs with broad generated-service impact.

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

@jeremy

jeremy commented Sep 16, 2026

Copy link
Copy Markdown
Member

Ran an adversarial pass over this at the head (96bf69c). No merge blocker — the parts that would be expensive to get wrong are right. Three comments, none of which I think should hold the merge.

What checked out, so the review is legible rather than just a list of doubts: all three site tables contain exactly the same 161 operations and 359 sites, and an independent traversal of the generated Go types matched that set. Ruby and Python reproduce FlexibleInt64's acceptance and refusal set across empty strings, whitespace, signs, leading zeros, hex and exponent strings, floats, the int64 boundaries, nulls, booleans and containers. None of the four plain-int64 person types is reachable from the new decoder — the thing this design exists to avoid. The pagination rules (cap ordering, the wrapped-page restriction, the three hand-written-type exceptions) match Go, with no missed page kind and no cap off-by-one. And the refusals use each SDK's non-retryable statusless API-error contract. Ruby's new suite passes 30 tests / 81 assertions, and six of those tests go red when decoding is disabled in memory, so they are testing behaviour rather than their own fixtures.

1. Site discovery can silently lose coverage when the schema changes. All three walkers stop at a recursive reference without checking whether the deeper instance contains people — python/scripts/generate_services.py:1190, ruby/scripts/generate-metadata.rb:98, typescript/scripts/extract-person-id-sites.ts:48. Adding creator: Person to a recursive MyAssignment yields sites for the top-level creator and none for children[].creator, so a child's "7" stays a string and a child's invalid id does not fail the read. Inline marked ids, markers inside allOf, and field-level $ref markers are missed the same way. No current site is affected — this is entirely about the next schema change. The part that makes it worth a note rather than a shrug is that regeneration reproduces the omission, so the freshness check cannot see it: the drift guard compares the table to what the walker produces, and both are wrong together. Whatever the fix is (or isn't), it seems worth writing down what the walker can and cannot see, next to the guard that reads as if it covers this.

2. The TypeScript float divergence is real but waiver 1B.6 doesn't cover it. creator.id: 1024.0 or 1e3 decodes to 1024 / 1000 where Go refuses the response — typescript/src/services/base.ts:332, documented at SPEC.md:1830. Waiver 1B.6 is about 64-bit precision and the number public API, and these values are exactly representable; the divergence is about the token form, which is a different thing. On supported Node 22 both original tokens are reachable through the reviver's context.source, so validating them wouldn't require returning bigint. Either way, this reads as wanting its own stated exception rather than being folded into the precision one.

3. TypeScript preserves numeric negative zero. {"creator":{"id":-0}} returns an id where Object.is(id, -0) is true; Go, Ruby and Python all give ordinary zero. -0 satisfies Number.isInteger and the range check, so the numeric branch at typescript/src/services/base.ts:332 returns it untouched. Practically harmless, listed because it is an unclaimed divergence and the audit table's value is that it is exhaustive.

@jorgemanrubia
jorgemanrubia merged commit eca5e31 into main Sep 16, 2026
53 checks passed
@jorgemanrubia
jorgemanrubia deleted the typed-decode-person-ids branch September 16, 2026 19:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

python Pull requests that update the Python SDK ruby Pull requests that update the Ruby SDK typescript Pull requests that update TypeScript code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants