Skip to content

Wire-contract parity: serializer, date split, payment_details discriminator, and error mapping - #218

Draft
tomas-amaro wants to merge 4 commits into
masterfrom
parity/transport-tranche
Draft

Wire-contract parity: serializer, date split, payment_details discriminator, and error mapping#218
tomas-amaro wants to merge 4 commits into
masterfrom
parity/transport-tranche

Conversation

@tomas-amaro

@tomas-amaro tomas-amaro commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

What this is

Wire-contract parity fixes for the Java SDK, derived from the language-neutral
Riskified contract corpus and audited against the C# reference implementation
(Riskified/sdk_net @ 9165cf5). Two commits, production source plus regression
tests.

Changes

Serializer parity

  • dropoff_latitudeRideLineItem.dropoffLatitude has no @SerializedName, so Gson derives dropoff_latitude, which is correct: both OpenAPI specs declare that spelling, with a description and an example. An earlier revision of this branch pinned the transposed dropoff_latitiude instead, on the strength of the contract corpus — which had inferred the misspelling was the live wire name purely from the fact that the C# SDK sends it (RideTicketLineItem.cs:100). That inference was wrong, and the pin has been reverted: the transposed spelling appears in neither spec, occurs exactly once in all of sdk_net with no test covering it, and sits beside a correctly spelled pickup_latitude and dropoff_longitude in the same class. It is a defect in the C# SDK, not the contract. The test now asserts dropoff_latitude is emitted and dropoff_latitiude is not, guarded in both directions. The other 19 RideLineItem fields derive correctly and are locked by a test. The corpus has been corrected in Riskified/sdk-orchestrator.
  • HMAC key charsetSHA256Handler used authKey.getBytes(), the platform default, making the signature depend on a JVM locale setting. Pinned to UTF-8.
  • The date split — the contract splits 25 offset-bearing date fields from 13 naive ones, following no principle a single adapter can express. The 13 naive fields now carry @JsonAdapter(NaiveDateTypeAdapter.class); everything else keeps the offset format. Both formats render in UTC with Locale.US, so the same Date no longer serializes differently on two machines — and neither does the HMAC computed over those bytes.
  • The method discriminatorpayment_details carries no type discriminator on the wire; the variant is expressed by which keys are present. RuntimeTypeAdapterFactory injected a method key whose values disagreed with the payment_type emitted alongside. Replaced with PaymentDetailsAdapterFactory, which dispatches on runtime type and emits no label. The replacement is necessary rather than cosmetic: BaseOrder/DecisionOrder declare the field as List<? extends IPaymentDetails>, and Gson's runtime-type promotion fires only for a Class element type, not a WildcardType — simply unregistering the old factory serializes every element as {}.
  • KycDetails.updateAt derived update_at; the contract key is updated_at (KycDetails.cs:18), so KYC update timestamps were silently not arriving. Pinned with @SerializedName rather than renaming the field, which would break every caller of getUpdateAt()/setUpdateAt().
  • Customer.verifiedEmailAt / Customer.firstPurchaseAt were absent from the Java model entirely (Customer.cs:143, :167). Added as offset-bearing boxed Date, omitted when null. The date split now covers 25/25 offset-bearing fields.

Error mapping

  • The default: branch rewrote every unmatched status to 500 "Contact Riskified support" and discarded the body, so a 502, a 503 and a genuine 500 were indistinguishable. Statuses are now reported as themselves through a new RiskifiedHttpException, which extends HttpResponseException — existing catch blocks and getStatusCode() are unaffected — and carries statusText, the raw body, and the parsed error as separate structured fields that logging can redact.
  • The checkout path no longer dereferences getError().getMessage() before establishing that the body parsed as that shape. Six of the seven documented error shapes previously raised a NullPointerException in place of the HTTP error.
  • postOrder read the response body with no charset. httpclient resolves UTF-8 for application/json, but ISO-8859-1 for text/html, text/plain, and a body with no Content-Type at all — exactly the 502/503-from-a-proxy case above. Now passes UTF-8 explicitly, matching postCheckoutOrder.
  • Response.getReceived() unboxed a null Integer, raising NullPointerException on every response that omits received — which is every error response. It now returns 0, keeping the primitive return type so existing callers still compile; getReceivedOrNull() exposes the absent/zero distinction.

