Skip to content
This repository was archived by the owner on Sep 11, 2026. It is now read-only.

Account-setup SDK: Register + GetAccountStatus, and the fresh-manifest read they need - #49

Open
legendko wants to merge 24 commits into
mainfrom
feat/agent-account-setup-client
Open

legendko wants to merge 24 commits into
mainfrom
feat/agent-account-setup-client

Conversation

@legendko

@legendko legendko commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

Why

Account setup was the one integration role served entirely by application code. Register
and GetAccountStatus were proto-defined RPCs with a complete generated client interface
in 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.md H1 said: "With RAMP-211
landed 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

Slice Go TypeScript Python
The two verbs, on the agent client (*connect.Client).Register / .GetAccountStatus client.register / .getAccountStatus Client.register / .get_account_status + the sync twins
The uncached requirements reader resolvers.WellKnownRequirementsReader + NewWellKnownRequirementsReader createWellKnownRequirementsReader WellKnownRequirementsReader
The injected seam connect.RegistrationRequirementsReader + WithRegistrationRequirements RegistrationRequirementsReader + registrationRequirements RegistrationRequirementsReader Protocol + ClientConfig.registration_requirements
The peer's own sentence CallError.PeerMessage RampCallError.peerMessage CallError.peer_message
The freshness knob at one tier (existing) WithSignWindow (existing) signWindow new ClientConfig.sign_window

Corpus: client-request-vectors.json gains register, register_caller_ver_wins and
get_account_status, replayed in all three languages.

No proto change. No connectserver change — the Exchange handler already binds both
RPCs.

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 class
constructor exactly as every other NewX does. The shrink-only baseline moves 16 → 17
as a reviewed bump under that one already-recorded class.

The seam interface lives in sdk/go/connect and the concrete reader in sdk/go/resolvers,
mirroring EndpointResolver / WellKnownEndpointResolver. That split is what keeps the
growth to a single allowlist entry rather than three.

AC 5 note: the completeness gate parses go doc and captures no methods, so the two
verbs are invisible to it by construction. Their cross-language parity is carried by the
client-request-vectors.json rows replayed in all three languages, not by a matrix row.

…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
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant