TypeScript and Python SDK: the unary JSON-RPC client layer - #39
Merged
Conversation
… 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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
fetchorhttpxcall and hand-assembled the Connect envelope, which is exactly there-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 wholeprotocol 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 usesundici/fetchwith Zod.What ships
Six verbs, the names Go uses:
Client.Discoverclient.discoverawait client.discoverClient.Executeclient.executeawait client.executeClient.ReportUsageclient.reportUsageawait client.report_usageClient.Disputeclient.disputeawait client.disputeClient.Fetchclient.fetchawait client.fetchBrokerClient.ResolvebrokerClient.resolveawait broker.resolveEntry points:
@ramp-protocol/sdk-l1/client(createClient,createBrokerClient) andramp_sdk.client(Client,BrokerClient).Python is async-first with a real blocking facade at
ramp_sdk.sync, under the samenames — the surface the API-surface design settled and ADR-020 ratified. The facade runs on
a synchronous
httpxclient and is deliberately notasyncio.runaround the asynccore: 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/sortGroupsare ported so the per-URI answer survives in all threelanguages — 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.titleandUsageAsset.titleare proto fields whose NAME istitle, andthe merge step removed every key called
title— meaning to drop the JSON-Schema keywordand taking the fields with it. No corpus case populates any of them, and
corpusgenonlyemits 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
debugprojection is lowerCamelCase and no server option changes it —
connect-gorenders itwith its own
protojsoncodec 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
transactionDenialwasremoved as an unknown key by the forward-compatibility policy: the parse succeeded,
carrying
domainandmessageand no reason at all, for a refusal the Exchange had namedprecisely. Eight of the ten corpus vectors lose their reason without the fix.
3. The three clients disagreed on
Content-Typereduction — surfaced by the newcontent 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 errorbeside a perfectly good media type (so
text/plain; ;answered "unknown", discarding theone 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 betoken "/" 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, nullmessages and
*_UNSPECIFIEDenums the signature never covered. The from-wire inversionalready 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 RAMPwire is snake_case proto-JSON and the
json_namealias is out of contract, but a stockconnect-goserver that registers noUseProtoNamescodec serves the alias — and thegenerated 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, aStruct) cannot reach it.Checked against the reference implementation:
src/exchange/cmd/server/main.go,src/broker/cmd/server/main.goand the raw mount all already register the codec, so thisis not a downstream break. It does become a standing requirement for any new JSON-serving
listener.
A
nullnow 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/sdkexports./wire/baseso 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 inDECISION-outbound-validation-default.md. A consequence of the null rule: a null on anoptional 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
repo carries two manifests; the root one is what a consumer resolves through, and it had
drifted.
exportscarries no"."and no wildcard, so Node treats it as an exhaustiveallowlist — an unlisted subpath fails with
ERR_PACKAGE_PATH_NOT_EXPORTEDand there isno deep-path workaround.
errordetailalone holds nine symbols the parity matrix listedas cross-language.
undiciwas missing from the rootdependenciestoo, so thealready-exported
./resolverscould not resolve its transport once installed. The debtlist that recorded the nine is now empty.
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.WellKnownManifest.endpointexplaining a workaround that no longerapplies 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.
nullfor an unset field was the first (see the behavior noteabove); a lowerCamelCase answer was the second, and the refusal had to run at every depth,
because a stock
connect-goserver omits unset fields — so aTransactionResponsearrivesas
{ver, items}, every root key a single word and identical in both spellings, whiletransaction_idsits one level down and is silently dropped. That reads as a purchase thatsucceeded 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:
RecursionErrorout of every Python verb, on success and error pathsmalformed, from a lexical depth scan placed before the parse__proto__appended to a signed offerVerifiedOfferNothing 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
httpxclient on both the async core and the blocking facade.An injected Python client could previously carry a signed usage report over
http://. Theendpoint 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
unreachableonevery 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 onemember 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
TYPESrather thanCONSTANTS; they are nowclassified, 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):
connect/testdata/connect-error-vectors.jsondebugprojection — the corpus that would have caught defect 2connect/testdata/client-request-vectors.jsonver, and fill-when-empty on the idempotency keyconnect/testdata/transport-failure-vectors.jsonconnect-goclient classifies each transport failure, so the ports answer the same classresolvers/testdata/content-fetch-vectors.jsonContent-Typereduces tohelpers/testdata/wire-names-vectors.jsonhelpers/testdata/wire-null-vectors.jsonnullin them, and that every SDK reads themsdk/go/connect/testdatais a third corpus home: the Connect envelope can only be capturedfrom a package that imports
connectrpc, which the Connect-free tiers may not. Registeredin 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_nameinverts back to every field's name (which is what makes thedebugnormalization sound — it would not hold for a field likefield_2).Parity record
connect.Clientandconnect.NewClientcarried an OPEN decision saying theAPI-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.mdis regenerated, not hand-edited: 116 symbols at parity, 173exclusions, 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
PushResources,RemoveResources,RefreshCatalog) — RAMP-209. Theticket'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.MonotonicWindoweither, so mirroring Go means the clock window plus an injectable one.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:
c92a05d— the generated-types title fix, and the three deleted fieldsb71ac20— the root export mapf12ed50— the typed reason Connect actually puts on the wire, + its corpus9840b32— the per-URI discovery answer in TS + Python core3ca67fe— the TypeScript clientbb659ce— the Python client, async + blocking facade8e28d3d— the parity record and the proto/website notes077d00f— the client-request and content-fetch corporaThe hardening (
596c9de..1ed3804) is twenty-eight commits and does not need reading inorder — each stands alone, and each names the behavior it changes rather than the review
that found it. If you want the load-bearing ones:
596c9deand1ed3804(what a clientaccepts off the wire),
e5dca1eandbc39865(what a hostile peer can spend),c3fd431and
dbf7536(nothing signed over plaintext), and73589b1(the gates that were measuringless than they claimed).
Verification
126 files, +15130 / −464.