Deliberately unchanged

Null-handling and the 30 always-emitted defaults (open question in the corpus), the Accept header, User-Agent, the Version header, and the webhook receiver. StripePaymentDetails stays in place, minus the method key.

A before/after payload diff confirms the only key-set changes are the dropoff_latitiude rename and the removal of method.

Reviewer notes

  • Public API surface is preserved throughout — no method name or signature moved, and the two behavioural changes to existing methods (getReceived() returning 0, unmatched statuses no longer collapsing to 500) are called out above.
  • Also audited: all 28 non-derivable wire names in the corpus model catalog against their Java fields. 26 are correct. The other two — group_founder_order_id (OrderBase.cs:188) and buyer_anonymous_id (Customer.cs:155) — are not modelled in Java at all. Reported here, not fixed.
  • Branch is based on d02605f and is one commit behind master (fa18fe0). It merges cleanly.
  • The test suite was not executed in the environment where these commits were authored; please let CI be the judge.

🤖 Generated with Claude Code

tomas-amaro and others added 4 commits August 26, 2026 10:52
…rence SDK

Five wire-contract parity defects from docs/parity/FLEET.md sections 4 and 6,
all verified against the corpus in docs/flows/ and the C# reference at
sdk_net@9165cf5.

1. dropoff_latitiude. RideLineItem.dropoffLatitude had no @SerializedName, so
   Gson's LOWER_CASE_WITH_UNDERSCORES derived dropoff_latitude. The live wire
   name is the transposed dropoff_latitiude (RideTicketLineItem.cs:101) and the
   API expects it; the "corrected" spelling silently dropped ride dropoff
   geolocation with no error. Pinned explicitly, with a comment and a test that
   assert the misspelling so it is not "fixed" again. The other 19 RideLineItem
   fields derive correctly and are now locked by a test.

2. HMAC key charset. SHA256Handler used authKey.getBytes(), the platform
   default, making the signature depend on a JVM locale setting. Pinned to
   UTF-8, which matches the reference's ASCII byte for byte for the hex tokens
   Riskified issues. The corpus reference vector is now asserted.

3. The date split. The contract splits 25 offset-bearing fields from 13 naive
   ones, following no principle. One global Date adapter cannot express that, so
   the 13 naive fields carry @JsonAdapter(NaiveDateTypeAdapter.class), which
   Gson gives precedence over the registered adapter; everything else keeps the
   offset format. Both formats now render in UTC with Locale.US, so the same
   Date no longer serializes differently on two machines -- and neither does the
   HMAC computed over those bytes.

4. The method discriminator. payment_details carries no type discriminator on
   the wire; the variant is expressed by which keys are present, with a constant
   payment_type as the de-facto discriminator. The RuntimeTypeAdapterFactory
   injected a "method" key whose values disagreed with the payment_type emitted
   alongside. Replaced with PaymentDetailsAdapterFactory, which dispatches on
   runtime type and emits no label. The replacement is necessary rather than
   cosmetic: BaseOrder and DecisionOrder declare the field as
   List<? extends IPaymentDetails>, and Gson's built-in runtime-type promotion
   fires only for a Class element type, not a WildcardType -- simply
   unregistering the old factory serializes every element as {}.
   StripePaymentDetails is left in place, minus the method key.

5. Error mapping. The default: branch rewrote every unmatched status to
   500 "Contact Riskified support" and discarded the body, so a 502, a 503 and a
   genuine 500 were indistinguishable. Statuses are now reported as themselves
   through RiskifiedHttpException, which extends HttpResponseException (so
   existing catch blocks and getStatusCode() are unaffected) and carries
   statusText, the raw body, and the parsed error as separate structured fields
   that logging can redact. The checkout path no longer dereferences
   getError().getMessage() before establishing that the body parsed as that
   shape: six of the seven documented error shapes used to raise a
   NullPointerException in place of the HTTP error.

Deliberately unchanged: null-handling and always-emitted-default behaviour
(corpus open question 5), the Accept header, User-Agent, the Version header, and
the webhook receiver. A before/after payload diff confirms the only key-set
changes are the dropoff_latitiude rename and the removal of method.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ody charset

Three follow-up divergences found while doing the transport tranche, approved
for fixing after review. Same guardrails: null-handling and the 30
always-emitted defaults are untouched (corpus open question 5).

1. KycDetails.updateAt derived the wire key "update_at"; the contract key is
   "updated_at" (KycDetails.cs:18). The derived name was a key the API ignores,
   so KYC update timestamps were silently not arriving -- the same class of
   failure as dropoff_latitiude, in the opposite direction: there the SDK
   "corrected" a wire typo, here it inherited a Java-side one. Pinned with
   @SerializedName rather than renaming the field, which would break every
   caller of getUpdateAt()/setUpdateAt().

2. Added Customer.verifiedEmailAt and Customer.firstPurchaseAt, absent from the
   Java model entirely (Customer.cs:143, :167). Both are DateTimeOffset? in the
   reference, so they are boxed Date here: omitted when null, offset-bearing by
   default, no @JsonAdapter. The date split now covers 25/25 offset-bearing
   fields rather than 23/25. A before/after probe confirms the payload for a
   default Customer is byte-identical.

3. postOrder read the response body with EntityUtils.toString(entity) and no
   charset. The fallback is subtler than it first looks -- for application/json
   httpclient resolves UTF-8 from ContentType.APPLICATION_JSON's registered
   default -- but it is ISO-8859-1 for text/html, text/plain, and for a body
   with no Content-Type at all, which is exactly the 502/503-from-a-proxy and
   bare-string-OTP-error case the unmatched-status work is about. Now passes
   "UTF-8" explicitly, matching postCheckoutOrder, so the body preserved on
   RiskifiedHttpException is not mojibake.

4. Response.getReceived() unboxed a null Integer, raising NullPointerException
   on every response that omits "received" -- which is every error response. It
   now returns 0, keeping the primitive return type so existing callers still
   compile, and getReceivedOrNull() exposes the absent/zero distinction. The
   CheckoutResponse copy constructor uses the latter so absence survives.

Also audited all 28 non-derivable wire names in 01-model-catalog.md section 5
against their Java fields. 26 are correct; the other two -- group_founder_order_id
(OrderBase.cs:188) and buyer_anonymous_id (Customer.cs:155) -- are not modelled
in Java at all. Reported, not fixed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…osed override

Reverts the `@SerializedName("dropoff_latitiude")` pin added earlier on this
branch, and inverts the test that asserted it.

The pin was based on the contract corpus, which claimed the transposed
`dropoff_latitiude` was the live wire name because the reference C# SDK sends
it. That inference was wrong:

  * Both OpenAPI specs declare `dropoff_latitude`, with a description and an
    example. The transposed spelling appears in neither.
  * It occurs exactly once in all of sdk_net
    (Riskified.SDK/Model/OrderElements/RideTicketLineItem.cs:100), with no test
    covering it, while pickup_latitude (:89) and dropoff_longitude (:104) in the
    same class are spelled correctly.

So Gson's derived `dropoff_latitude` was right all along, and this branch was
about to break a field that already worked. The transposition is a defect in the
C# SDK.

  * RideLineItem: override and its DO-NOT-FIX comment removed; the now-unused
    SerializedName import goes with it. The field derives to the contract name.
  * RideLineItemTest: asserts `dropoff_latitude` is emitted and
    `dropoff_latitiude` is not -- guarded in both directions, since this branch
    already pinned it the wrong way once.
  * The wire-name map test drops the transposed entry.
  * KycDetailsTest: javadoc no longer cites the typo as a contract quirk.

The corpus has been corrected in Riskified/sdk-orchestrator.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

1 participant