Read a pixel dimension one ulp off an integer as that integer
Problem
BC3 computes some upload and attachment width/height values in floating point and serves them a few ulps off an integer: 752.0000000000001, 496.00000000000006. types.FlexInt required f == math.Trunc(f), so one such upload failed the whole response. In the hosted MCP server that's Sentry BC-MCP-K (flexint: 752.0000000000001 is not an integer) and BC-MCP-M (failed to parse upload: …): list_uploads, search, get_project_timeline, list_everything_feed, get_my_day, get_progress_report, get_catchup.
The value comes from API responses, not from tool arguments. FlexInt only decodes response dimensions.
Fix
A number within 1e-6 of an integer reads as that integer. A real fraction (1024.5, 1024.001, 752.0000011) is still refused with the same flexint: … is not an integer error, and the int32 range check now runs on the rounded value.
- Why
1e-6: float64 spacing is under1e-13at typical pixel sizes and under5e-7at 2^31, so the tolerance covers a few ulps of noise anywhere in int32 range. It's still far below any fraction a pixel count could mean. - Size bounds:
FlexIntis int32-only and float64 represents every int32 exactly. Ids never go through it: they useFlexibleInt64viajson.Number. An id-sized value (9007199254740993) is refused as an overflow, not rounded. - Strings: Go never accepted
"752"here and still doesn't. Kotlin's serializer already did and still does. - Negatives: still accepted, as before.
FlexibleIntSerializer (Kotlin) and flex_int (Rust) had the same exact-integer test, so they take the same tolerance. SPEC §10 (Nullable Numeric Dimensions) records the rule.
Residuals, not fixed here:
- Swift's synthesized
Int32?decode (Int32(exactly:)) still refuses the noisy value. Fixing it is a generator change for every model with a dimension. - Ruby's
parse_integerusesto_i, so it truncates751.9999999999999to751and accepts1024.5as1024.
Tests
- Go
TestFlexInt_UnmarshalTable(27 rows): noise around 752, 496 and ±0, negatives, and both int32 edges with noise round. Real fractions, overflow past either edge (including2147483647.9999998, which rounds past MaxInt32), an id-sized value, a string and a bool are refused, and a refused value leaves the target untouched.TestFlexInt_FloatNoiseInsideAPayloadcovers the as-served shape through*FlexIntfields. Checked against the old implementation: 9 table rows and the payload test fail there. - Kotlin
roundsFloatingPointNoiseandrejectsRealFractionsAndOverflowPastTheTolerance. - Rust
dimensions_round_float_noise_and_refuse_real_fractions.
Locally green: make go-check, make rs-lint rs-test, and ./gradlew :basecamp-sdk:jvmTest --tests FlexibleIntSerializerTest.
Release notes: Bug Fixes. Once this is released, a follow-up PR bumps basecamp-mcp-server.
Summary by cubic
Fixes FlexInt in Go, Kotlin, and Rust so pixel dimensions the API serves a few ulps off an integer (752.0000000000001, 496.00000000000006) read as that integer. Previously one such upload made list_uploads, search, the timeline, and other hosted MCP calls fail (Sentry BC-MCP-K/M).
- A number within
1e-6of an integer decodes to it; real fractions (1024.5,1024.001,752.0000011) are still refused, and the int32 range check now runs on the rounded value. 1e-6sits above float64 ulp spacing up to the int32 limit, and ids go throughFlexibleInt64/json.Number, so no id-sized value is rounded.- Kotlin also refuses non-finite strings (
NaN,Infinity) before rounding, sincetoDoubleOrNullparses them and NaN would otherwise read as 0. - SPEC §10 documents the rule. Residuals: Swift's
Int32(exactly:)still refuses the noise, and Ruby truncates withto_iinstead of refusing — left for follow-ups.