Read a pixel dimension one ulp off an integer as that integer

Merged
JE
12d ago
+214-16

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 under 1e-13 at typical pixel sizes and under 5e-7 at 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: FlexInt is int32-only and float64 represents every int32 exactly. Ids never go through it: they use FlexibleInt64 via json.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_integer uses to_i, so it truncates 751.9999999999999 to 751 and accepts 1024.5 as 1024.

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 (including 2147483647.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_FloatNoiseInsideAPayload covers the as-served shape through *FlexInt fields. Checked against the old implementation: 9 table rows and the payload test fail there.
  • Kotlin roundsFloatingPointNoise and rejectsRealFractionsAndOverflowPastTheTolerance.
  • 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-6 of 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-6 sits above float64 ulp spacing up to the int32 limit, and ids go through FlexibleInt64/json.Number, so no id-sized value is rounded.
  • Kotlin also refuses non-finite strings (NaN, Infinity) before rounding, since toDoubleOrNull parses 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 with to_i instead of refusing — left for follow-ups.