This repository was archived by the owner on Sep 11, 2026. It is now read-only.
Repository navigation
Conversation
…manifest A registering client must read the terms digest from a freshly fetched manifest rather than a cached copy — a cached endpoint is fine, a cached digest is not, because a client cannot detect staleness locally and a warm cache would make it echo a value the Exchange has already stopped accepting and retry the same refusal until the cache expired. The SDK could not serve that rule. The only manifest path it had is the endpoint resolver, which is built out of exactly the mechanism the digest may not touch: a per-host LRU with a TTL, single-flight coalescing on top, and no bypass. Adding a member to the document it decodes would have satisfied the rule's letter at the one point it fails in practice — the first caller to reuse that path would be handed a digest minutes old with nothing in the API to warn them. So the reader is separate and holds no document cache. Leaving no cache slot to reuse is what makes the rule structural rather than conventional, the same argument the report leg makes about configuration. Reading the published data_schema out of the same fresh bytes then costs nothing extra and removes a failure mode of its own: a stale local schema cannot refuse a payload the Exchange would have taken, because there is no stale local schema. Not even the compiled validator is held. Memoising it is a cache, and the useful key for one is a property of a deployment's threat model rather than of the protocol; an application that wants it wraps this reader. Two refusals precede any dial, both here rather than at the call site because they are properties of building this URL and of the deployment's policy, not of any one caller's plans. The shape predicate is the contract's IsBareDomain and not the routing tier's IsBareHost: nothing upstream has run the contract's rule, and the URL is built by concatenation, so a value carrying a path or userinfo would choose what is fetched rather than merely where from. A manifest describing another role — or naming none, which the contract forbids — is refused rather than read for members it has no business carrying. A schema this SDK refuses is a verdict, never an error. The contract requires a client that cannot check locally to send anyway and let the Exchange decide, so a refused schema comes back as a nil validator plus the verdict that names why, and a nil validator reports no failures. Claude-Session: https://claude.ai/code/session_01VEcS7WSHviGSqQwCvEaSWS
Register and GetAccountStatus were proto-defined RPCs with a complete generated client interface and no SDK verb in any language, so the first role an agent developer meets — open an account, check it is active — was served by application code no external consumer could reuse. They sit on the agent client rather than on a fourth constructor because they are the same party holding the same key. What decides a constructor's shape is where the address comes from, and an account is per-Exchange with the Exchange being the agent's choice PER CALL: a target routinely arrives at runtime, since a denial names where to register, rather than from the operator's own configuration. So the destination is read off the request's own exchange field and resolved through that Exchange's own manifest, over the guarded leg — the usage report's shape, and for the same reason: there is no parameter a configured origin could be passed as. Both run the contract's SHAPE rule on the recipient as well as the routing tier's dialability question. The two are deliberately different, and this is a leg where the difference bites: nothing upstream of the client has applied the contract's rule, so a request naming a value the wire would refuse is declined here instead of being carried to an Exchange that will refuse it. Neither message carries an idempotency key, so neither verb takes call options. That is the contract's choice rather than an omission: registering again returns the same account handle, so a key would be ceremony rather than a guarantee. Register applies the four bounds on registration_data before anything is signed, in the order the contract fixes, because a limit that exists to stop work belongs before the work it would stop — including before the manifest read. It fills terms_digest only when the caller left the field unset, from a freshly fetched manifest, and pre-checks the payload against the schema that manifest publishes. A caller that sets the digest is managing its own requirements and gets neither; presence is what makes "unset" and "deliberately empty" different requests. A schema this SDK refuses never becomes a local veto. Refusing locally and declining to send would turn a rule about reading a third party's document into a denial of service against the caller's own user, so an unusable schema is skipped and the Exchange's own enforcement decides. A usable schema the payload fails is the pre-check working, and that request is refused here with the offending members named. The status request carries no varying field, so two calls inside one second sign identical bytes and a peer screening replays on (key id, signature) refuses the second. The verb documents that and a test proves both halves — a plain clock window collides, a monotonic one does not. The window is not chosen here: it is one instance per client rather than per call, so it belongs to whoever builds the client. Bounding its drift is a separate change. Claude-Session: https://claude.ai/code/session_01VEcS7WSHviGSqQwCvEaSWS
…prose A caller reading why a call failed had two machine-readable values — the failure class and the peer's refusal token — and no way to reach the peer's own sentence except by parsing it back out of the rendered error. A layer that has to do that is a layer that will get it wrong, and the downstream MCP adapter had grown parallel extraction arms over three error shapes to avoid it. PeerMessage sits BESIDE Reason rather than in it. Reason is the peer's machine token; putting prose there is a mistake this SDK has already made once and reverted, and the same mistake in the other direction would be no better. It is filled from the TYPED reason's developer message and from nothing else. An answer that did not come from a RAMP service — a draining load balancer, a proxy's own page — carries no message of its own, and the text a transport synthesizes for one is that transport's rather than the peer's: connect-go writes a status line where a fetch-based client writes nothing, so filling the field from it would make its value a property of the language rather than of the answer. That text stays reachable through the cause, where it reads as what it is. What the field holds is therefore the same string in every language, already pinned by the connect-error corpus that records it. It is unbounded on purpose. The contract calls the field an easy existence oracle and places the no-secrets duty on the server, so truncating it is a decision that belongs to whoever displays it, where the audience is known — the same reason the typed detail's message has never been bounded here either. The field doc says so rather than leaving a consumer to infer it. Also states, in all three languages, that a monotonic signature window is one instance per client and never one per call. The running maximum is the whole mechanism, so a window minted per request provides none of the uniqueness it was chosen for while still looking correct at the call site — which is a constraint the downstream implementation had to discover for itself. Claude-Session: https://claude.ai/code/session_01VEcS7WSHviGSqQwCvEaSWS
The same two verbs under the same names, over the same routing: the destination is read off the request's own exchange field and resolved through that Exchange's own manifest, and the shared client-request corpus now replays three more rows here. The requirements reader is a separate face holding no document cache, for the reason the oracle gives: the contract requires a registering client to read the terms digest from a freshly fetched manifest, and the endpoint resolver is built out of exactly the mechanism that value may not touch. One thing this port had to build that the oracle got for free. The registration-schema caps are defined over the bytes AS SERVED, and JavaScript has no raw-message slice — JSON.parse discards whitespace and reformats numbers, so re-serialising the parsed member would measure a document nobody sent. A schema padded past the cap on the wire that minified under it would then be refused by an Exchange and accepted here, which is the two-privately-chosen-limits failure the contract's numbers exist to prevent. So the member is sliced out of the served body by extent, and a test pins the padded case rather than trusting the reasoning. RampCallError gains peerMessage on the same terms as the oracle: the typed reason's developer message, empty when the answer carried none, and never the transport's own synthesised text. Claude-Session: https://claude.ai/code/session_01VEcS7WSHviGSqQwCvEaSWS
… parity Both faces gain register and get_account_status under the same names, over the same routing, and the shared client-request corpus replays the three new rows here as well. The requirements reader is a class with no document cache, for the reason the oracle gives. Python gets the schema extraction exactly right the way TypeScript had to: the registration-schema caps are defined over the bytes AS SERVED, and json.loads discards whitespace and reformats numbers, so the member is sliced out of the served body by extent. Value extents come from JSONDecoder.raw_decode rather than a hand-rolled skipper — it already knows where every JSON value ends, and a second implementation of that is a second place to get strings and escapes wrong. Three seams moved or grew while porting, each because the port found the asymmetry: The requirements interface now lives beside the endpoint one in the client package rather than in resolvers. That is where the existing seam is, for the stated reason — the client must have no way to accept an address, or now a digest, from configuration — and putting the new one anywhere else would have made the two read differently in the map. ClientConfig gains sign_window, so the freshness knob sits at the tier Go and TypeScript already surface it at. A Python caller previously had to know to pass window= when constructing its SigningTransport; the map recorded that as "py/ts pass options via kwargs", which was true of the proof window and false of this one. sign_outbound takes the override, and the adoption guard now pins the override form rather than the exact call it used to. CallError gains peer_message on the oracle's terms: the typed reason's developer message, empty when the answer carried none. Parity record: six new mapped symbols, one new Go-idiomatic exclusion for the option builder, and one allowlist entry — the reader's Go factory folding into the Python class constructor, which is the reason every other NewX already carries. The shrink-only baseline moves 16 -> 17 under that one recorded class, and the bump is the review tell. Claude-Session: https://claude.ai/code/session_01VEcS7WSHviGSqQwCvEaSWS
Three new design-history sections, because each records a decision a later reader would otherwise have to re-derive from the code. Why the account verbs sit on the agent client and read their destination off the message: an account is per-Exchange and which Exchange is the agent's choice per call, since a denial names where to register precisely so an agent can converge unattended. An earlier design document said the opposite, and it was written before accounts were per-Exchange. Why the requirements read is a separate face that cannot cache: the contract requires a freshly fetched terms digest, the endpoint resolver is built out of exactly the mechanism that forbids, and adding a member to the document it decodes would have satisfied the rule's letter at the one point it fails in practice. Also what the two JSON ports had to build to measure a cap defined over the bytes as served, and why an unusable schema is skipped rather than refused. Why the peer's sentence is carried beside the token rather than in it, and why it holds the typed reason's message and nothing else — a transport's synthesized status line is the transport's, and filling the field from it would have made the value a property of the language in a way no shared corpus could have caught, because each port would have been faithfully reporting its own transport. A fourth client default joins the recorded divergences: Python stamps a ten-minute signature window where Go and TypeScript stamp five. It has been that way since the ports were written and was recorded nowhere, which is how a decision reads as drift. Every client now names the knob at the same tier so a deployment that cares can set it, and the cost of the difference is stated. The rest is the sweep the addition obliges: both changelogs, the agent getting-started next-steps naming the verbs and what an inactive account means, the SDK overview's language-bindings paragraph, the manifest page's account block saying who reads it and under which rules, and both READMEs' role sentences — "account setup" had been listed as a role that does not ship. Claude-Session: https://claude.ai/code/session_01VEcS7WSHviGSqQwCvEaSWS
One fetch decodes one well-known manifest for three faces — keys, endpoint and registration requirements. terms_digest and account_registration arrived as typed fields, so an Exchange publishing either with a type the contract does not admit failed the whole decode, and with it the two faces that never read them. That is not a cosmetic loss. A decode failure is classified as a transport failure, which is retryable, so one off-spec optional member turned every usage report and every key resolution against that Exchange into an endless retry rather than a loud, final refusal. Both members are now raw, and the face that reads them decides: a member of the wrong type is absent, which is what the TypeScript and Python readers already answer, so the three agree on a value instead of on an error. JSON null is checked explicitly — encoding/json decodes it into a string without complaint and leaves the zero value, so a reader consulting only the error would report a present, empty digest where the ports report none. Claude-Session: https://claude.ai/code/session_01VEcS7WSHviGSqQwCvEaSWS
The registration-requirements reader takes its domain off a RegisterRequest, so the address is chosen by the caller at runtime rather than by the operator at start-up. That is the provenance the Go oracle sends to the SSRF-guarded transport, and it is the invariant the design history states across all three SDKs: every third-party-influenceable fetch runs through a guarded client. Both ports defaulted to the plain one, and measured, both reached a loopback origin directly and through a redirect where Go refused it. It matters more here than for the resolvers beside it. TypeScript and Python have no default endpoint resolver at all — an application injects one and picks the transport with it — so this reader is the first component either client builds on its own. The SDK dialled, and the application never chose how. Building it once per client is the same change rather than a separate one: the guarded transport constructs a dispatcher, so a per-call default trades an unguarded dial for a connection pool per registration and none closed. Both faces now resolve it in their constructor, the tier Go resolves it at, on a copy of the caller's config; the transport that comes with it is owned and closed with the client, while an injected reader stays the caller's to close. Three comments claimed a guard the code did not apply. The two that caused it are the transport docstrings, which explained the plain default as matching a Go oracle that guards only its WBA client — Go guards its endpoint resolver too, and says why in the same options struct these ports mirror. They now state the rule as provenance, and name the endpoint resolver here as the leg that does not follow it yet. Claude-Session: https://claude.ai/code/session_01VEcS7WSHviGSqQwCvEaSWS
Go set PeerMessage at one site, from a detail the peer emitted. Both ports derived it in the CallError constructor from any detail at all — and a detail is not always the peer's. The content leg SYNTHESIZES one from the edge's refusal token: the token is the edge's, the sentence wrapped around it is the SDK's own. So "delivery refused: keyid_mismatch" arrived as the peer's words in TypeScript and Python and as nothing in Go, measured. The field exists so a consumer can attribute prose to a remote party — the MCP adapter surfaces it to an agent — and a field that sometimes holds the SDK's own words cannot do that job. Both ports now take it from what the decode site passed and derive nothing, and the Connect-envelope decode fills it there, where Go already did. Each language gains the case that separates the two rules: a synthesized detail is present, readable, and carries no peer message. None of the three had it. The test comment above the Go case described the transport fall-through this SDK built and reverted, and contradicted the subtest below it; it now says what the three cases actually assert. Claude-Session: https://claude.ai/code/session_01VEcS7WSHviGSqQwCvEaSWS
… pin TypeScript had nine account tests and none for the local work register does before it signs. All three branches survived mutation across the whole suite: deleting the schema pre-check refusal, collapsing the read taxonomy into one class, and turning an unusable schema into a veto each left the suite green. The middle one matters most. "A local check that cannot run must not become a local veto" is the contract's rule, and it is the branch a future edit is most likely to get wrong, because refusing looks like the careful choice — the contract says it is a denial of service against the caller's own user. Named to match the Go and Python cases so the three files read as one suite. Claude-Session: https://claude.ai/code/session_01VEcS7WSHviGSqQwCvEaSWS
The design history states as an invariant that every third-party-influenceable fetch runs guarded, but not the rule that decides which fetches those are. The ports mirrored the options struct carrying that rule without carrying the rule, and explained the result in a docstring that had stopped being true — which is how a reader inherits a gap rather than noticing one. Written down now: a resolver's default follows its URL's PROVENANCE. A fixed, operator-chosen address takes the plain client; a host another party named takes the guarded one. With it, the two consequences that keep costing something — a guarded default has to be built once per holder because the factory constructs a pool, and the escapes were always injection or the two environment flags, which is why defaulting to the guard costs nothing. The threat model said "safe-by-default" without saying which fetches, so a reader could not tell the one deliberately plain default from an oversight. It now names the split and why the configured JWKS URL is on the other side of it. Both changelog blocks note the guarded manifest read and tighten the peer-message paragraph to say where the value comes from. The manifest page says which transport the SDK reads the registration block over. Claude-Session: https://claude.ai/code/session_01VEcS7WSHviGSqQwCvEaSWS
The paragraph added a commit ago enumerated the request-derived hosts and claimed all of them take the guarded transport in every SDK. Two of three do not: the endpoint resolver's host is an Offer.exchange domain, which is exactly request-derived, and it still defaults plain in TypeScript and Python. The same paragraph then called the JWKS resolver "the one unguarded default", where the two ports have two. That is the worst place to be loose. The endpoint resolver sits on the usage report and dispute path in every deployment, and both ports make the application construct it — so a reader who believes the default is guarded builds one bare and ships an unguarded dial to a host an offer named. Replaced with the rule, a per-fetch table showing where each language stands, and the gap named as a gap rather than left for a reader to discover. The table form is deliberate: the claim is per-language and per-fetch, which is what the prose got wrong. It also carries the remedy, which is what makes naming the gap worth more than hiding it — both ports already require the caller to supply that resolver, so passing the guarded transport is a one-line choice at a site the application owns. Claude-Session: https://claude.ai/code/session_01VEcS7WSHviGSqQwCvEaSWS
The rule is that a resolver's default follows its URL's provenance, and it is written down in three places: the options struct's own comment, the design history, and the published threat model, which states it per language. It has drifted from the code twice. The scheme guard was added to the dial and not walked back through its callers, so a sentence beginning "every fetch" stayed standing after it stopped being true. Then the ports mirrored the options struct without the rule, and the correction to that shipped a threat-model paragraph claiming a posture two of three languages did not have. Prose cannot detect either failure. A socket can. One table per language over the well-known resolvers, asserting which of them dial a loopback origin with no transport injected. Changing a row obliges an edit to the threat model, and the comment says so — the failure is the reminder. Two rows record the ports' endpoint resolver reaching where Go refuses. That is a gap and not a decision, the files say so in as many words, and those are the rows that will fail in the change that closes it. A test that pins an insecure default without saying why would read as endorsement. The WBA directory's guarded default is left to the tests that already own it, where its non-opt-outable guard is covered too. The two default-transport assertions added for the requirements reader fold into these tables rather than asserting one thing in two places. Not a shared corpus, deliberately: a corpus replays one expectation in three languages, and here the expectations differ by language — which is the subject. Claude-Session: https://claude.ai/code/session_01VEcS7WSHviGSqQwCvEaSWS
The section argued the rule correctly and had no way to enforce it — which is how the same rule drifted from the code twice. It now names the per-language tests that dial for the answer, and says why two of their rows record a gap rather than a decision. Claude-Session: https://claude.ai/code/session_01VEcS7WSHviGSqQwCvEaSWS
The client computes []*RegistrationFieldError against the Exchange's published data_schema, flattens it into a sentence and drops the list. An Exchange refusing the same payload attaches that same list, from that same validator, as a typed RegistrationFailure detail — so the two sides of one refusal arrive in two shapes, and a consumer rendering both has to parse the members back out of prose. That is the thing PeerMessage's own contract says a layer must never be asked to do. The refusal now carries the detail beside the sentence. Its domain names the client's own tier rather than the Exchange, which never saw the request, and the naming rule both synthesized details follow — a Service suffix for an RPC service that exists in the contract, a bare noun for a tier that does not — is recorded once, beside the edge's. PeerMessage stays empty, which in Go holds structurally: only the decode site fills it. The field's doc now says so, because a typed detail the SDK built itself is not caught by the transport-envelope rule already written there. A whole-object violation is reported with an empty path, and the renderer emitted "path: error" unconditionally, so a missing required member read as ": required: ..." — a member with no name. The separator is now written only when there is a member to address. Claude-Session: https://claude.ai/code/session_01VEcS7WSHviGSqQwCvEaSWS
The Go oracle now returns the schema failures its registration pre-check found as a typed RegistrationFailure detail rather than only as prose. Both ports mirror it: same domain naming the client's own tier, same message, same reason, same list. Neither constructor needed a change. Both already accept a detail and both already refuse to derive peerMessage from one, which is the rule that keeps a synthesized detail from attributing the SDK's own sentence to a remote party — so the provenance invariant holds here for free, and each port gains the case that proves it. Each port also stops writing a separator for a violation with no member to address. An empty path is how a whole-object failure is reported, and ": ..." there read as a member with no name. Claude-Session: https://claude.ai/code/session_01VEcS7WSHviGSqQwCvEaSWS
…s record ramp.proto names the exact token each RetrievalAuthFailureReason stands for, beside the value: 'expired' is URL_EXPIRED, 'pop_expired' is PROOF_EXPIRED. Go carries that record as a table. Both ports instead DERIVED the enum name by uppercasing the token and prefixing it, which coincides with the record for two of the eleven tokens and misses the rest. So nine refusals a real delivery edge emits carried a typed reason in Go and none in either port, where the caller fell back to substring-matching the raw token — the outcome the mapping exists to prevent. The inverse held too: any string whose uppercase spelled an enum suffix was promoted, including spellings no edge sends. Both ports now carry the same table, with the same two deliberate absences: the token both checkers emit, which the body cannot disambiguate, and the edge's parse-level tokens, which have no enum value. An unmapped token still reaches the caller as the raw refusal string. One test moves with them. It asserted on a token the protocol does not record, which is why it passed against the derived name and would have failed against the record. Claude-Session: https://claude.ai/code/session_01VEcS7WSHviGSqQwCvEaSWS
Decoding a peer's ErrorDetail was already pinned across the three languages. Two details are not decoded at all: the content leg promotes a delivery edge's refusal token into a typed reason and writes the envelope around it, and the registration pre-check builds one from schema failures the peer never saw. In both, the failing surface, the sentence and the reason are authored — three times, in three languages, from three copies of one decision — and nothing compared them. The corpus records every one of those values, driven from the real client rather than described. Each recorded edge token is replayed in all three, including the two that must stay UNTYPED: the one the protocol records against two values, so no language may guess which check ran, and a spelling no edge emits that a language deriving names from tokens would promote anyway. Field errors are recorded as PATHS and never as text. The constraint prose comes from a different validator library in each language and the contract calls it non-authoritative, so pinning it would pin an accident. The Go replay adds the obligations a regenerated corpus cannot satisfy by agreeing with a regression: both member shapes present, a recorded reason that is the one the token table names, and both verdicts represented. The parity map's exclusion for the token lookup said the ports branched on the string directly. They now carry the same table, and the note says so. Claude-Session: https://claude.ai/code/session_01VEcS7WSHviGSqQwCvEaSWS
…ot retry Go classifies a requirements-reader refusal on three causes; both ports checked only two, so an injected reader answering "that is not a host" was classified retryable where Go calls it final. A value that is not a host will not become one on a later attempt, so a caller following that classification retries a verdict forever. Each port already made the right call one tier over — its own endpoint-routing leg treats the same refusal as final — so this was the account leg disagreeing with its neighbour inside one language, not only with the oracle. TypeScript now shares the predicate the routing leg already had rather than restating the prefix it matches on, which is how the two would have come apart again; Python catches the same shape its routing leg does. Go gains the table it never had. Its third cause had no test at all, which is why the ports could mirror two of three and look complete. Claude-Session: https://claude.ai/code/session_01VEcS7WSHviGSqQwCvEaSWS
Two rules the SDKs implement had no gate below them, and both had already drifted. An ErrorDetail's domain is deliberately open — no protovalidate rule, and the proto gives an example rather than a set — but the values in use fall into two shapes and the suffix separates them: a Service suffix names an RPC service the contract defines, a bare noun names a tier that is not one and appears in no descriptor. A guard now reads every committed domain and holds it to that. It caught a vector naming a registration service the contract has never defined, sitting beside two siblings using the exchange service for the same reason block; that fixture is corrected here. The delivery edge's refusal tokens are recorded only as a trailing comment beside each enum value, and the mapping is not derivable — 'expired' is URL_EXPIRED, 'pop_expired' is PROOF_EXPIRED, neither is the suffix lowercased. A second guard reads those annotations out of the .proto source and holds the shared corpus to them: every recorded token replayed, a token recorded against two values left untyped because the body cannot say which check ran, and a token the record does not carry never promoted. It fails on a wrong value, on a guessed one and on a missing row. Both read the vectors as data. Nothing in conformance depends on sdk/. Claude-Session: https://claude.ai/code/session_01VEcS7WSHviGSqQwCvEaSWS
Two new design-history sections. The first is why a refusal the client computed carries the same structured answer the Exchange would have sent, why its domain names the client's own tier rather than a party that never saw the request, what the Service suffix distinguishes, and why the field errors are compared by pointer and never by their prose. The second is why the details the SDK writes itself need their own corpus at all — nothing on the wire says what they should contain, so three languages author the same values from three copies of one decision, and the edge token table had already come apart that way. The peer-message section gains the case that was missing from it: a typed detail the SDK built itself leaves the field empty too, which the transport-envelope rule beside it does not cover. The cross-language decode section points at the new one rather than implying it covers synthesized details as well. The unreleased changelog entry is extended rather than followed by a second: it is the entry for this work and nothing has shipped yet. Both copies stay identical. Prose that describes the pre-check now says what its refusal carries — the Go SDK README, the manifest's registration-schema section, and the agent-SDK overview's account-setup sentence. Claude-Session: https://claude.ai/code/session_01VEcS7WSHviGSqQwCvEaSWS
main enforces WellKnownManifest.ver in the endpoint and key resolvers and exports the version constant; this branch adds the account-setup role, the synthesized- detail corpus and the two conformance guards under it. The overlap is the well-known document both sides taught to carry more, and the routing tier both sides taught to classify more. Resolution ledger: - mechanical: docs/sdk-parity-matrix.md -> generated file, never content-merged; regenerated from the merged symbol map (143 symbols, 37 corpora — both sides'). - mechanical: sdk/parity/symbol-map.json -> union by key; the two sides added distinct entries at the same insertion point. - mechanical: proto/CHANGELOG.md, website reference/changelog.mdx -> union under Unreleased, newest first, which keeps this branch's entry above main's. - mechanical: sdk/ts/resolvers/index.ts, sdk/python/ramp_sdk/resolvers/__init__.py, sdk/go/connect/taxonomy_test.go -> union of adjacent additions to a sorted export or import list. - semantic: sdk/go/resolvers/endpointresolver.go -> union. Both sides added RAW members to the shared projection and for the same reason, so the two comments are folded into one statement of it: main's ver plus its manifestVer reader, and this branch's role, terms_digest and account_registration plus the block type the requirements face decodes. - semantic: sdk/ts/client/route.ts -> union. main added the version refusal to the verdict set; this branch had extracted the invalid-host predicate so two callers share one copy of the prefix it matches. Kept both, with main's inline copy replaced by the call. - semantic: sdk/ts/resolvers/errors.ts, sdk/python/ramp_sdk/resolvers/errors.py -> union; three distinct error classes, none subsuming another. - semantic: sdk/go/connect/testsupport_test.go -> union. The shared manifest fixture now serves main's ver beside the role this branch's reader requires, and keeps the per-test extras hook the account verbs read. One file merged cleanly and was wrong, which is the failure a conflict list cannot show: sdk/go/resolvers/wellknowndoc_shared_test.go builds its manifests as literals, and all three omitted the ver the endpoint resolver now requires. They serve it from the exported constant, as the other fixtures do. Verified after resolution: go build/vet/test ./... clean including conformance · TS 1587 · Python 1710 · gen/ts 672 · API-surface parity gate 15 · website 26 tests, 79 pages, links valid · doc-conformance clean. Claude-Session: https://claude.ai/code/session_01VEcS7WSHviGSqQwCvEaSWS
The registration-requirements seam is injectable on purpose — an application that wants an allow overlay, or a compiled-schema cache this tier will not hold, supplies its own reader. What the seam did not offer was a way for that reader to say a refusal was final. It classified on the three sentinels the SDK's own reader raises and read everything else as a transport failure, so a reader stricter than the SDK's — which is the only kind anyone writes their own for — had its permanent refusals reported as transient and retried against a third party's origin indefinitely. The seam's first external implementation found it. That reader validates the whole manifest: the document must be JSON, must satisfy the manifest's own schema, must carry a version it recognises, must pass the protocol's field rules. Every one of those refusals is a property of the bytes that were served, none is in the SDK's three, so all of them arrived as "try again", forever. ErrManifestUnusable states the verdict: the document arrived, this reader cannot use it, and the next fetch returns the same bytes. The SDK's own reader never raises it, and that is the point rather than an omission. Its two ways of being disappointed are deliberate non-errors, each load-bearing somewhere else — an off-spec optional member reads as ABSENT because the projection is shared with the endpoint and key faces, and an undecodable document is a transport failure because the leg sharing this classification carries usage reports, where a wrongly-permanent refusal loses money. Both stay, and a test now asserts the reader raises the new verdict for neither rather than leaving it assumed. The seam doc gains the sentence its sibling already carried, that an implementation returning a bare error has its final answer retried indefinitely. Its absence was the root defect: the contract described its checks and not its verdict, which is how a reader infers one from whatever implementation it has at hand. Both ports stated no classification rule at all; all three do now. The endpoint seam's sentinels are not reused and nothing is shared between the two seams. Their vocabularies answer different questions — whether an endpoint may be dialled, and whether a document can be read for what a registration owes — and the one sentinel both honour is the invalid-host refusal, because that is the L1 host predicate both implementations run rather than a verdict either reaches. Claude-Session: https://claude.ai/code/session_01VEcS7WSHviGSqQwCvEaSWS
…ts type The host helpers are L1 and deliberately raise a bare ValueError for an unusable host reference, so both classifying legs had to catch that shape. TypeScript matches the message prefix through one exported predicate, and says in its own doc that a second copy of the prefix is how the two readers would come apart. Python matched the bare TYPE instead, in both legs. json.JSONDecodeError subclasses ValueError. So an injected resolver or reader that could not parse a document had its transport failure reported as a permanent verdict — final, do not retry — where the oracle and TypeScript both call it retryable and the next fetch may well succeed. Any other ValueError an injected seam raised was classified the same way. A verdict class is what the three languages owe each other; the wording is not, and this port was answering a different question from the other two. The predicate lives beside the factory that mints the prefix, which is the only place that string is written, and both legs call it. A typed exception class would have been the other repair and is still the wrong one: it would cross the helpers-versus-resolvers sentinel split the SDK documents, and the parity map's exclusion for the sentinel says the ports raise rather than export one. Private, so no public symbol moves and the parity map is untouched. Claude-Session: https://claude.ai/code/session_01VEcS7WSHviGSqQwCvEaSWS
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 subscribe to this conversation on GitHub.
Already have an account?
Sign in.
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
Account setup was the one integration role served entirely by application code.
Registerand
GetAccountStatuswere proto-defined RPCs with a complete generated client interfacein all three languages and no SDK verb in any of them, so the first thing an agent
developer does — open an account, check it is active — was hand-rolled inside the
platform's identity service where no external consumer could reuse it.
ADR-020's 2026-08-07 addendum names Account setup as a first-class role and defines done as
"a role is complete when someone in it can do their whole job through the SDK without
re-deriving protocol mechanics". It was written with this exact code as its motivating
example.
There was also a prediction to close.
RAMP-205-DECISIONS.mdH1 said: "With RAMP-211landed first, RAMP-219's calls route through the SDK client and inherit its vetting and its
pool. Without it, RAMP-219 has to hand-roll a replacement for the code RAMP-205 just
deleted." RAMP-219 shipped first and hand-rolled it. These verbs are that code's intended
home, so most of this is lifting a working implementation up a layer rather than designing
one.
What ships
(*connect.Client).Register/.GetAccountStatusclient.register/.getAccountStatusClient.register/.get_account_status+ thesynctwinsresolvers.WellKnownRequirementsReader+NewWellKnownRequirementsReadercreateWellKnownRequirementsReaderWellKnownRequirementsReaderconnect.RegistrationRequirementsReader+WithRegistrationRequirementsRegistrationRequirementsReader+registrationRequirementsRegistrationRequirementsReaderProtocol +ClientConfig.registration_requirementsCallError.PeerMessageRampCallError.peerMessageCallError.peer_messageWithSignWindowsignWindowClientConfig.sign_windowCorpus:
client-request-vectors.jsongainsregister,register_caller_ver_winsandget_account_status, replayed in all three languages.No proto change. No
connectserverchange — the Exchange handler already binds bothRPCs.
Parity record
Six new mapped symbols and one Go-idiomatic exclusion (
connect.WithRegistrationRequirements,the usual functional-option-builder reason). One new allowlist entry:
resolvers.NewWellKnownRequirementsReader, whose Go factory folds into the Python classconstructor exactly as every other
NewXdoes. The shrink-only baseline moves 16 → 17as a reviewed bump under that one already-recorded class.
The seam interface lives in
sdk/go/connectand the concrete reader insdk/go/resolvers,mirroring
EndpointResolver/WellKnownEndpointResolver. That split is what keeps thegrowth to a single allowlist entry rather than three.