Skip to content

spec(1.1): permit the decimal-string form for monetary amounts - #22

Merged
yahalomran merged 2 commits into
masterfrom
spec/integer-wire-encoding
Sep 21, 2026
Merged

yahalomran merged 2 commits into
masterfrom
spec/integer-wire-encoding

Conversation

@maoryeh

@maoryeh maoryeh commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

The problem

"type": "integer" on a monetary field is a constraint no Protobuf-generated implementation can satisfy.

The canonical Protocol Buffers JSON mapping serializes 64-bit integer fields as decimal strings, and does so unconditionally. From protobuf-java's own JsonFormat — note there is no flag, unlike the 32-bit case immediately above it:

case SFIXED32:
  if (alwaysWithQuotes) generator.print("\"");     // 32-bit: quoted only on request
  generator.print(((Integer) value).toString());

case INT64: case SINT64: case SFIXED64:
  generator.print("\"" + ((Long) value).toString() + "\"");   // 64-bit: always quoted

The reason is that JSON numbers are IEEE 754 doubles and cannot represent every 64-bit integer exactly. So an implementation that models money as int64 — the natural choice, since minor units in a zero-decimal currency overflow int32 at roughly 21 million major units — emits "amount": "35000" and fails validation against its own protocol.

Found while bringing an implementation into conformance: every monetary field it produced was rejected, and no amount of implementation work could fix it.

What changes

Nineteen monetary fields in the canonical schemas, plus four defined inline in the REST and MCP bindings, now accept either form:

"amount": {
  "type": ["integer", "string"],
  "pattern": "^-?[0-9]+$",
  "description": "Price in minor currency units. ..."
}
file fields
schemas/booking.json BookingPayment.{amount, amount_due, deposit_amount, tax_amount}, PaymentContext.{amount_due, tax_amount, line_items[].amount}, Cancellation.{fee, refund_amount}
schemas/catalog.json Pricing.{amount, price_range.min, price_range.max, deposit.value}, ServicePolicies.{cancellation.late_cancellation_fee, rescheduling.fee, no_show.fee}
schemas/availability.json TimeSlot.pricing.amount
schemas/registry.json PriceRangeFilter.{min, max}
openapi/usp-rest.json, openrpc/usp-mcp.json filters.price.{min, max}, payment_result.amount_paid

And §1.1 gains an Integer Encoding subsection stating the rule normatively:

  • consumers MUST accept both forms and MUST treat them as equal
  • producers SHOULD NOT vary the form for a given field between responses
  • the string form MUST be a canonical decimal integer, ^-?[0-9]+$

Two deliberate choices

The pattern is not decoration. A bare string would let a producer send "3.5e4" or "1,500" for a price and still validate. Verified behaviour:

35000    ACCEPT      "35000"   ACCEPT      -500  ACCEPT
"3.5e4"  reject      "35000.5" reject      "+35000" reject      35000.5 reject

pricing.deposit.value takes ^-?[0-9]+(\.[0-9]+)?$ instead, because it already admits number to carry a percentage when deposit.type is percentage.

Bounded-domain integers stay strict. Counts, capacities, party sizes, percentages, pagination limits, waitlist positions and HTTP status remain integer and MUST be JSON numbers. They fit a 32-bit integer, are not subject to the 64-bit mapping, and widening them would cost type safety for no interoperability gain. Rating.value is likewise untouched — it is a score, not money.

Alternative considered

Narrowing the fields to int32 in implementations instead. Rejected: it caps money at 2,147,483,647 minor units (~$21.5M, or ~¥2.1B in a zero-decimal currency) to fix a presentation detail, and it would not help any implementation that has already chosen int64. Since the constraint is inherited from the IDL rather than chosen, the spec is the right place to fix it — this is also what Google's own REST APIs do.

Verification

tools/usp_check.py schemas, refs and vectors all pass, with cryptography installed so the vector signatures are genuinely verified rather than silently skipped.

Rebuilt onto current master; the branch had diverged at the root, so this is the change
reapplied as one commit rather than a merge of unrelated histories. The only conflict was
in CHANGE_LOG.md, where master had gained a newer entry at the same position — both
entries are kept, ordered newest-first as the file requires.

Also drops the `tools/__pycache__/*.pyc` this branch had accidentally committed, and adds
a .gitignore so it cannot come back. The repo had no ignore rule, which is how it got in.

schemas / refs / vectors / coherence all pass.
@maoryeh
maoryeh force-pushed the spec/integer-wire-encoding branch from 3388985 to 4b8a483 Compare September 21, 2026 11:58
@yahalomran
yahalomran enabled auto-merge (squash) September 21, 2026 12:47
@yahalomran
yahalomran disabled auto-merge September 21, 2026 12:47
@yahalomran
yahalomran merged commit 7eea4e4 into master Sep 21, 2026
1 check passed
@yahalomran
yahalomran deleted the spec/integer-wire-encoding branch September 21, 2026 12:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants