Skip to content

TypeScript and Python SDK: the unary JSON-RPC client layer - #39

Merged
legendko merged 38 commits into
mainfrom
feat/ts-and-py-json-rpc-client-layer
Aug 25, 2026
Merged

TypeScript and Python SDK: the unary JSON-RPC client layer#39
legendko merged 38 commits into
mainfrom
feat/ts-and-py-json-rpc-client-layer

Conversation

@legendko

Copy link
Copy Markdown
Collaborator

Why

The TypeScript and Python SDKs could build, sign, verify and validate every RAMP request
and response — and could not send one. A caller wired the signing face into its own
fetch or httpx call and hand-assembled the Connect envelope, which is exactly the
re-derivation the SDK exists to prevent.

Every RAMP RPC is unary, so a Connect-unary JSON client — POST /ramp.v1.<Service>/<Method>, Connect-Protocol-Version: 1, JSON body — is the whole
protocol rather than a subset of it. Full Connect framing exists for streaming, which RAMP
has none of, and it would need a protobuf binary codec these SDKs deliberately do not
have.

No new runtime dependencies. Python already declared httpx + pydantic; TypeScript uses
undici/fetch with Zod.

What ships

Six verbs, the names Go uses:

verb Go TypeScript Python
discover Client.Discover client.discover await client.discover
execute Client.Execute client.execute await client.execute
report usage Client.ReportUsage client.reportUsage await client.report_usage
dispute Client.Dispute client.dispute await client.dispute
fetch Client.Fetch client.fetch await client.fetch
resolve (Broker) BrokerClient.Resolve brokerClient.resolve await broker.resolve

Entry points: @ramp-protocol/sdk-l1/client (createClient, createBrokerClient) and
ramp_sdk.client (Client, BrokerClient).

Python is async-first with a real blocking facade at ramp_sdk.sync, under the same
names — the surface the API-surface design settled and ADR-020 ratified. The facade runs on
a synchronous httpx client and is deliberately not asyncio.run around the async
core: that raises inside a running loop, so a facade built that way is unusable in exactly
the place a caller would reach for it by accident. Every line of protocol is shared; the
two faces hold only the send.

Offers go through the same core Verifier, never a second path. DiscoveryResult /
OfferGroupResult / sortGroups are ported so the per-URI answer survives in all three
languages — a refused URI keeps its group and its typed reason instead of vanishing into a
flat list.

Three defects found on the way, each verified before it was fixed

1. Three protocol fields were missing from both generated clients. Offer.title,
ResourceEntry.title and UsageAsset.title are proto fields whose NAME is title, and
the merge step removed every key called title — meaning to drop the JSON-Schema keyword
and taking the fields with it. No corpus case populates any of them, and corpusgen only
emits cases for fields carrying a protovalidate rule, so the canonical round-trip gate had
nothing to lose and stayed green. A new conformance test now asserts every contract field
reaches both clients.

2. The typed error reason was silently dropped, in both languages. Connect's debug
projection is lowerCamelCase and no server option changes itconnect-go renders it
with its own protojson codec at default options, inside a method on an unexported type,
so the snake_case codec a deployment registers reaches the response body and not the error
beside it. Both readers parsed it with a snake-only schema, so transactionDenial was
removed as an unknown key by the forward-compatibility policy: the parse succeeded,
carrying domain and message and no reason at all, for a refusal the Exchange had named
precisely. Eight of the ten corpus vectors lose their reason without the fix.

3. The three clients disagreed on Content-Type reduction — surfaced by the new
content corpus on its first run. The cause was that the rule had never been stated: Go
delegated to mime.ParseMediaType, which reports a malformed parameter as an error
beside a perfectly good media type (so text/plain; ; answered "unknown", discarding the
one thing the field carries because of the part the function is defined to ignore) and
accepts a bare token with no slash, which is not a media type at all. Neither is a
decision; neither is reproducible elsewhere without inheriting the quirk. The rule is
stated instead — the text before the first ;, trimmed and lowercased, must be
token "/" token — and all three follow it.

Two things the client had to get right

A wire offer is not the signed offer. A RAMP Exchange serves proto-JSON with
EmitUnpopulated, so what arrives carries zero-valued scalars, empty repeateds, null
messages and *_UNSPECIFIED enums the signature never covered. The from-wire inversion
already existed for exactly this and said so in its header; the client is its first caller.

A schema parse is a gate, not a source. Zod and Pydantic fill every declared default,
so verifying the parsed answer adds keys the signer never covered and fails every genuine
offer. Offers are verified from the raw answer; the parse still runs, because it is what
proves the answer well-formed and its field names canonical.

Behavior change reviewers should know about

A lowerCamelCase response body is now REFUSED (not_canonical_wire_naming). The RAMP
wire is snake_case proto-JSON and the json_name alias is out of contract, but a stock
connect-go server that registers no UseProtoNames codec serves the alias — and the
generated schemas accept snake_case only and strip what they do not know, so such an answer
would otherwise parse successfully into a message with no offers, no rate limit and no
reason, reporting nothing wrong. The check is at the message root, where open maps (ext,
metadata, a Struct) cannot reach it.

Checked against the reference implementation: src/exchange/cmd/server/main.go,
src/broker/cmd/server/main.go and the raw mount all already register the codec, so this
is not a downstream break. It does become a standing requirement for any new JSON-serving
listener.

A null now means the field has no value, at every depth and whatever the field holds.
The canonical wire is proto-JSON, where a null is a field's default for any field, so the
generated schemas are parsed under a policy that drops it wherever the schema does not
require a value and leaves it for the schema to refuse where it does. The generated schemas
themselves are unchanged; parseWire() is what applies it, and @ramp-protocol/sdk exports
./wire/base so a consumer that parses directly gets the same seam.

Outbound request validation is on by default in TypeScript and Python and off in Go
recorded in docs/design-history.md, with the reasoning in
DECISION-outbound-validation-default.md. A consequence of the null rule: a null on an
optional request field is dropped rather than locally refused before sending, which is
what Python already did.

Also in scope, because it was reachable from this work

  • Nine TypeScript modules the root export map could not reach are now importable. The
    repo carries two manifests; the root one is what a consumer resolves through, and it had
    drifted. exports carries no "." and no wildcard, so Node treats it as an exhaustive
    allowlist — an unlisted subpath fails with ERR_PACKAGE_PATH_NOT_EXPORTED and there is
    no deep-path workaround. errordetail alone holds nine symbols the parity matrix listed
    as cross-language. undici was missing from the root dependencies too, so the
    already-exported ./resolvers could not resolve its transport once installed. The debt
    list that recorded the nine is now empty.
  • Client-side request validation, against the generated model, defaulting to strict —
    deliberately stricter than Go, which defaults to off, because these two SDKs are the ones
    handed to external partners. It is a field-level check, not protovalidate's cross-field
    CEL, so "strict" here does not mean what it means in Go. Recorded in
    docs/design-history.md.
  • The stale note on WellKnownManifest.endpoint explaining a workaround that no longer
    applies is gone, and the proto's wire-format note gains its one exception.

Hardening

The first eight commits are the client. The remaining twenty-eight harden it, across five
review passes. Everything below was reproduced against running code before it was changed,
and each fix was reverted afterwards and required to fail a test — mutation, not the suite,
is what gates this half of the branch.

Reading a real server's answer. The generated schemas rejected the shapes a conformant
Exchange actually serves. null for an unset field was the first (see the behavior note
above); a lowerCamelCase answer was the second, and the refusal had to run at every depth,
because a stock connect-go server omits unset fields — so a TransactionResponse arrives
as {ver, items}, every root key a single word and identical in both spellings, while
transaction_id sits one level down and is silently dropped. That reads as a purchase that
succeeded with no transaction id and no delivery URL, severing the dispute chain at its
first link.

Bounds against a peer that is not friendly. Every one of these was measured:

before after
gzip bomb through the delivery leg 420 MiB resident 6 MiB — the coding is refused before a byte is read, since a decoder expands a whole raw read at once
a 40 KB deeply-nested JSON body raw RecursionError out of every Python verb, on success and error paths a typed malformed, from a lexical depth scan placed before the parse
__proto__ appended to a signed offer verified, and the caller then read attacker-chosen values off a VerifiedOffer rejected
six refused delivery answers six sockets held open forever, on a process-wide dispatcher released; with a bounded pool one refusal used to wedge it permanently, and the call's own deadline did not break it
the error-detail reader unbounded recursion on a hostile detail chain bounded

Nothing signed reaches a plaintext endpoint. The scheme gate sits above the dial in
all three languages, so no injected transport can remove it — in TypeScript above the send,
in Python above the injected httpx client on both the async core and the blocking facade.
An injected Python client could previously carry a signed usage report over http://. The
endpoint an injected resolver hands back is re-checked against the same rule the SDK's own
resolver applies, and the SDK carries one agent private key: the one the request is signed
with.

One answer per verb, in all three languages. A refused redirect is unreachable on
every leg rather than a token promoted out of the redirect body; a gateway draining is told
apart from a service saying no; hex decoding follows one rule instead of three; the
rate-limit standing is decoded the way the oracle decodes it, except reset_at, the one
member a parse cannot hand back unchanged. Delivery-leg failures are typed like the RPC
legs' — a mid-body deadline used to raise the dialing library's own error straight out of
fetch.

Gates that measure what they claim. Three of these rounds each turned up a test that
asserted less than its parity row claimed, so the bar became mechanical: every corpus column
must be read by every language's replay, not merely named. The API-surface gate was blind
to 46 exported Go enum members rendered under TYPES rather than CONSTANTS; they are now
classified, with the divergence allowlist unchanged.

Conformance

Six new corpora, all captured from the real Go faces rather than written by hand, all
replayed in Go + Python + TypeScript (there is no exemption mechanism):

corpus pins
connect/testdata/connect-error-vectors.json the Connect error envelope, incl. the camelCase debug projection — the corpus that would have caught defect 2
connect/testdata/client-request-vectors.json where each verb sends and what it stamps: the RPC path, ver, and fill-when-empty on the idempotency key
connect/testdata/transport-failure-vectors.json how a real connect-go client classifies each transport failure, so the ports answer the same class
resolvers/testdata/content-fetch-vectors.json which delivery answers are a refusal, which edge tokens the SDK will repeat, what a Content-Type reduces to
helpers/testdata/wire-names-vectors.json the proto-name recovery behind the camelCase refusal, and one hex rule for all three
helpers/testdata/wire-null-vectors.json the bodies the codec emits with a null in them, and that every SDK reads them

sdk/go/connect/testdata is a third corpus home: the Connect envelope can only be captured
from a package that imports connectrpc, which the Connect-free tiers may not. Registered
in the completeness gate, the non-empty gate, the matrix generator and the CI
vector-currency step.

Two new descriptor-driven conformance guards: every contract field reaches both generated
clients, and json_name inverts back to every field's name (which is what makes the
debug normalization sound — it would not hold for a field like field_2).

Parity record

connect.Client and connect.NewClient carried an OPEN decision saying the
API-surface design governs and specifies the same verb names in all three languages, so
what diverged was the transport, not the API. That is now true rather than pending, and the
record catches up: the client types, the failure a caller branches on, the endpoint seam,
the bounds and the content value are mapped in both languages; the Go-idiomatic remainder is
reclassified for what it is.

The allowlist of documented divergences is unchanged at 14 — the client resolved one
and the Broker constructor's Python fold added one, the same ctor-fold shape the resolvers
already carry. It is a shrink-only ratchet, and the 46 enum members the surface gate had
been blind to were classified as Go-idiomatic exclusions rather than added to it.
docs/sdk-parity-matrix.md is regenerated, not hand-edited: 116 symbols at parity, 173
exclusions, 33 corpora each tri-replayed.

The guard that kept the client's absence loud now guards the reverse. Its assertions had
gone vacuous — it only ever checked that a DECISION bullet existed, and other bullets
satisfy that — so it would have watched the client silently reclassified as Go-only without
a word.

Not in this PR

  • Catalog verbs (PushResources, RemoveResources, RefreshCatalog) — RAMP-209. The
    ticket's Scope bullet named them, but its own Acceptance criterion is "every verb the Go
    client exposes", and Go exposes none of them.
  • Register / GetAccountStatus — RAMP-211.
  • Bounding the RFC 9421 signing window — RAMP-194. Go's client does not adopt
    MonotonicWindow either, so mirroring Go means the clock window plus an injectable one.
  • Downstream re-pin and adoption — planned separately in DOWNSTREAM-RAMP-210-PLAN.md.

Review order

Thirty-six commits, each independently green. They fall in two halves, and the first is
where the design lives.

The client — read these in order:

  1. c92a05d — the generated-types title fix, and the three deleted fields
  2. b71ac20 — the root export map
  3. f12ed50 — the typed reason Connect actually puts on the wire, + its corpus
  4. 9840b32 — the per-URI discovery answer in TS + Python core
  5. 3ca67fe — the TypeScript client
  6. bb659ce — the Python client, async + blocking facade
  7. 8e28d3d — the parity record and the proto/website notes
  8. 077d00f — the client-request and content-fetch corpora

The hardening (596c9de..1ed3804) is twenty-eight commits and does not need reading in
order — each stands alone, and each names the behavior it changes rather than the review
that found it. If you want the load-bearing ones: 596c9de and 1ed3804 (what a client
accepts off the wire), e5dca1e and bc39865 (what a hostile peer can spend), c3fd431
and dbf7536 (nothing signed over plaintext), and 73589b1 (the gates that were measuring
less than they claimed).

Verification

./scripts/ci-local.sh                       # PASS
go test ./...
cd sdk/ts && npm install && npm test        # 1296 tests
cd gen/ts && npx vitest run                 # 594 tests
cd sdk/python && PYTHONPATH=../../gen/python:. pytest -q   # 1403 tests
cd website && npm ci && npm test && npm run build

# CI-only (needs `go doc`), run explicitly:
cd sdk/python && PYTHONPATH=../../gen/python:. uv run pytest \
  tests/test_api_surface_parity.py tests/test_parity_matrix_generated.py -q

126 files, +15130 / −464.

… title

protoschema splits a multi-paragraph proto comment: paragraph one becomes the
JSON-Schema `title`, the remainder becomes `description`. The merge step removed
every key named `title`, which cost two different things.

The head of every multi-paragraph comment, in 18 fields and 23 messages. Offer's
`signature` reached Pydantic and Zod describing the canonical-signing rules
without ever saying the field IS the signature or that it is required. Go was
unaffected — ramp.pb.go carries the whole comment — so the loss showed only in
the two generated clients.

And three whole fields. Offer.title, ResourceEntry.title and UsageAsset.title are
proto fields whose NAME is `title`, so a blanket key-strip deleted them from
`properties` outright. No corpus case populates any of them, and corpusgen only
emits cases for fields carrying a protovalidate rule, so the canonical round-trip
gate had nothing to lose and stayed green.

A title is now carried into its description unless it merely restates the field's
own enum or message type, which protoschema fills in when the comment has a single
paragraph. Type-derived is decided by comparing the title's spaces-removed form
against the type's simple name — exact, and unlike a Title-Case shape test it
reads "C2PA Status" as C2PAStatus.

Two guards keep it: the merge fails before writing if any title survives, and the
conformance suite asserts every contract field reaches both generated clients,
which is the invariant the deleted fields violated.

WellKnownManifest.endpoint's comment was collapsed to one paragraph to work around
the loss; the note explaining that is no longer true, so it goes.
The repo carries two manifests. sdk/ts/package.json is the development-time map;
the root one is what a consumer resolves through, since npm has no
git-subdirectory support and the package is pinned as a whole-repo git dependency.
The root map was written once as a snapshot and never updated as sdk/ts grew.

Nine subpaths were declared in one and absent from the other. `exports` carries no
"." entry and no wildcard, so Node treats it as an exhaustive allowlist: an
unlisted subpath fails with ERR_PACKAGE_PATH_NOT_EXPORTED and there is no
deep-path workaround. The files ship inside the tarball and are simply not
addressable — errordetail alone holds nine symbols the parity matrix lists as
cross-language, so for those the matrix over-claimed.

undici was missing from the root dependencies too, so ./resolvers — which WAS
exported — could not resolve its transport once installed.

The debt list that recorded the nine is now empty. Its own gate proves entries are
used and necessary, so it cannot quietly absorb the next drift.
A JSON-only SDK has no protobuf binary codec, so it cannot open an error detail's
`value` — a base64 Any — and must read Connect's `debug` projection instead. That
projection is lowerCamelCase, and no server option changes it: connect-go renders
it with its own protojson codec at default options, inside a method on an
unexported type, so the snake_case codec a RAMP deployment registers reaches the
response body and not the error beside it.

The generated ErrorDetail model accepts snake_case only, and both clients are
forward-compatible by policy — extra="ignore" in Pydantic, .strip() in Zod — so
`transactionDenial` was removed as an unknown key. The parse SUCCEEDED, carrying
`domain` and `message` (single words spell the same either way) and no reason at
all, for a refusal the Exchange had named precisely. That is a fail-open read of
the one field a caller is told to branch on, and every suite passed.

Both readers now recover the proto names before parsing. Keys inside `metadata`
are left verbatim: that member is a map, so its keys are the emitter's data, not
field names.

The rewrite is textual, so two facts it depends on are now held by the conformance
suite: that protojson's spelling inverts back to every contract field's name — it
would not for a field like `field_2` — and that `metadata` is the only open map in
the ErrorDetail subtree.

The vectors are captured from a real connect-go handler rather than written by
hand, so what is asserted is what the wire does. They live in a third corpus home:
the envelope can only be produced by a package that imports connectrpc, which the
Connect-free tiers may not. Eight of the ten lose their typed reason without the
fix.
A discovery call is per-URI: an agent asks about several resources at once and
the answer comes back grouped, one group per requested URI, each either carrying
offers or carrying a typed reason it carries none. Only Go could express that.
Python and TypeScript had the flat {verified, rejected} split and nothing to
hang a per-URI reason on.

A flat list is not a smaller version of the same answer, it is a different one. A
refused URI has no offer to carry it back, so flattening erases the URI entirely
— and with it the difference between "not in the catalogue" (give up), "scope
insufficient" (acquire an entitlement and retry) and "content blocked" (never
retry), which all read alike as "found nothing".

Every group is sorted by the SAME Verifier rather than one per group: it is
stateless apart from the injected resolver and clock, so a fresh one per group
would mean N resolver caches and N clock readings for a single logical answer —
and it is what keeps the fail-closed split from being re-implemented per group.

A responder that stated no reason says nothing, rather than saying unspecified.
Go keeps a pointer to tell those apart; here the generated enums carry no
UNSPECIFIED member at all, so a present value is always a real reason.

A malformed group element is skipped rather than surfaced with an empty URI:
inventing an answer the responder never gave is worse than dropping one.

The two shapes were recorded as Go-only pending exactly this work, so they move
out of the exclusion list.
The TypeScript SDK could build, sign, verify and validate every RAMP request and
response and could not send one. A caller wired the signing face into its own
fetch and hand-assembled the Connect envelope — the re-derivation the SDK exists
to prevent.

Every RAMP RPC is unary, so a Connect-unary JSON client is the whole protocol
rather than a subset of it: POST to the method path, JSON body, Connect error
envelope on the way back. Full framing exists for streaming, which RAMP has none
of, and it would need a protobuf binary codec this SDK deliberately does not
have. Six verbs, the names Go uses: discover, execute, reportUsage, dispute and
fetch on the Exchange client, resolve on the Broker's.

Offers are verified by the SAME core Verifier, never a second path. Two things
that took getting right:

A wire offer is NOT the signed offer. A RAMP Exchange serves proto-JSON with
EmitUnpopulated, so what arrives carries zero-valued scalars, empty repeateds,
null messages and UNSPECIFIED enums the signature never covered. The from-wire
inversion already existed for exactly this and said so in its header; the client
is its first caller.

A schema parse is a gate, not a source. Zod fills every declared default, so
verifying the PARSED answer adds keys the signer never covered and fails every
genuine offer. The offers are verified from the raw answer; the parse still runs,
because it is what proves the answer well-formed and its field names canonical.

A camelCase answer is REFUSED. The RAMP wire is snake_case proto-JSON and a
stock connect-go server — one that registers no snake_case codec — serves the
json_name alias instead. The generated schemas accept snake_case only and strip
what they do not know, so such an answer would otherwise parse successfully into
a message with no offers, no rate limit and no reason, reporting nothing wrong.
The check is at the message root, where open maps cannot reach it.

Reports and disputes go where the signed message says, resolved from that
Exchange's own manifest and re-vetted, over a separately guarded transport that
refuses redirects — following one would re-sign the call for a target the peer
chose. Their address is vetted before their schema: an unroutable recipient is a
refusal to send, which is a different verdict from a message a server would
reject.

The client tree joins the resolvers as a second IO-bearing tree, which the leaf
guard now says, and the typecheck now reaches it — the tsconfig include listed
every other tree and would have compiled none of this.
The Python SDK could build, sign, verify and validate every RAMP request and
response and could not send one. Same six verbs as the Go and TypeScript
clients, over the same Connect-unary JSON transport: every RAMP RPC is unary, so
that is the whole protocol rather than a subset of it.

Async is the core and the blocking face is a facade over a synchronous httpx
client, which is what the API-surface design settled and ADR-020 ratified. It is
deliberately NOT asyncio.run around the async core: that raises inside a running
loop, so a facade built that way is unusable in exactly the place someone would
reach for it by accident. Every line of protocol is shared — the stamping, the
routing, the request check, the signing, the decode, the verification — and the
two faces hold only the send, which is what stops them becoming two dialects.
The suite runs every case through both.

The tiers this composes are synchronous by design: the offer Verifier, its key
resolver and the well-known endpoint resolver all block on httpx. The async
client reaches them through asyncio.to_thread rather than growing async twins.
Both resolvers are lock-guarded and cache, so the steady state costs no threads
at all, and a twin of each would be a Python-only public face with no Go or
TypeScript counterpart — surface the parity map would carry forever for a seam a
thread already crosses correctly.

The two things the TypeScript client had to get right apply here unchanged: a
wire offer is not the signed offer, so the from-wire inversion runs before
verification, and a model parse is a gate rather than a source, because Pydantic
fills declared defaults that the signer never covered.

Validation is the same word in all three now. Go and TypeScript name the
strictness, so Python does too rather than carrying it as a bool — one fewer
documented divergence for a difference that was only spelling.
The parity map called the typed client a Go-only divergence and the matrix
followed it. That entry said in its own words that it was OPEN and that the
API-surface design governed — the design specifies the same verb names in all
three languages, so what diverged was the transport, not the API. All three ship
a client now, so the record catches up: the client types, the failure a caller
branches on, the endpoint seam, the bounds and the content value are mapped in
both languages, and the Go-idiomatic remainder is reclassified for what it
actually is — functional-option types, a type alias, an interceptor whose
validation the JSON clients do perform without one.

The allowlist of documented divergences is unchanged at fourteen: the client
resolved one and the Broker constructor's Python fold added one, the same
ctor-fold shape the resolvers already carry.

The guard that kept the absence loud now guards the reverse. Its assertions had
gone vacuous — it only ever checked that a DECISION bullet existed, and other
bullets satisfy that — so it would have watched the client silently reclassified
as Go-only without a word. It now asserts the symbols are mapped, carry no
allowlist reason, and are absent from the exclusions.

The proto's wire-format note gains its one exception. The RAMP wire is snake_case
proto-JSON, and that is still true of every message; the `debug` projection
Connect attaches to an error detail is not, and no server option changes it. A
reader relying on that note needs to know where it stops.
Two corpora, both captured from the real Go faces rather than written by hand,
and both replayed in all three languages.

Where each verb sends, and what it stamps. "The same verbs, with the same names"
is the whole claim the three clients make to each other, and neither half of it
is visible from the exported surface the parity map compares: a client can export
reportUsage and address the wrong RPC, or overwrite an idempotency key the caller
supplied — which turns each of their retries into a second report, because the
key identifies the action rather than the attempt. The body's bytes are
deliberately not pinned, since Go serializes through protojson and the two JSON
clients build the object directly.

How a delivery refusal reads. This is the one leg where the peer's own words are
promoted over the SDK's classification, so three things have to agree: which
answers are a refusal, which tokens the SDK is willing to repeat — the body is
written by the host just fetched from, so prose must not render as though the SDK
had said it — and what a Content-Type reduces to.

That last one is why the corpus earned its keep on the first run. The three
disagreed, and the reason was that the rule had never been stated: Go delegated
to its stdlib parser, which reports a malformed PARAMETER as an error beside a
perfectly good media type. Honouring that answers "unknown" for `text/plain; ;`,
discarding the one thing the field carries because of the part the function is
defined to ignore, and the same parser accepts a bare token with no slash, which
is not a media type at all. Neither is a decision, and neither is reproducible
elsewhere without inheriting the quirk. So the rule is stated instead — the text
before the first semicolon, trimmed and lowercased, must be token "/" token — and
all three follow it. The vectors that made it visible are in the corpus.
Two things are true of the RAMP wire that a generated schema cannot say, and both
now live with the schemas rather than beside them.

An unset message field arrives as null. proto3 JSON with EmitUnpopulated — what
an Exchange serves — renders every unpopulated message, Struct and map as null
instead of omitting it, so {"ext":null} is the ordinary shape of a response and
not a malformed one. Every generated Zod schema rejected it, which meant the
TypeScript client could not complete a single call against a conformant server:
the answer failed its own schema. Nothing caught it because no test parsed a
body the platform codec had actually produced. Python was unaffected; it spells
those fields X | None already.

The lowerCamelCase refusal was root-only, and the case it could not see is the
money verb. A stock connect-go server registers no snake_case codec and omits
unset fields, so a TransactionResponse arrives as {ver, items} — every root key
a single word, identical in both spellings — while transaction_id sits one level
down in TransactionResultItem and is dropped by the forward-compatibility
policy. That reads as a purchase that succeeded with no transaction id and no
delivery URL: the dispute chain severed at its first link, silently, on the one
call that moves money.

Both rules are applied at every depth now. Open maps need no hold-back list:
the pass is schema-driven, so it stops at a map, and a null inside a Struct
stays the value it is. Python needs no walk at all — every generated model
inherits WireModel, so a validator there recurses by inheritance — while
TypeScript recurses itself, because the repo runs Zod 3 in the SDK trees and
Zod 4 in the canonical round-trip gate and the two disagree about how a schema
is rebuilt.

The field-name rule moves to gen/*/wire/names.*, one implementation per
language, re-exported for the SDK's own reader of the alias. Its boundary test
is ASCII A-Z: protojson builds json_name by uppercasing after an underscore and
a proto field name is [a-z_][a-z0-9_]*, so nothing else can be a boundary — the
broader tests the three previous copies used disagreed on characters protojson
never emits.

The generated-field guard now reads a message's own keys instead of searching
the line. json-schema-to-zod inlines every nested message into the same
expression, so a substring search let a nested field of the same name stand in
for a missing outer one, and ext, ver and exchange recur throughout the
contract.
…y named

ssrfGuard is undici's CONNECTOR. It decides what a hostname may resolve to and
never sees a scheme, because by the time a connector runs the URL has already
been reduced to a host and a port. The client tier composed it and nothing else,
so a guarded send to http://example.com completed — carrying the RFC 9421
signature header, in cleartext.

Go states the scheme decision in schemeGuardRoundTripper and Python in
_SchemeGuardTransport, both wrapping the transport so it holds for whatever base
a caller injected. TypeScript dials undici directly on these two legs, so the
gate is stated at the dial: https always, plaintext http only under
ALLOW_INSECURE, every other scheme denied. One predicate, still stated once in
the resolvers tier.

Which legs, mirroring Go exactly: the offer-derived RPC leg and the delivery
leg, whose hosts another party names. The configured home Exchange keeps its
plain transport, as it does in Go, because an operator that points the SDK at a
private origin chose that address.

On the delivery leg the gate runs before the proof is minted rather than before
the dial. mintProof already documented that ordering — "the proof is minted
AFTER the URL has been accepted as dialable" — and nothing was making it true,
so a URL that could never be used still cost a signing operation, which may
reach a custody backend. The refusal reports unreachable, matching Go, where the
same refusal surfaces through the transport, and it does not echo the URL: a
delivery URL carries a live credential in its query.

This also closes a gap against published documentation. The threat model states
the shipped SDK guard "enforces an http(s)-only scheme allowlist on the initial
URL and every redirect hop" and names ssrfGuard() as the seam; in TypeScript
that was true of the resolvers and not of the client.
An EndpointResolver is an injectable seam, and the SDK's own docs offer it — a
caller can drive reporting without standing up a manifest server. TypeScript and
Python then sent a SIGNED usage report or dispute to whatever that
implementation returned, unchecked. Go has always re-applied the endpoint rule
there, and says why: this package cannot make a signed call conditional on a
stranger's implementation having remembered it.

What that let through is the whole reason the rule exists. An off-host endpoint
receives a signed call the Exchange never advertised. An endpoint carrying
userinfo passes the host comparison — which reads the authority and ignores any
user:password before it — and then has the HTTP client stamp an Authorization
header the SDK never chose, on a leg already carrying the caller's own
signature.

The rule now lives in one module per language instead of inside the resolver,
because it has two callers checking the same value for different reasons: the
resolver checks what a manifest advertised, the client checks what its resolver
returned. Both read one parse of the reference, which is load-bearing — a value
naming no scheme is a URL to one parser and a path to another, and the two
answers put a credential on opposite sides of the check. Go keeps it in a shared
internal package for exactly this reason; the note in both ports saying they had
only one call site is no longer true.

A refusal is not_sent, never unreachable. It is a verdict, and reporting it as a
transport failure would tell a caller to retry something that can never succeed
— the same argument the resolve path already makes for classifying by cause.

The endpoint-vet corpus now replays through the CLIENT as well as the resolver,
in all three languages. Its 30 vectors already pinned what the rule decides; what
nothing pinned was that the client applies it at all, which is precisely the gap
here. Go replays it too, so the property is guarded in the oracle rather than
only mirrored from it.
…with

The protocol carries a single agent identity. agent_identity_hash is defined as
the thumbprint of the agent's request-signing key, an Exchange verifies the
detached acceptance against the key registered for the caller its request
signature identified, and a delivery URL is bound to that same thumbprint. A
second key is refused at execute, and any URL it did produce could never be
fetched — the presented key would not match the binding. Go says exactly this
where it declines to offer one, and holds to it: the acceptance is signed by the
injected Signer, and only the PUBLIC half is supplied alongside, because custody
keeps the private one and a Signer cannot yield it.

Both ports carried a second private key. Python took an agent_seed separate from
the signer and REQUIRED it — for the acceptance and for the delivery proof —
raising not_signable when it was absent even with a signer configured. Nothing
bound the two, so a caller could configure two identities and learn about it
from a server refusal. TypeScript took a whole agentKeyPair for the delivery
proof while signing requests and acceptances with signer.privKey, so the same
divergence was one field away.

Python now signs both through the transport that already holds the key, which
keeps custody in one place and gives the client no key material to handle.
TypeScript takes agentPublicKey — the public half only, mirroring WithAgentKey —
and mints the proof with the signer's key. Every existing test already passed
the same key material twice, which is the shape the option should have had.

Two things found while doing it.

Nothing exercised a successful delivery fetch in TypeScript; the only coverage
was the refusal when no key was configured. Both languages now drive a real
fetch and assert the presented x-ramp-agent-key IS the signer's public key,
which is the property that was silently untrue.

And the TypeScript client tier ignored SKIP_SSRF. Go reads it in
NewGuardedTransport and Python in guarded_client, so TypeScript was the one
place a documented deployment opt-out did nothing. The scheme gate stays
separate from it, as it is in both other languages: ALLOW_INSECURE is its own
decision.
…e home one

A cap compared against response.content is a measurement, not a limit. httpx
buffers the whole body before that attribute exists and decompresses on the way,
so the allocation has already happened by the time the comparison runs. Measured
against a local server: 203861 gzipped bytes on the wire became 209715200 in
memory and 420 MiB of peak RSS, and only then did the 1 MiB check fire. Three of
these legs dial a host another party named — an offer-derived Exchange, a
delivery edge — which is the case the bound exists for, and the refusal path
read its whole body before slicing 4 KiB out of it.

Every leg now streams and stops one byte past the cap. Detected, never
truncated: content that looks whole but is not is worse than a refusal, because
the caller cannot tell and on the delivery leg has already paid for it. A
refusal BODY is truncated instead, deliberately — it is a small JSON object
carrying a reason token, and an edge that answers with a huge error body should
still get its refusal reported rather than swapped for a size complaint.

Every leg also asks for identity encoding. Bounding the decoded stream already
holds memory; asking for no coding makes the cap count wire bytes too, and it
keeps the three languages reading the same answer, since undici does not decode
a content coding and TypeScript would otherwise be handed octets it then blamed
the peer for.

Two transports now, mirroring Go. The configured home Exchange and the Broker go
over a plain one — an operator that points the SDK at a private origin chose
that address — and the offer-derived legs and the delivery fetch over an
address-guarded one. One guarded client for everything looked safer and was not:
it refused a home Exchange that Go and TypeScript reach, and injecting a client
to get that back disarmed the guard on the leg that actually needed it. An
injected client still carries both legs, because a caller who replaced the
transport replaced it.

The guard's own refusal is now typed. SsrfError subclasses OSError, not
httpx.HTTPError, so it escaped every send site raw and broke the contract this
package states — that every verb raises CallError and nothing else. Nothing was
sent, which is exactly what not_sent means.

Both faces gained close() / aclose() and the context-manager protocols. Every
client built before this leaked its connection pool, and `async with Client(cfg)`
raised TypeError. A transport the SDK built is closed with it; an injected one is
left alone.

The send, the bounded read and the lifecycle now sit on a shared base in each
face rather than being written twice — BrokerClient.resolve had a whole copy of
the send because _send lived on Client. What the blocking facade holds is the
iteration and nothing else, which is what the module promises.

The new tests drive real HTTP against a local server. The defect was invisible to
a mock transport, which hands back a body that is already in memory.
Both languages document that every verb raises exactly one failure type. Three
places broke that, and each is reached from bytes a peer chose.

A debug projection that is a well-formed object but not an ErrorDetail went
straight into the generated model with no guard, so it raised a validation error
out of the library — on a path taken for EVERY non-2xx, which is precisely where
a hostile peer operates. It is now no answer rather than an exception: raising
there replaces the typed failure the caller is about to receive with an untyped
one from a package they never called. The scan also keeps going past it, as the
TypeScript reader always has — details is a list, and an entry that does not
decode says nothing about the next.

A key resolver that raises rejected the whole answer instead of one offer. The
shipped resolvers raise on a network failure, and on a Broker fan-out the
exchange comes off a relayed offer, so a single Exchange whose key endpoint hangs
denied the agent every offer in the response — as an untyped exception out of a
call that promises a typed one. Go returns the resolver's error as that offer's
rejection reason and moves on; both ports now do.

And a wire key naming an inherited object member — __proto__, constructor —
resolved through Object.prototype in the from-wire inversion, so the
unknown-field branch was skipped and the walk was handed something with no
schema. An offer comes from a peer, so the key is attacker-chosen, and the result
was a raw TypeError. The lookup asks whether the shape owns the name.

Python's guard refusal is also typed now, and reports unreachable. SsrfError
subclasses OSError rather than httpx.HTTPError, so it escaped every send site
raw. The class is what Go answers, measured rather than assumed: there the same
refusal comes out of the RoundTripper, so the client reads it as a dial that did
not happen. Nothing was sent either way, and a caller who retries gets the
identical refusal.
A non-2xx whose body is not a Connect envelope did not come from the service. It
is a load balancer draining, a gateway with no upstream, a proxy returning its
own HTML page. Both ports called any non-JSON body malformed and any JSON without
a code a refusal, which puts a momentary 502 in the "this peer is broken" class —
so a caller stops retrying a usage report that would have succeeded a second
later, the outcome the routing module argues at length must not happen.

connect-go already decides this, and it does not guess: for a body it cannot read
as an envelope, and for an envelope carrying no code, it derives the code from
the HTTP status. Both ports now do the same, and the new corpus is CAPTURED from
a real connect-go client rather than transcribing that table into three
languages — nine answers a deployment's own infrastructure gives, four of them
transient and five final. Each vector pins the class and the consequence
together, so a replay comparing only the label would still fail if the two
classes swapped meanings.

Also here, each a place one language answered differently for a reason nobody
chose:

The call deadline COVERS signing rather than starting after it. Signing may reach
a custody backend, and a timer started afterwards gave the send a fresh full
budget on top of whatever signing had already spent. It does not interrupt
signing — WebCrypto takes no signal — so what this bounds is the total, which is
the property Go gets from passing one context through both.

An empty pinned idempotency key is no key. `??` took "" as a value and sent it,
failing the message's own min(1); Go and Python both fall through to the next
source.

The RPC path is joined onto the base URL rather than concatenated. A base
carrying a query left the path inside the query string, so the call reached the
origin's root — a signed request at an address nobody chose.

Python's delivery leg refuses an unparseable URL and one that does not
re-serialize to itself, which Go and TypeScript already did. It checks with
httpx's own parser, because the question is whether the value the TRANSPORT will
put on the request line still matches the bytes the proof covered; urllib
round-trips a raw space that httpx percent-encodes, so it would miss the case.

Python's edge-reason anchor is \Z rather than $, which also matches before a
trailing newline. The token is echoed into a caller's logs, so the anchor that
admitted a newline was admitting log injection. Pinned by a vector, since the
three languages disagreed silently.

A discovery answer is parsed once instead of twice, and its rate limit comes from
the parsed message like TypeScript's — it is the SDK's own answer to the caller
rather than bytes a signature covers.

The size-cap failure names the verb, and the send interface now states that an
implementation must honour the abort signal: the tier above sets the call
deadline on it and has no other way to stop a send that never returns.
Four guards passed while the thing they guard was broken.

The io-leaf detector required a trailing separator, so `from "../client/x.ts"`
was caught and `from "../client"` was not — the extensionless directory form,
which is exactly what the package export map publishes and therefore the
spelling a consumer is told to use. `from "../resolvers"`, `import "undici"` and
`await import("undici")` slipped the same way. A guard that misses the ordinary
spelling reports zero offenders either way, which is worse than not having one.
Its meta-tests now exercise each of them.

The manifest guard compared export maps and nothing else, but the bug it was
written after was not an export-map bug: `./resolvers` was declared in both maps
and still failed to import, because the root manifest did not depend on undici.
It now compares the dependency blocks too — and found a live divergence on its
first run, `hono` marked optional in sdk/ts and not at the root, which decides
whether an install succeeds at all.

The lowerCamelCase inverse and the hex decoder existed as one transcription per
language, and they did not agree. Two tested an ASCII-uppercase predicate and one
tested "not equal to its own lowercase", which answer differently for a titlecase
character. Go and Python refuse a hex signature carrying a sign, whitespace or an
odd length; parseInt accepted all three, so TypeScript read a signature where the
other two read garbage — and both rules are applied to bytes a PEER chose. There
is now one corpus for both, replayed by all three SDKs, and the boundary is ASCII
A-Z because that is the only thing protojson can produce: it capitalizes after an
underscore, and a proto field name is [a-z_][a-z0-9_]*.

The descriptor guard over that inverse transcribed the rule instead of reading
one, which is what the repo's own template for this exists to avoid. It replays
the same corpus now — read as data, so conformance still depends on nothing under
sdk/ (verified) — while keeping its own descriptor sweep, which remains the
authority for what the contract requires.

And the parity gate could not see `ramp_sdk.sync` at all. It is a public face a
caller imports by name, so every symbol living only there — the blocking Client
and BrokerClient among them — was outside the surface the gate compares.

The generated matrix header also still named two corpus homes after a third was
added, which is the kind of drift the generator exists to prevent.
The changelog gains an Unreleased entry, mirrored into the site. Six things
reach anyone re-pinning and none of them moves a field, a message or an
encoding: an unset message field may arrive as null and the generated schemas
now accept it; a lowerCamelCase answer is refused at every depth; three fields
named title exist in the generated models for the first time; Go's mimeTypeOf
narrowed; a hex signature carrying a sign, whitespace or an odd length is
refused in TypeScript as it already was elsewhere, and Python's edge-token
anchor no longer admits a trailing newline; and nine TypeScript modules became
importable.

design-history said the resolvers are the ONLY place a network fetch lives.
That has two IO-bearing trees per language now, and the second one is
IO-bearing for a different reason — it SENDS, so every leg it dials carries a
credential, which is why it refuses redirects where the resolvers follow them
under a cap. The layering record is this file: ADR-020 is cited throughout this
repo and lives in the reference implementation's, so a reader with only this
checkout has this document and nothing else. Said so, in it. The Python
package's own tier map was stale the same way.

The published list of checks before a signed send said five; it is six, and the
new one is the re-check of whatever an injected endpoint resolver handed back.

The site's language-bindings table credited the TypeScript SDK with
@connectrpc/connect and @bufbuild/protobuf, and Python with fastmcp. None of
those is a dependency of either; choosing Zod and Pydantic over the protobuf
runtimes is the decision that makes these clients JSON-only by design. Neither
client tree was listed.

connectserver's codec comment said presence-tracked fields are still omitted
when unset. protojson renders an unset MESSAGE field as null under
EmitUnpopulated — which is the whole of what broke the TypeScript client, and
this was the comment that would have told a reader it could not happen.

TypeScript's verbs return the generated response types instead of an untyped
record. The fields a caller reaches for there are the links of the dispute
chain, and every read of one was an unchecked index while Python returned
models.

Both manifests described a two-tier IO-free SDK while exporting a client and
depending on undici.

And three places claimed the strict outbound-validation default mirrors the Go
client. Go's default is the opposite. The default STAYS — these two SDKs go to
external partners, the Exchange enforces the same rules regardless, and the
answer coming back is validated either way — so what changes is the wording,
plus the note that "strict" here is a field-level check and not the cross-field
one Go means by it. That divergence and the guarded-leg split are recorded in
design-history, where a behaviour difference belongs; the symbol map's allowlist
is a ratchet over symbols.
Streaming under a running total is not a bound, which took a second measurement
to see. httpx's iter_bytes yields the DECODED output of one raw read, and the
gzip decoder expands a 64 KiB raw chunk in a single unbounded zlib.decompress —
so a peer that gzips anyway materialises one chunk far past the cap before the
total can refuse it. Measured with a baseline taken before the payload exists:
70 MiB of growth against a 1 MiB cap. Down from 420 MiB, and not closed. The
module docstring asserted it was.

The check that holds at any chunk size is a different one: every leg already
negotiates identity, so a coding on the answer is the peer breaking a
negotiation it responded to. Refused before a byte is read, on all four Python
read sites and both TypeScript legs — the delivery refusal path included, since
its 4 KiB bound is the tightest and so the one an unnegotiated coding overshoots
furthest. Now 6 MiB against the same 1 MiB cap.

TypeScript was exposed from the other direction and sent no Accept-Encoding at
all. Per RFC 9110 an absent header means ANY coding is acceptable, and undici
does not decode one — so a gzipped answer arrived as raw octets, failed to
parse, and was reported as the peer's fault for something the peer was entitled
to do. It asks for identity now, on the RPC legs and on the delivery leg, which
builds its own header set.

The class is malformed rather than too_large: the answer is unreadable because
the peer did not answer the question it was asked, which is a different
complaint from one that is merely too big.

The tests drive real HTTP against a local server. A mock transport hands back a
body that is already in memory, which is exactly what made this invisible twice.
This package is a deliverable in its own right — CLAUDE.md names it as how a
TypeScript consumer pulls the wire types, and the README showed the import. The
wire policy landed in wire/base.ts and that subpath was never exported.
`exports` carries no wildcard for ./wire/*, so Node treats the map as an
exhaustive allowlist: an unlisted subpath fails with
ERR_PACKAGE_PATH_NOT_EXPORTED and has no deep-path workaround.

So a consumer got the schemas and nothing that knows how to use them. A
conformant Exchange sends "ext": null for an unset message field, the schemas
alone reject it, and the README's own snippet was the failing call.

./wire/base and ./wire/names are exported now. zod is a peerDependency, so there
is one copy in a consumer's tree and the instanceof checks inside the seam hold
against their schemas rather than failing open. The README says which call to
make and why: safeParse is right for a message you built, parseWire is right for
an answer off the wire, and the two things that differ — the null and the
camelCase alias — are named.

The changelog said "the generated Zod schemas now accept it". They do not, and
that sentence is mirrored to the public site. Corrected in both, together with a
second error in the same paragraph found by probing the codec: an unset map
renders {} and an unset optional message field is omitted outright. Only a
non-optional message field and a Struct render null.

A test now walks the manifest, asserts every subpath the README names is
declared and every target exists, and parses real codec bytes through the seam —
so "reachable by a consumer" is gated rather than asserted. It also pins the
inverse, that the bare schema still refuses that body, because that is the reason
the seam has to be reachable at all.
…akes

The last change guarded the generated-model validation and left the naive
recursion in the normalizer beside it. A nested debug projection raised a
RecursionError at depth 1200 in Python and a RangeError at depth 4000 in
TypeScript — raw, out of a reader that both languages document as raising
exactly one failure type, on the path every non-2xx takes.

The probe that reported this closed was vacuous: it built an entry with no
`type` member, so the loop skipped it and the normalizer was never reached. A
test that cannot reach the code it names proves nothing, which is the same
failure the guard work fixed elsewhere.

Bounded at 32 now. A real ErrorDetail nests two deep, and 32 is the number the
protocol already sets for a third party's JSON in
AccountRegistration.data_schema — so one number covers how deep a stranger's
document may be. Past it the entry is not an answer and the scan continues to
the next, which is what the reader already does for a payload that does not
decode.

Two more raw escapes, both reachable the same way. A `details` member that is
not a list of entries raised TypeError out of the Python reader; TypeScript
already guarded it. And a call on a closed client raised httpx's bare
RuntimeError — a hole the lifecycle fix opened. Nothing was sent, which is
exactly what not_sent means.

The TypeScript normalizer also writes through defineProperty, so a key named
__proto__ inside a debug projection is a member rather than an instruction to
replace the object's prototype.

The numeric trigger reported alongside these does not reproduce: 1e999 and NaN
both read back cleanly in either language. No change.
Assigning `out[key] = value` invokes the prototype SETTER for that one key: no
member is created, and the object silently inherits whatever the peer put there.
Both schema walkers did it, and the consequence depended on which.

In the from-wire offer inversion it was a fail-open at the trust boundary. The
member vanished from the canonical form, so the JCS bytes were unchanged and the
signature still verified — __proto__ was the one name that could be appended to
a signed offer for free, while an ordinary unknown member correctly makes
verification fail. The VerifiedOffer handed to the caller then answered
attacker-chosen values for any property the real offer did not own: an offer
carrying nothing could report a pricing model. That is the reverse of what the
module's header promises, which is that an unknown member is kept verbatim so
verification fails CLOSED.

In the response walker it bypassed the depth check. The value was never walked,
and the schema then read declared keys back through the prototype chain, so a
camelCase items[] hidden inside it parsed with an empty transaction_id — the
exact outcome the depth check was added to prevent, through a different door.

Both write through defineProperty now, so the member is a real own property:
JSON.stringify emits it, JCS covers it, the signature check sees it, and nothing
is answered that was never sent.

This was a regression. Before the hasOwn fix on the READ, the same key threw a
TypeError — loud, and fail-closed. Fixing the read without fixing the write
turned a crash into silence, which is the worse of the two.
Two halves of one corpus, each broken in its own way.

Python accepted embedded whitespace. bytes.fromhex skips ASCII whitespace
BETWEEN bytes — CPython has since 3.7 — so "00 ff" decoded to two bytes where Go
refuses it outright, on a value that reaches the offer-signature check straight
off the wire with no proto pattern constraint behind it. The emitter comment
asserted the opposite. A shape check now runs before the decode, in the
offer-signature path and the acceptance path both.

The corpus had not caught it, and could not have: its two whitespace vectors are
" a" and "a ", which Python rejects for the wrong reason — stripping leaves an
odd length. Vectors with whitespace between an even number of digits on each
side are the ones that separate the three languages, and they are in now.

The TypeScript replay was asserting nothing at all. It checked that verification
returned false, which is true of every vector whether the decode refused the
value or merely failed to match it, and it never read the ok or bytes columns —
so reverting BOTH TypeScript decoders to the lenient parseInt form left all 1253
tests green while the matrix counted the row as replayed by three languages.

Both replays now drive the Verifier and read its rejection REASON, which already
separates the two outcomes in both languages: "not valid hex" means the decode
refused the value, "signature invalid" means it decoded and did not match. No new
export was needed — the distinction was already public.

Checked by mutation, which is the only way to know a replay works: reverting the
TypeScript decoders fails five vectors, reverting the Python one fails two, and
both go green again when restored.
…aced

guardedSend is a documented option, and while the gate lived inside
createUnarySend, supplying one removed it — along with the address pin. Verified:
a signed usage report went to http://issuer.test/ramp.v1.ExchangeService/ReportUsage.

Go states the rule the other way round and means it: WithGuardedBaseTransport
takes what sits UNDER the guard, and NewGuardedTransport says the guard is not a
default a caller may replace. The only way out there is the deployment-level
SKIP_SSRF / ALLOW_INSECURE opt-out.

The gate moves to unaryCall, above any send, which is where the delivery leg has
always had it — vetDialable runs before the dispatcher is even chosen. Which leg
is guarded is now stated at the call site rather than inferred from which send
was passed, so the dial and the gate are picked together and cannot drift apart.

The address pin stays replaceable, and that is the deliberate part: a caller who
replaced the transport replaced it, and both ports need an injectable dial far
more than Go does, whose httptest seam is a *http.Transport. Written into
design-history beside the other two divergences, because a difference from the
oracle that nobody wrote down reads as drift.

send.ts claimed "The SSRF guard is composed here and cannot be handed in already
built". True of that factory, false of the client above it. It now says what
replacing the send does take with it, and what it does not, and the UnarySend
contract says the same from the other side.
…guages

Four places where one language answered differently for a reason nobody chose.

The Python delivery deadline did not cover proof minting, while the docstring
said it did. Minting may call out to a custody backend bounded only by that
backend's own client, so the round trip started a fresh full budget on top of
whatever signing had spent: a 0.5 s budget against a 3 s signer completed at
3.0 s. The clock starts before minting now and the round trip gets the
remainder; a budget already spent is not signable in time, which is what Go
answers for the same condition. It does not interrupt signing — sign_agent_binding
takes no deadline — and the docstring says so, because claiming otherwise is the
mistake being fixed.

A refused redirect was classified as a refusal in both ports, while all three
failure taxonomies document a redirect as unreachable. Every leg refuses to
follow one, so a 3xx reaching the decode is a server that did not answer the
call, not one that declined it — and the difference decides whether a caller
retries. It is absent from the captured corpus because connect-go never sees a
3xx: its transport follows them, so the row is unreachable there rather than
decided.

An address-pin refusal on the delivery leg was indistinguishable from a network
blip. redact() returns a fresh Error — deliberately, since attaching the original
as a cause would put the credential-bearing URL straight back into anything
walking the chain — but it ran BEFORE the classification, so the verdict was gone
by the time anything could read it. The class is taken while the original is
still in hand; only the redacted message survives.

And a rate limit came back with reset_at as a datetime, under a comment claiming
TypeScript parity. Zod keeps it the RFC 3339 string it arrived as, and a datetime
is not JSON-serializable — which a caller logging or forwarding the value hits.
Dumped in JSON mode now, which is what the comment already promised.
…pus covers

The replacement codec comment was wrong twice, and had been copied into four
places. Probed through the real codec: an unset MAP renders {}, and a field
declared optional is omitted outright. Only a non-optional message field and a
Struct render null. The code was always right; the sentence describing it was
not, and it was on the public site.

The threat model credited ssrfGuard() with enforcing the scheme allowlist "so a
custom client stays safe in one line". It is two checks, not one, and the split
is structural rather than incidental: by the time a connection-level hook runs,
the URL is a host and a port and the scheme is gone. Go and Python wrap the
transport so one object carries both; TypeScript composes the connector and
states the scheme rule beside it. And because replacing the dial takes the
address pin with it, the scheme check sits above the dial where an injected
transport cannot remove it.

The published list of checks before a signed send counted six and enumerated
five, having credited "resolve the endpoint" — which is not a check — as one of
them. Rewritten as a numbered list, with the re-check of an injected resolver's
answer standing on its own.

Both new corpora were missing from the non-empty guard, which is the guard that
stops a corpus asserting nothing because it emptied. And the transport-failure
set now covers 403, the last row of connect-go's status mapping no vector
reached.

The parity gate's read of the blocking facade adds nothing today, because that
face deliberately mirrors the async names. That is now stated, and pinned: if a
symbol ever exists only there, a test says so rather than the enumeration
quietly doing nothing forever.

The changelog's own inventory said two corpora were new where five are, and
PR-DESCRIPTION still claimed the strict validation default mirrors Go — the one
line the decision record names as needing to change.
Hoisting requireScheme above the send was right — an injected send can no longer
remove it — but it landed one line above the try, so nothing converted what it
threw. Through the real client:

    threw: SsrfBlockedError | instanceof RampCallError: false | kind: undefined

Every verb throws RampCallError and nothing else; that is stated in errors.ts and
recorded in the parity matrix, and a caller branching on it drops a security
refusal silently. requireScheme raising the resolvers' own error is deliberate —
its docstring says so, because the client tier is what classifies it — and the
tier had stopped doing its half.

Converted at the call rather than by the catch below, because the check runs
before the try on purpose: a URL this client will not dial should cost no body
encoding, no timer and no signature. Unreachable, matching what the delivery leg
already answers for the identical refusal through dialFailure, and what Go
answers when the same check fires inside its RoundTripper.

The three tests covering this asserted .rejects.toThrow(/scheme/i), which any
Error satisfies — so they passed throughout. They now go through one helper that
requires a RampCallError and a kind before checking the wording, which is what
the delivery-leg test in the same file already did. Reverting the fix fails all
three; it failed none of them before.
The last change bounded the error-detail normalizer and left the parse feeding
it. json.loads descends recursively and aborts on a deep document by raising
RecursionError — which is not a ValueError, so the handler beside it never caught
one, and not a failure this package says it raises. Through the real decode():

    depth 20000 (body 40000B) status 200: *** RAW RecursionError escaped
    depth 20000 (body 40000B) status 500: *** RAW RecursionError escaped

A 40 KB body, far under the 1 MiB read cap, threw an untyped exception out of
every verb — on the SUCCESS path as well as the error path, so this was not
confined to where a hostile peer is expected. TypeScript is unaffected: its
parser is iterative, clean to 200 000 deep.

The depth is checked BEFORE the parse, because a check after it is reached only
by documents harmless enough to parse. The scan is lexical, so counting needs no
recursion, and it is the one the registration-schema compiler already uses — that
module documents this exact trap and solved it the same way, for a schema read out
of a third party's manifest. It moves to a shared module rather than being
transcribed; a second copy of a security rule is how the three languages drifted
apart elsewhere.

The bound is 32, the same number the normalizer uses and the protocol sets for a
stranger's JSON in AccountRegistration.data_schema. The deepest instance in the
whole conformance corpus is 5, and a test pins that the bound stays above the
contract rather than merely above today's examples.

Reverting the guard fails the 20 000-deep cases on both status bands.
… transport

design-history says, in a sentence added last round, that no injected send
reaches a plaintext endpoint carrying a signature, and that a signature over
plaintext is not a latitude any of the three languages offers. That was true of
Go and became true of TypeScript last round. It was never true of Python.

Python's scheme policy lives inside _SchemeGuardTransport, which a caller
replaces when they pass their own client — deliberately, since an injected client
carries both legs. Measured through the documented http= seam:

    DEFAULT:  refused UNREACHABLE
    INJECTED: SENT to http://issuer.test/ramp.v1.ExchangeService/ReportUsage
              | signature header: True

The default path was already correct, so this needed an injected client to reach.
The gate now sits above the transport, on all four guarded sites: the RPC leg in
both faces when the plan is offer-derived, and the delivery fetch in both. The
configured home Exchange keeps its latitude — the operator chose that address —
which is the same split TypeScript makes in unaryCall and Go makes with
NewGuardedTransport.

The predicate and the ALLOW_INSECURE relax are imported from the resolvers rather
than restated, so the rule keeps one owner. The refusal names the scheme and never
the URL, because a delivery URL's query is the credential, and reports unreachable
— what the guard's own refusal reports once typed, and what Go answers when the
check fires inside its RoundTripper.

The sentence in design-history stays as written. Fixing the code to match a stated
invariant is the point; softening the invariant would have been the other way to
make the two agree, and the wrong one.

Reverting all four pre-flights fails the two injected-client tests and leaves the
home-leg test passing, which is the split this is meant to hold.
Both fixes were in the code and neither could fail. Mutating each site one at a
time, before this: the Python async delivery coding refusal left 1364 passing,
the sync delivery one the same, the TypeScript delivery one left 1277, and both
acceptance hex decoders left their suites green. Only the RPC and offer legs were
gated — which is how a fix lands in half the places and a commit says it landed
everywhere.

The delivery legs get their own coding cases, in all three faces. They need
ALLOW_INSECURE to be reachable at all, because the scheme pre-flight now refuses a
loopback http:// delivery URL first — that ordering is itself worth seeing.

The acceptance half needed a different shape. It answers a plain boolean, and a
refused decode and a signature that merely does not match both read false, so no
corpus vector fed to that face can separate them. What separates them is a value a
LENIENT decoder reads as the RIGHT bytes: parseInt reads " a" as 0x0a exactly like
"0a", and bytes.fromhex skips whitespace between pairs. So the test signs a
genuine acceptance, rewrites one pair into the lenient-only spelling, and requires
it not to verify — with the unmodified signature verifying first, as the control
that proves the face is reached.

Every site was re-mutated afterwards and each now fails: two Python delivery, one
TypeScript delivery, two acceptance decoders.
The redirect class was fixed on one leg and claimed for every leg. On the
delivery leg a refused 302 still read `refused`, and worse than the wrong class:
the refusal reader ran first and promoted a token out of the redirect body, so a
302 carrying {"reason":"moved"} surfaced as though the edge had named a typed
protocol refusal.

The check now runs before anything is read out of the body, on all four legs, and
it is unconditional. A 302 carrying what looks like a Connect envelope used to be
read as the peer's own verdict; there is nothing in a redirect body to interpret,
because this client did not follow the hop and never reached a server that could
decline. connect-go has no answer to mirror here — its transport follows
redirects, so it never surfaces one — which is why this is the SDK's call to make
and worth making the same way everywhere.

Two tests pinned the old side and passed throughout. They pinned the
envelope-carrying case, which is exactly the one the unconditional rule changes.

A rate limit is handed back as the peer sent it, once the parse has GATED it —
the same split the offers already use, for the same reason. Pydantic reads
reset_at into a datetime and re-renders it on the way out, and that round trip is
not the identity: ".123Z" returns ".123000Z", nanosecond precision is truncated,
and "+00:00" becomes "Z". Zod keeps whatever the Exchange sent, so handing back
the parsed value meant the two languages reporting different rate limits for one
answer. All four spellings now survive verbatim.

One test needed an explicit dispatcher rather than SKIP_SSRF: the delivery
dispatcher is built once per process and caches what the flag said at first use,
so setting it leaves every later test in the file dialing unguarded.
Two guards were reporting on less than they said.

The Go surface enumerator missed 46 exported symbols. go doc renders the
constants of an enum-like type under that TYPE rather than under CONSTANTS, and
the parser only opened const groups in the CONSTANTS section; worse, its
entry pattern required a token after the name, so even where a group WAS opened
every iota continuation — which is most members — fell out. Both halves are
fixed, and the count went 265 → 303. Every CallErrorKind, every FetchFailure,
every SchemaVerdict and AudienceVerdict, both core Modes were outside the surface
a gate exists to refuse anything unmapped from.

All 46 are classified as go_exclusions against their already-mapped parent type:
Go declares each as a package-level constant while Python spells it as an enum
member and TypeScript as a literal-union member, so neither is a top-level export
the surface enumerator can see, and the shared corpora already pin the values.
The allowlist is unchanged at 14 — none of these is a divergence, and that
ratchet only shrinks.

The corpus guard checked that a replay NAMES its corpus, which a replay satisfies
while reading none of its columns. That is how the hex half asserted nothing
through two review rounds while the matrix counted the row as replayed by three
languages. It now also requires each language to reference every column the
corpus declares, derived from the committed JSON's own keys, so a column an
emitter adds is covered the day it lands. Top-level case lists only: anything
deeper is a case's payload, whose field names belong to the data.

It found five on its first run, all real. connect-error recorded the status
connect-go maps each code onto and neither port read it — the column that decides
the failure class when an envelope names no code. client-request recorded the
verb and Python read only the path, which cannot catch one verb addressing two
RPCs; that is now asserted by grouping. The active-key corpus explains each case
in a note that nothing read, so an emitter could have stopped filling it silently.

Reverting either parser fix, or any of the five column reads, now fails.
Every guard above the read throws while the response is still in flight — the
check that rejects an unnegotiated coding, the class that rejects a redirect.
undici cannot return a connection whose body nobody consumed, so each of those
refusals left its socket open until the peer chose to hang up. A peer that never
does is the party those guards exist to contain, and on the delivery leg the
dispatcher is shared process-wide, so they accumulated: six refusals held six
live sockets, still six after fifteen seconds idle. With the pool bounded to one
connection a single refusal wedged it outright, and the call's own deadline did
not break it, because nothing after it was ever dispatched.

Three sites, two of them on the delivery leg and one on the RPC send. The RPC leg
looked safe because it reads for every status, but its coding check runs before
that read.

Released in a finally rather than at each throw. The last change here added a
refusal ABOVE the branch that happened to be doing the reclaiming, and a release
attached only to the throws visible today would go the same way; a future early
return is covered too. Both siblings get this from an idiom with no TypeScript
equivalent — Go from a deferred close, Python from a streaming context manager.

Destroy rather than drain: reading a body to free its socket is an unbounded read
from a peer whose answer is already refused, and draining under a cap settles only
when the stream closes, which hands a peer that trickles control of how long the
refusal takes. The teardown carries an error listener, without which the abort it
raises would be an uncaught exception — releasing the socket would then take the
caller's process down on every refusal, which is worse than the leak it fixes.

The tests count closed sockets, not open ones: a stranded socket is never reused,
so the next request opens a fresh one and the totals match either way. Their
bodies are two mebibytes of random data because a small answer is already buffered
whole by the time the refusal runs, and a compressible one is a couple of kilobytes
on the wire — either would pass against the unfixed code.
The answer arrives, and then goes wrong under the read: the deadline fires
mid-body, or the connection resets. Both raise the dialing library's own error out
of the loop, and this leg had no catch of its own — the request promise was
guarded, the body was not. So the failure escaped untyped past every caller
branching on the contract this package states, that a verb raises the client's own
error and nothing else. Measured before this: a mid-body deadline surfaced as a
bare DOMException.

The RPC legs already route the identical case through their own classifier. This
is that, and it reports the same class, plus the redaction only this leg needs —
a delivery URL carries a live credential in its query, and the failure reaches a
log.
The pre-flight above the injected transport is written twice — once in the async
client, once in the blocking facade over it — and only the facade was driven. So
two of its four call sites asserted nothing: removing either async one left the
whole suite green, and the async client is the documented core while the facade is
the wrapper over it.

Parametrized over both faces rather than given a second set of tests, so the pair
cannot drift apart again without a case disappearing.

The negative control moves with them, and it is the only case that catches the
gate firing too widely: the configured home Exchange keeps its latitude because an
operator that points the SDK at a private origin chose that address, and dropping
the guarded-leg condition in the async client alone would have refused it with
nothing to say so.

Each of the six now fails exactly one case when reverted individually; before this
it was two of six.
…eserve

Python answered this field from the wire, which made it the odd one of the three.
Go hands back the decoded message, so an int32 the peer spelled as a string
arrives as a number and a member the schema does not declare is gone; the
TypeScript parse performs the same two normalizations. Python kept the peer's
object verbatim, so for one conformant answer it reported "300" where the other
two reported 300, and carried a vendor key they both dropped. proto3 JSON permits
an int32 as either a number or a string, so no hostile peer is needed.

reset_at stays as the peer spelled it, and is now the only member that does. It is
the one the parse cannot return unchanged — read into a datetime and re-rendered,
".123Z" comes back ".123000Z", nanosecond precision is truncated and "+00:00"
becomes "Z" — while the other port validates it as a plain string and hands back
what it was given. Taking it from the wire is what makes the two agree on it.

The reason the offers beside it are read raw does not reach here: those carry a
signature that covers what the responder sent, and this field carries none.

Tested in both languages. TypeScript had no test for this field at all, which is
how the two drifted apart without anything failing; making that port read from the
wire instead now fails a case.
…olds

The canonical wire is proto-JSON, where a null is a field's default — for any
field, not only a message-typed one. The TypeScript policy asked a different
question: it dropped a null where the schema looked like a message or a map. Those
read as the same question and are not, because the type generator FLATTENS the
well-known types. An unset google.protobuf.Timestamp arrives as null like any other
unset field, but reaches the seam as a plain string schema, so the test for a
message answered no, the null survived, and the string schema refused it.

What that cost: an attestation with no attested-at, or a rate limit with no reset
time, took the whole answer down. Both are conformant — no validation rule requires
either field — and the reference Exchange produces the first by default, since its
ingest sets the timestamp only when the publisher's feed carries one. Go and Python
read the same bytes without complaint, so nothing anywhere reported a problem.

The rule now asks about presence: a null is dropped wherever the schema does not
require a value, and left for the schema to refuse where it does. Optional and
defaulted both count — a null on a defaulted field is that default in Go, and
dropping it is what lets the schema supply exactly that. Detected with the same
public API the file already uses two functions above, which matters because this
file is copied verbatim into the Zod 4 gate; reaching into schema internals to
recognise a flattened timestamp would differ between the two versions with nothing
able to notice it silently ceasing to match.

A corpus now holds the rule for all three languages, emitted through the codec's own
option set. Its absence is why four review rounds and a tri-language corpus missed
this: no vector carried a null on anything but a message field.

Also true of requests, which run through the same pass: a null on an optional field
is now dropped rather than locally refused before sending. That is what Python
already did, and the server reads the null as the default either way.

The published note said only a message field and a Struct arrive as null. That
described what the codec emits and was read as a bound on what a client must
accept. Both changelog mirrors now say what the rule is.
Three readers parse a document the client did not write, and each one had a
way to answer that was not a verdict.

The delivery edge's refusal reader parsed a 4 KiB body with no bound on its
nesting. json.loads descends recursively and raises RecursionError on a deep
document, which is not a ValueError and not a failure this package says it
raises, so it left Client.fetch untyped on one supported interpreter and
decoded fine on the next. The size cap does not bound the nesting: 4 KiB of
"[" nests four thousand deep. It scans the depth first now, as the response
reader already did, and a body past the bound yields no token — which is what
this reader already answers for any body it cannot interpret.

The response reader's bound existed only in Python. TypeScript's parser does
not overflow and takes the same number anyway: two clients answering
differently about one body is the state that produces the bugs, and which of
them a caller holds is not something a peer can know. The number now lives
beside the scan rather than at either call site. Go keeps protojson's own
limit and is left alone, so an answer nested past the bound is read there and
refused by the other two; docs/design-history.md records that and what it
costs. The tests pin the EDGE through a conformant ext, which is the reachable
carrier — the previous pair asserted a typed failure the schema produced on
its own, and passed with the bound mutated to anything at all.

The delivery leg refuses a URL for two reasons a caller acts on oppositely: a
fault in the VALUE refuses identically forever, a refusal of the DIAL may not.
All three drew that line in a different place, in both directions, because
each let its own URL parser decide what "unparseable" meant — and the three
disagree. Python minted a proof and dialled for a URL Go refused locally, so
its answer then depended on whether the host resolved. The rule is stated
instead, as the Content-Type reduction already is: the value's faults are
malformed, the dial's refusals are unreachable, and the list of value faults
is named rather than inherited. A refused dial also carries no reason token
now — reason holds the peer's own, and a refused dial reached no peer.

content-fetch-vectors.json gains a second list for this, captured from the
real fetcher and replayed in all three languages, so the split is held by the
mechanism that already holds the reading of an answer.

Claude-Session: https://claude.ai/code/session_014NYgjvC5yGavfRzvQ8PRQ7
Resolution ledger:

- mechanical: gen/descriptor.binpb, gen/go/ramp/v1/ramp.pb.go,
  gen/python/wire/models.py, gen/ts/wire/schemas.ts
  -> REGENERATED from the merged proto, never content-spliced. buf 1.66.1:
     `cd proto && buf generate && buf build -o ../gen/descriptor.binpb`, then
     ./scripts/gen-sdk-types.sh. Re-running the generators over the result
     produces byte-identical output, so the tree is what the proto implies
     rather than a splice of two outputs.

- semantic: proto/ramp/v1/ramp.proto
  -> UNION. Auto-merged, then verified rather than trusted: main states the
     shipped key-discovery model (keys live in the WBA directory, resolved from
     the covered Signature-Agent header, never from the self-asserted
     Requester.domain); this branch adds the file-header note on Connect's
     lowerCamelCase `debug` projection and drops the stale
     WellKnownManifest.endpoint workaround note. The edits fall in disjoint
     regions; both sides' text is present and neither side's deleted text came
     back.

- semantic: sdk/python/tests/test_guards_resolvers_io_leaf.py
  -> UNION, and the one file where taking either side whole would have lost the
     other. main replaced the regex detector with an AST walk, because a regex
     missed a banned module in the second position of a multi-module import.
     This branch widened the ban to the client and its blocking facade, which
     are the second IO-bearing tree. The AST detector strictly subsumes the
     regex, so it is kept, and ramp_sdk.client / ramp_sdk.sync join its banned
     roots. main's docstring — resolvers are "the SDK's ONLY IO-bearing tree" —
     is no longer true and this branch's replaces it. New meta-tests pin both
     added roots: no pure module imports either today, so nothing else would
     notice the ban being dropped.

Verified on the merged tree, not on either parent: go build/vet/test; Python
1415 collected and passed; TypeScript 1310; gen/ts 594; the canonical
proto-JSON round-trip; website 26 tests, internal links valid, 79 pages built.

Claude-Session: https://claude.ai/code/session_014NYgjvC5yGavfRzvQ8PRQ7
@legendko
legendko merged commit 71798f5 into main Aug 25, 2026
4 checks passed
@legendko
legendko deleted the feat/ts-and-py-json-rpc-client-layer branch August 25, 2026 11:10
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