Skip to content

Audience check in three languages, held to the wire by conformance - #35

Merged
legendko merged 11 commits into
mainfrom
feat/go-sdk-vectors
Aug 14, 2026
Merged

Audience check in three languages, held to the wire by conformance#35
legendko merged 11 commits into
mainfrom
feat/go-sdk-vectors

Conversation

@legendko

Copy link
Copy Markdown
Collaborator

Why

exchange is now a required field on every addressed request. Nothing in this SDK
read 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

verdict, err := helpers.CheckAudience(cfg.ExchangeDomain, report.GetExchange())
  • sdk/go/helpers/audience.go — the check. Variadic, so one call covers a
    single exchange field and TransactionRequest's per-item
    items[].offer.exchange; every item must name this Exchange, first failure
    decides.
  • sdk/go/helpers/hosts.goIsBareDomain, plus the exported
    BareDomainPattern / MaxBareDomainLen.
  • sdk/go/helpers/testdata/audience-vectors.json — 51 shape cases, 40 audience
    cases, with the pattern and length bound recorded beside them as data.
  • sdk/ts/src/hosts.ts, sdk/python/ramp_sdk/hosts.py — ports and replay
    suites.
  • conformance/domain_sdk_parity_test.go — the gate: the SDK's rule must equal
    the 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 error
means 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. AudienceAccepted is deliberately not the zero value — a
caller 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 :443 because a schemeless domain reads as https. :80 does not.

IsBareDomain is a second predicate, not a replacement for IsBareHost. Is
this 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 a
usable 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

main landed 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 conformance imports nothing under sdk/ by
design. Mutation-tested: it fires on a changed pattern and on a changed max_len.

Also in here

  • conformance/domain_sdk_parity_test.go closes the "one definition, cannot
    drift apart" requirement, which was a convention until now.
  • Two proto corrections. A recipient has exactly one identity domain, not "one
    of its own domains" (confirmed by the author); and the recipient's port folds
    :443 specifically, not "the default port for the scheme in use" — the field
    forbids a scheme, so there is none to take a default from.
  • docs/design-history.md records why the audience match is exact where the
    endpoint rule is not, why the constants are exported against the money
    precedent, and that 260 is the contract's max_len rather than something
    derived from DNS's 253.

Deferred

  • The IO half of the TS/Python ports — neither enforces the endpoint-anchor
    rule yet; HostAnchored / HostOf / IsBareHost stay go_exclusions.
  • Downstream adoption — replacing the hand-written copies and flipping the
    fail-open branches is the flag-day story.
  • The send path still vets Offer.exchange with the wider IsBareHost, so the
    SDK 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.py admits no orphan corpus — "deliberately NO
exemption 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, so re.match accepts
"exchange.example\n" — a value Go's RE2 refuses. re.fullmatch restores the
anchoring; JavaScript's test already matches Go. The trailing_newline vector is
what 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 and
the emitter refuses to write a file whose verdicts the real face disagrees with →
the JSON → either port. Read hosts.go as a diff.

Verification

./scripts/ci-local.sh                                                   # PASS
go build ./... && go vet ./... && go test ./...                         # 8/8 ok
cd sdk/ts && npm test                                                   # 626 passed
cd sdk/python && PYTHONPATH=../../gen/python:. uv run pytest tests -q    # 646 passed

Plus the gates ci-local.sh does not run — API-surface parity, corpus-replay
completeness, corpora-nonempty, TS package exports, parity-matrix drift — all
green; ruff, gofmt clean.

Both gates mutation-tested by hand: tampering one byte of the corpus fails the
vector drift gate; a changed pattern or max_len fails the conformance gate.

Regenerate after an intended change:

RAMP_UPDATE_VECTORS=1 go test ./sdk/go/helpers/ -run TestGenerateAudienceVectors

Bookkeeping

docs/sdk-parity-matrix.md regenerated, not hand-edited — 82 symbols at parity
(was 77), 24 corpora each tri-replayed (was 23). ./hosts went into both export
maps rather than the UNREACHABLE debt list, so the new TS module is importable
rather than shipping unreachable.

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.
@legendko
legendko merged commit b16e917 into main Aug 14, 2026
4 checks passed
@legendko
legendko deleted the feat/go-sdk-vectors branch August 14, 2026 18:27
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant