Audience check in three languages, held to the wire by conformance - #35
Merged
Conversation
A request states its intended recipient in a body field, and nothing in the SDK read it. That field is the only agent-authenticated statement of audience that reaches an Exchange across a relay: each hop signs its own @target-uri, so on a Broker path the agent's signature covers the Broker's URL and the Exchange never receives one over its own. It also covers a direct hop whose dial target came out of a fetched manifest, where the signature covers the URL that was dialed while the body field states whom the sender meant. The check is pure string work, so it sits in the IO-free tier and runs before any lookup. That ordering is the point rather than an optimisation: the other identifiers a message carries are opaque and Exchange-scoped, and verifying one requires the very lookup an audience check exists to precede. Everything the request can get wrong is a verdict — it claimed nobody, claimed something that is not a domain, or named somebody else — while an unusable configured identity is an error. A caller maps the two onto different status codes without reading any text, because the two blame different parties. The accepted verdict is deliberately not the zero value, so a caller that drops the error reads "no verdict" rather than a pass. The comparison is exact: a subdomain is a different party and does not name this Exchange. That is narrower than the endpoint rule, where a manifest may advertise its endpoint on a subdomain of the host that served it — there the question is which addresses one Exchange answers at, here it is who the Exchange is. Two spellings of one identity still agree, since case folds and a written-out :443 folds; 80 does not, because it is not the default of the scheme a bare domain implies. The shape both sides admit is a new predicate rather than the existing bare-host one. They answer different questions — whether a value is safe to build a URL from, versus whether it is the form the wire contract admits — and they disagree on a trailing root dot, a leading hyphen, an underscore and a bracketed IPv6 literal, each of which is a usable host and none of which the contract accepts. A test pins that divergence so neither is later folded into the other. The pattern and its length bound are exported and recorded in the vectors beside the cases, so the rule stamped on the wire fields has one source and a guard can compare the two without the conformance tier importing the SDK. The Python and TypeScript faces land together with the Go one because the corpus gate admits no orphan: a committed vector file is replayed in all three languages or it does not land. Each port asserts its own copy of the pattern equals the recorded one, which is what a case-by-case pass alone would not prove. Python needs fullmatch rather than match — its $ also matches just before a trailing newline, and would accept a value RE2 refuses — and the vectors carry that case.
The bare-domain comments said the protovalidate rule on the wire's domain-valued fields carries these same bytes. It does not: the proto revision that adds that rule is separate work and has not landed. The claim shipped through go doc and the README as a property an implementer could act on — that anything this SDK accepts before sending, the wire accepts on arrival — so it is now stated as the obligation it is, with a standalone sentence saying the rule is not on the wire yet and this SDK is therefore stricter than the wire rather than equal to it. That sentence is one deletion when the revision lands. The corpus had no case where two claimed values failed differently, so nothing pinned which fault decides. An implementation that scanned the whole list for empty values before comparing any of them agreed with the oracle on every committed case and disagreed on ["other.example", ""] — the corpus is the only thing binding three implementations together, so a rule it does not reach is guarded by nothing. Six cases now cover both orders of each pair of fault kinds, each decided by its first value. The recipient's own domain is now defined rather than named. An Exchange may publish an identity of exchange.example and serve its API from api.exchange.example — the manifest contract explicitly allows it — so an operator who configures this from the host the process listens on refuses every request that addressed them correctly. A pure function cannot detect that, which is why the README also says a mismatch on every request means checking your own configuration before suspecting callers. The test pinning the two host predicates apart bound one predicate's answer and used it only in a failure message. Narrowing that predicate to agree with the other would have left the test green, which is the collapse it exists to prevent; both halves are asserted now. The trailing-colon case moves out of that table, because both predicates refuse it for unrelated reasons and it never evidenced a divergence. Two rationales were overstated and are corrected. Checking the length before the pattern was justified by a backtracking risk; measured, the pattern is unambiguous — every repetition is anchored by a literal dot — and its cost is linear, so the ordering stays as cheap insurance rather than a fix for a blowup that does not exist. And the warning that Python must apply the pattern with fullmatch, because its $ also matches before a trailing newline, sat beside the compiled matcher rather than on the exported pattern, which is not where a consumer of that constant reads. Finally, the default-port fold now exists twice in the Go package. The two are deliberately not merged — one answers scheme-relatively for values that may be full URLs, the other runs only on values a pattern has already reduced to bare domains, so reusing it would mean parsing a URL to learn what is already known — and the comment says so, and that they must keep agreeing.
The multi-Exchange proto revision landed on main, and with it the protovalidate pattern this SDK's bare-domain predicate was written to mirror. The merge itself was textually clean — the two branches touch disjoint files — but a clean merge is not a correct one here, because the two copies of that rule are held together by nothing but intent. They had already diverged. The proto spells the port as a real 1-65535 range; this SDK spelled it as one to five digits. So the SDK accepted six shapes the wire now refuses — :0, :012, :0443, :00443, :65536 and :99999 — which is exactly backwards: a client that vets a value before sending it must never be more permissive than the server receiving it. Every suite passed either way, in all three languages, because nothing compares the two patterns. That comparison is still missing and is the next commit. The pattern is now the proto's bytes verbatim in Go, TypeScript and Python, and the corpus grew the six port shapes that separate a range from a digit count. One audience case moved with it: a padded :0443 used to be a mismatch, reached because the shape check let it through, and is now refused for its shape before any comparison happens. The comments came into line too. Four places said the wire carried no such rule yet; it does now. And the reason given for the recipient travelling in the body, which this SDK had copied from the earlier design note, is replaced by the corrected one that landed with the revision: the signature proves the sender signed the URL it dialled, not that the URL was right, because that URL came out of a fetched, cached manifest. The claim that a relaying Broker cannot alter the field is gone — the field is a statement by whoever authors the request, and for transactions the binding statement is the Exchange-signed offer. Cross-recipient replay is not this field's job either: a recipient that rebuilds @target-uri from its own identity refuses a replayed capture at signature verification, before this check runs. Resolution ledger: - mechanical: none — no file was touched by both sides, so every path resolved without a conflict and no generated or data file needed pinning - semantic: sdk/go/helpers/hosts.go, sdk/ts/src/hosts.ts, sdk/python/ramp_sdk/hosts.py -> the proto's pattern subsumes this branch's; adopted verbatim in all three - semantic: sdk/go/helpers/gen_audience_vectors_test.go and its corpus -> union; six port cases added, one audience verdict follows the tightened shape rule - semantic: sdk/go/helpers/audience.go, sdk/go/README.md and both ports -> main's corrected addressing rationale subsumes this branch's copy of the old one
The bare-domain shape exists twice — as the protovalidate pattern on every domain-valued field of the contract, and as the constant the three SDKs export so a client can refuse a bad value before sending one. The second copy is only worth having while it is byte-identical to the first, and nothing checked that. It had already failed. When the shared constraint was stamped on the fields its port group was a real 1-65535 range, while the SDK's copy said "one to five digits", so the SDK accepted :0, :012, :0443, :00443, :65536 and :99999 — values the wire refuses. A client-side check that is looser than the server it protects is worse than none, because it reports a value as good that will be rejected on arrival. Every suite in every language passed; the divergence was found by reading the two regexes side by side. The guard reads the SDK's copy out of the committed vectors rather than restating it, for the reason the `ver` guard gives at length: this package cannot import sdk/go, since it is the tier below the SDKs. A file generated from the real constants, already replayed by the Python and TypeScript suites, is a data read like the corpus and the doc scans. Membership stays with the neighbouring guard — which fields carry the rule, that they all carry the same one, and the count ratchet that stops a new field skipping it. This one asserts agreement with the SDK and nothing else, and leans on that membership check for its field set, so a disagreement about the contract's shape fails there first and more precisely. Failure prints both values. Which side is wrong is not obvious from a boolean when the two strings are ninety characters of regex differing in one alternation, and the message says which side is authoritative: the wire.
The recipient rule was written as "a recipient MUST reject a request whose `exchange` is not one of its own domains", and the same plural reached the changelog and four sequence-diagram notes. There is no such thing as several domains for one Exchange: it has exactly one identity, the domain it stamps into the offers it issues, and the audience check compares against that one value. The plural came from the endpoint rule nearby, which does admit a set — a manifest may advertise its endpoint on the host that served it OR a subdomain of that host — but that rule answers which addresses one Exchange can be reached at, not who it is. Left as an open question the plural would have been a real fork: a recipient holding several identities needs a different check surface from one holding a single value, in three languages and a shared corpus. It is settled the other way, so the wording should not keep implying otherwise to whoever implements against it next. The endpoint rule's own "itself or one of its own subdomains" is untouched, and means what it says. Comments reach the generated types and the descriptor the reference pages render from, so gen/ is regenerated in the same commit.
… not Two host comparisons in this contract answer different questions and compare differently: the endpoint rule admits a host or a subdomain of it, the audience check admits exactly one identity domain. Only the first was written down. The second lived on a ticket and in a pull request, both of which outlive their usefulness faster than the code does, and its sibling has had a section here since the port question was settled. Recorded with it: what normalization survives the exact match and what does not, why the pattern and its length bound are exported when the money pattern they are modelled on stays private in all three languages, and where 260 comes from — it is the bound the contract carries, not a derivation from DNS's 253, so the next reader does not reconstruct it and conclude it is wrong.
None of these change behaviour; the vector corpus comes out byte-identical. They are statements about the code that a reader would act on and that do not hold. The audience check was documented as NOT being what stops a request captured at one recipient and replayed at another, on the reasoning that a recipient rebuilding @target-uri from its own identity refuses such a capture at signature verification. True of that recipient. This SDK is not it: the signature base rebuilds @target-uri from the ARRIVING request, falling back to the Host header, and the server binding takes no expected-host option. So a capture signed for one Exchange verifies at another when replayed with a forged Host, and this check is what refuses it. The design history already carried the qualifying clause; it was lost in the port to the code. Understating a control invites skipping it, which is the mirror of the overstatement corrected a round earlier. The rule was said to be on every domain-valued field of the contract. It is on the recipient-addressing family; nine fields that hold a domain carry no rule at all, and one of those — the error envelope's — is not a host in the first place. The direction is safe, since the wire accepts a superset of what the SDK does, but the converse is what an implementer reads it for. The new conformance guard described its neighbour's count as stopping a new field from skipping the rule. It does not: the count is over fields CARRYING the rule, so an addition that never carried it leaves the total where it was and both guards stay green — which is the state those nine fields are already in. The header now says what the count does catch, and separates the guard's two halves, because they are not equally direct: the length bound is a live descriptor read and the only place that number is pinned, while the pattern comparison runs against the literal the neighbour selects fields by. The chain still closes, and the failure message no longer implies the value it prints was read from the descriptor. Exporting the shape constant was justified partly by the conformance guard reading it. The guard reads the vectors file and cannot import the SDK at all; the emitter that writes those vectors lives in the same package as the constant, so it needs no export either. What actually needs it is an implementer writing their own validator, and TypeScript, where the parity test imports the constant to assert it against the vectors. The README promised that a value this SDK accepts before sending is one the wire accepts on arrival. The shape constant does agree with the wire rule byte for byte, but the client's send path vets with the wider host predicate, so the promise is not the SDK's to make yet. It now says what is true and names the gap. Also: a cross-reference pointed at a design-history section that does not exist, and claimed the shared vectors pin both of the package's default-port folds. They pin one; the other has its own test.
The recipient rule said an absent port and "the default port for the scheme in use" are the same port. The field forbids a scheme, so there is no scheme in use to take a default from — the value is a bare domain and nothing else. Read literally the sentence asks a recipient to fold :80 when it happens to be serving plaintext, which makes the comparison depend on transport the value never mentions and which a pure check has no way to learn. Narrowed to the rule that was actually agreed and is implemented everywhere: an absent port and an explicit :443 are one port, because a bare domain is read as https; :80 is a distinct port even under plaintext. The sentence's own example already said HTTPS, and the SDK's two host predicates both behave this way on schemeless values — the scheme-relative fold applies where a scheme is genuinely written, which is endpoint URLs, not this field. Wording only. No rule, no generated type and no test changes; the descriptor moves because the reference pages render from it.
…pass The corpus held 143 values and exactly one of them was outside ASCII, and no case crossed two rules. That is enough to check each rule alone and not enough to check the order they run in, which is where independent implementations of one rule actually diverge. Three wrong implementations passed the whole audience corpus. Two lowercase or NFKC-normalise a value BEFORE checking its shape, which is the order the contract's own prose invites — it says the value is lowercased before comparing, without saying that the shape rule applies to the value as sent. The third lowercases only inside the branch that handles no port or :443, which every previously accepted case happened not to exercise. Each returned the wrong answer on a value the real face gets right, and the corpus certified all three. Six cases close it. A label carrying U+212A KELVIN SIGN is refused for its shape; lowercase it first and it becomes a plain ASCII "market.example" that matches the identity exactly, so folding before checking turns a homograph into an accepted recipient. A label carrying fullwidth U+FF25 survives lowercasing unchanged — it folds to ASCII only under NFKC — so the two are not variants of one case, they catch different mistakes, and a comment says so where the next reader will be tempted to merge them. Two more cross case folding with a port that is not the folded one. The codepoints are written as Go escapes rather than pasted, because a literal KELVIN SIGN does not survive every editor and a silently substituted ASCII K would make the case pass while testing nothing. The emitter caught exactly that substitution while these were being written, which is the argument for the escapes and for the intent column that caught it.
The audience doc said port 80 "is not folded anywhere else here". It is: canonicalPort folds it whenever the caller supplies http, because 80 is that scheme's default. The two comparisons differ because one is handed a scheme and the other is handed a value that names none — a difference between them, not an inconsistency, and the comment now says which. The same block justified keeping the two folds apart by claiming reuse would mean parsing a URL. It would not; canonicalPort takes two plain strings. The real cost is having to invent a scheme this path does not have, so that is what it says now. The replay paragraph ended "This check is what refuses it", which is true of a recipient that calls the check and reads, without one, as though the SDK already refuses it. Nothing here calls it — wiring it into a server is the adopting service's job — so the sentence now says so, and says that the exposure is the reason to do it. Separately the TypeScript parity suite guarded two of the four things its Python sibling guards. It asserted the two top-level arrays are non-empty but not the two partitions, and the loop picks a throw assertion or a value assertion per case: a corpus that lost every identity-fault case would leave TypeScript registering zero throw assertions and reporting green, while Python failed. The weaker guard had landed on the language with no checked exceptions, where it protects least.
The recipient paragraph described the shape and the port fold and left out three things that change whether a request is accepted. Each is already enforced — by the protovalidate rule, or by every SDK — so this writes down what the wire does rather than changing it. An implementer working from the contract alone could get all three wrong and still believe they had conformed. The identity is ONE domain, matched exactly, and a subdomain is a different party. The contract never said so, while the endpoint rule a few hundred lines below explicitly admits a subdomain — so the natural reading was to use one anchored match for both fields. That reading lets anyone holding a subdomain claim the parent. The two rules answer different questions and the paragraph now says which is which. The alphabet is ASCII, and an internationalised name travels as punycode. The pattern already admits nothing else; saying it stops a consumer adding an IDN-tolerant pre-pass in the belief that it is being helpful. Case folding happens AFTER the shape check, never before. This is the one with teeth: several codepoints fold into ASCII letters — U+212A KELVIN SIGN lowercases to "k" — so a consumer that normalises first turns a homograph into an exact match on somebody else's identity. The old wording said only that the value is lowercased before comparing, which invites exactly that order. Also rewraps a 115-column line in a block that otherwise wraps at 63-85.
EugeneDymo
added a commit
that referenced
this pull request
Aug 14, 2026
Brings in the audience/domain work (#35): the SDK-side bare-domain rule, the Go/Python/TS host helpers and their shared audience vectors, plus the proto comment corrections that went with them. Three conflicts, all resolved by regenerating rather than hand-merging, because both sides changed generated artifacts: gen/descriptor.binpb, gen/ts/wire/schemas.ts — rebuilt from the merged proto (buf generate + buf build, then scripts/gen-sdk-types.sh). Both sides' content is present: main's ramp.v1 comment corrections and this branch's ramp.admin.v1 evidence plane. One semantic conflict git could not see. main's new conformance/ domain_sdk_parity_test.go calls a local fieldRules helper that this branch had already deleted from domain_constraint_test.go, when it moved rule resolution into contract.go. The guard now calls the shared FieldRules, which is where that call was heading anyway — and it inherits the stricter error policy: the deleted helper read a RESOLVER ERROR as "no rules", which would have reported this guard's rules as "vanished" and pointed a reader at the wrong problem. FieldRules panics on a resolver error and returns nil only for a genuinely unruled field. Verified on the merged tree: buf lint, go build/vet/test, no corpus drift, doc conformance, the SDK parity matrix, the website guards, 770 Pydantic parity tests, 719 Zod parity tests and the canonical round-trip. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ci6XyfVXH4bPZSRifBXP5c
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
exchangeis now a required field on every addressed request. Nothing in this SDKread it. Downstream, exactly one place did — the Exchange's report validator — and
it fails open on both sides: an absent claim passes, and so does an empty
configured identity. So today the check is opt-in for whoever is sending.
This builds it once, in three languages, with a shared corpus that pins what it
means and a conformance gate that holds it to the wire.
What landed
sdk/go/helpers/audience.go— the check. Variadic, so one call covers asingle
exchangefield andTransactionRequest's per-itemitems[].offer.exchange; every item must name this Exchange, first failuredecides.
sdk/go/helpers/hosts.go—IsBareDomain, plus the exportedBareDomainPattern/MaxBareDomainLen.sdk/go/helpers/testdata/audience-vectors.json— 51 shape cases, 40 audiencecases, with the pattern and length bound recorded beside them as data.
sdk/ts/src/hosts.ts,sdk/python/ramp_sdk/hosts.py— ports and replaysuites.
conformance/domain_sdk_parity_test.go— the gate: the SDK's rule must equalthe wire's.
Decisions worth a reviewer's attention
Request faults are verdicts; an unusable own identity is an error. The verdicts
(
empty,malformed,mismatch) are things the request got wrong; the errormeans this deployment's configured identity is not a bare domain. They blame
different parties and want different status codes, so a handler picks one without
parsing a string.
AudienceAcceptedis deliberately not the zero value — acaller that drops the error reads
AudienceNoVerdict, never a pass.The match is exact — a subdomain does not name this Exchange. Narrower than the
endpoint rule, where a manifest may advertise its endpoint on a subdomain of the
host that served it. Different questions: there, which addresses one Exchange
answers at; here, who the Exchange is. Spelling still folds — case, and a
written-out
:443because a schemeless domain reads as https.:80does not.IsBareDomainis a second predicate, not a replacement forIsBareHost. Isthis safe to build a URL from versus is this the form the wire admits. They
disagree on
exchange.example.,-x.example,_x.example,[::1]:443— each ausable host, none a valid wire value. A test pins the divergence in both directions
so a later "cleanup" has to delete the reason.
Fail-closed where the downstream validator is open. No claim, or an empty
claim, is a refusal; an unusable identity is the error.
The merge caught a real divergence
mainlanded the protovalidate rule mid-branch. The merge was textually clean,everything compiled, all three suites passed — and the SDK was wider than the
wire: its port group said "one to five digits" where the wire says a real
1–65535 range, so it accepted
:0,:012,:0443,:00443,:65536,:99999.A client-side check looser than the server it fronts is worse than none.
Found by hand-diffing two regexes, which is why the conformance gate now exists.
It reads the rule the SDK ships out of the committed vectors and compares it to the
descriptor — a data read, since
conformanceimports nothing undersdk/bydesign. Mutation-tested: it fires on a changed pattern and on a changed
max_len.Also in here
conformance/domain_sdk_parity_test.gocloses the "one definition, cannotdrift apart" requirement, which was a convention until now.
of its own domains" (confirmed by the author); and the recipient's port folds
:443specifically, not "the default port for the scheme in use" — the fieldforbids a scheme, so there is none to take a default from.
docs/design-history.mdrecords why the audience match is exact where theendpoint rule is not, why the constants are exported against the money
precedent, and that 260 is the contract's
max_lenrather than somethingderived from DNS's 253.
Deferred
rule yet;
HostAnchored/HostOf/IsBareHoststaygo_exclusions.fail-open branches is the flag-day story.
Offer.exchangewith the widerIsBareHost, so theSDK can dial a value the wire would reject. Pre-existing, ticketed separately.
The pure predicates landed in all three languages rather than Go-only because
test_corpus_replay_completeness.pyadmits no orphan corpus — "deliberately NOexemption mechanism". Each port asserts its own pattern equals the recorded
one, which case-by-case agreement alone would not catch.
One cross-language trap
Python's
$also matches before a trailing newline, sore.matchaccepts"exchange.example\n"— a value Go's RE2 refuses.re.fullmatchrestores theanchoring; JavaScript's
testalready matches Go. Thetrailing_newlinevector iswhat proves a port got it right.
Review path
sdk/go/helpers/audience.go(the doc comments carry the reasoning) →gen_audience_vectors_test.go, where the case list is the real specification andthe emitter refuses to write a file whose verdicts the real face disagrees with →
the JSON → either port. Read
hosts.goas a diff.Verification
Plus the gates
ci-local.shdoes not run — API-surface parity, corpus-replaycompleteness, corpora-nonempty, TS package exports, parity-matrix drift — all
green;
ruff,gofmtclean.Both gates mutation-tested by hand: tampering one byte of the corpus fails the
vector drift gate; a changed pattern or
max_lenfails the conformance gate.Regenerate after an intended change:
RAMP_UPDATE_VECTORS=1 go test ./sdk/go/helpers/ -run TestGenerateAudienceVectorsBookkeeping
docs/sdk-parity-matrix.mdregenerated, not hand-edited — 82 symbols at parity(was 77), 24 corpora each tri-replayed (was 23).
./hostswent into both exportmaps rather than the
UNREACHABLEdebt list, so the new TS module is importablerather than shipping unreachable.