Skip to content

Pre-v1 revision: name the recipient, block the registration contract, split the account denial (RAMP-226) - #34

Merged
noxlesh merged 12 commits into
mainfrom
feat/ramp-226-multi-exchange-revision
Aug 14, 2026
Merged

Pre-v1 revision: name the recipient, block the registration contract, split the account denial (RAMP-226)#34
noxlesh merged 12 commits into
mainfrom
feat/ramp-226-multi-exchange-revision

Conversation

@noxlesh

@noxlesh noxlesh commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

The proto half of the multi-Exchange accounts work. Four contract gaps close here so
the affected messages are revised once rather than twice; the SDK and application
tickets build on the shapes settled in this PR.

What changes

Every addressed request names its recipient. exchange becomes a required bare host
on ten messages — ResourceQuery, DisputeRequest, RegisterRequest,
GetAccountStatusRequest, the domain-verification pair and the three Catalog requests —
and UsageReport.exchange is promoted from optional, which is the breaking half: an
absent value used to skip the recipient check entirely, so the check was opt-in for the
caller.

The RFC 9421 signature does not already establish this. On a relayed path each hop signs
its own @target-uri, so the agent's signature covers the Broker's URL. On a direct hop
the signature does cover the recipient's URL — but that URL came out of a fetched, cached
manifest, so it proves the sender signed the URL it dialled, not that the URL was right.

Two messages are deliberately exempt. DiscoveryRequest terminates at the Broker, which
authors fresh per-Exchange ResourceQuery messages rather than forwarding it, and the
agent cannot name the fan-out set anyway. TransactionRequest needs no top-level field
because its audience statement is per item — so Offer.exchange is presence-enforced
instead, and the receive rule is that every item's offer names this Exchange.

Registration becomes a manifest block. registration_schema (field 29) is replaced by
account_registration (field 30) carrying data_schema, with field 2 left free for a
future web mode, and the precedence rule fixed now while it is still cheap: an Exchange
publishing data_schema MUST accept registration through the API.

Terms get a record, not just a pointer. terms_digest (field 31) pins the document at
terms_uri, and RegisterRequest echoes it under the request signature. All four echo
cases are specified, not only the stale one, and a message rule keeps a digest from being
published without the address it pins.

The account denial splits. DENIAL_REASON_BILLING_REF_INACTIVE becomes
ACCOUNT_INACTIVE (keeping field 1) and ACCOUNT_NOT_REGISTERED (field 18) — two states
of the caller with two different remedies — and TransactionDenial.exchange names where
to register. This reverses a decision recorded in docs/design-history.md, so it is
written up there as a supersession rather than changed silently.

The domain shape is a rule, not prose. One shared string constraint (max_len: 260
plus a bare-host pattern) goes on all seventeen domain-valued fields, including five that
until now accepted any string at all. The pattern is deliberately permissive — single-label
service hosts with a port are what real deployments use, and a stricter dotted-name rule
would reject the three-Exchange e2e stack.

Verification

./scripts/ci-local.sh passes, as do sdk/ts (522), sdk/python (538) and the website
build. The corpus grows 208 → 345 cases; Offer enters it for the first time, since a
message with no field rules produces no cases at all.

Two enum values reached no case anywhere — the corpus generator auto-fills the first
allowed value and the undefined mutant jumps past the rest — so the stale-terms reason and
the register-first denial each got a cross-language vector. Both replay in Go, TS and
Python without a source edit.

A descriptor-walking guard covers the AC directly: every field carrying the shared
constraint is checked against ten malformed shapes and eight real hosts.

Follow-ups, not in this PR

  • The SDK drift gate. feat/go-sdk-vectors publishes BareDomainPattern and
    MaxBareDomainLen plus an accept/reject vector list. The pattern and length in this PR
    are byte-identical to them, but the gate that proves they cannot drift has to read that
    vectors file, so it lands once that branch is in main. It is written and waiting.
  • The terms-acceptance sentence is pending legal confirmation that an agent's key
    signature counts as its operator accepting a third party's terms. The fields are needed
    either way; only the wording is contingent.
  • proto/CHANGELOG.md and its website mirror are already divergent in main on historical
    entries. The entry added here is byte-identical in both; the older drift is untouched and
    has no gate.

🤖 Generated with Claude Code

noxlesh and others added 12 commits August 13, 2026 16:48
`exchange` becomes a required bare host on the ten addressed requests, and
`Offer.exchange` — the execute-routing target and the audience statement of a
TransactionRequest — is presence-enforced. The RFC 9421 signature cannot carry
this: on a relayed path each hop signs its own @target-uri, and on a direct hop
the dialled URL came from a fetched, cached manifest, so the signature proves
only that the sender signed the URL it dialled.

The manifest's registration field becomes an `account_registration` block whose
`data_schema` keeps the publish-is-enforce contract, leaving room for a future
web mode, and `terms_digest` joins `terms_uri` so a registration records WHICH
terms document was accepted. The single billing-reference denial splits into
"no account — register first" and "account exists — awaiting activation", with
`TransactionDenial.exchange` naming where to register.

The pattern is deliberately permissive — labels, optional port, no scheme or
path, case normalised — matching the structural bare-host rule the reference
clients already apply. A stricter dotted-name rule would reject the
single-label service hosts real deployments use.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rule (RAMP-226)

Message-level CEL does not survive the JSON-Schema export the TS and Python
wire types are generated from, so each rule is transcribed by hand in
crossfield.ts and crossfield.py. The new
well_known_manifest.terms_digest_requires_terms_uri had no transcription, so
both clients accepted a manifest the Go oracle rejects — the exact divergence
the shared crossfield corpus exists to catch, and it caught it.

The corpus-shape assertions pinned an exact five-message set, so they move to
six rather than being loosened: an exact set is what makes a forgotten
transcription fail loudly instead of silently.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The remark autolink pass resolves every backticked proto symbol against the
current descriptor and fails the build on a member that no longer exists. It
does not exempt the changelog, but a changelog entry recording a rename has to
name the old value — so it goes on the ignore list, alongside the manifest field
the same entry supersedes. Both are self-cleaning: the build fails again if
either ever starts resolving.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…-226)

REGISTRATION_FAILURE_REASON_TERMS_DIGEST_STALE reached no case anywhere — not
the corpus, which auto-fills the first allowed enum value, and not the
error-detail vectors. A reason no vector exercises is a reason a client can drop
while staying green, which is the gap the field-errors vector was added to close
for its own branch a revision ago.

The vector carries no field_errors on purpose: that list is scoped to
INVALID_REGISTRATION_DATA and every other reason leaves it empty, so this also
pins that boundary. Both replays pick it up from the corpus with no source
change, as the tri-language replay contract intends.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…anything (RAMP-226)

ResourceResponse.exchange, RequestConstraints.exchanges,
RequestConstraints.preferred_exchanges, Requester.domain and
AuthorizedExchange.domain carried a domain in name only — no rule at all, so an
empty string, a URL or a path passed. They now take the same constraint as the
recipient fields this revision adds, because one value space with two contracts
is the state that produces the bugs.

Requester.domain earns it most: a verifier concatenates it into
{domain}/.well-known/ramp.json and fetches the result, so a smuggled path or
query would choose WHAT gets fetched, not merely from where.

The guard walks the descriptor rather than listing fields, so a field that gains
the pattern later is covered without editing the test, and it fails loudly if the
pattern is renamed out from under it. The malformed shapes live there instead of
in the corpus generator: its bad-string table is shared with the money and token
fields, and widening it there would add a mutant to every pattern-ruled field in
the contract to cover shapes that only matter here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…MP-226)

DENIAL_REASON_ACCOUNT_NOT_REGISTERED reached no case anywhere. The corpus
generator auto-fills the first allowed enum value, so TransactionDenial.reason
always lands on ACCOUNT_INACTIVE and the undefined-value mutant jumps past 18 —
leaving the half of the split that actually tells an agent to act untested, and a
client that never decoded it green.

The vector carries the reason alone. TransactionDenial.exchange already has
cross-language coverage from the field corpus, and this family's sub-fields are
caller-set, so feeding one back through the builder would assert a construction
the builder does not perform — which is what the construct replay caught when the
first attempt did exactly that.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…xchange-revision

# Conflicts:
#	gen/descriptor.binpb
#	gen/ts/wire/schemas.ts
#	proto/CHANGELOG.md
#	website/src/content/docs/reference/changelog.mdx
…AMP-226)

UsageReport.exchange is no longer optional, so the generated Go field is a
string rather than a *string and the fixtures that arrived on main while this
branch was open no longer compile. Git could not see this: there is no textual
conflict, only a semantic one between a field this branch retyped and callers
main added meanwhile.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Seven items, all verified before acting; two were reproduced first.

**The composed cross-field model was missing.** Both SDKs registered the new
manifest rule in the dispatch map but never added WellKnownManifestCrossField
beside its five siblings — the surface both modules document as the way a
consumer validates a fetched ramp.json. A manifest publishing terms_digest with
no terms_uri was therefore accepted by TS and Python and refused by Go, on the
one message consumers most often parse from a third party.

**The rule had no positive instance, so its predicate could be inverted.**
Reproduced: rewriting it to fire on a missing terms_uri — rejecting every
manifest that publishes no terms document, the common case — left both suites
green, because the corpus only carries mutants and mutants only prove
over-acceptance is caught. Both languages gain a valid instance for each new
rule plus an assertion tying the instance list to the registered rule set, so
the next rule cannot arrive without one.

**The malformed-domain shapes were proven in Go only.** The acceptance criterion
asks for vectors "consumed by the SDK sub-tasks", and a Go test is not that. The
generator gains a per-pattern killer table keyed by the pattern string, so a
family's own refusal shapes reach the corpus without widening the table shared
with money and token fields. Each entry is asserted to actually fail its pattern,
so a stale one cannot sit there as a no-op. 170 cases across all 17 fields,
replayed by Pydantic and Zod.

**Membership was observed rather than required.** The guard found its fields by
matching the pattern, so a field that lost the constraint was invisible, and the
anti-vacuity floor sat two below the real count. It is now an exact count, and
the digest pattern — on its third hand-copy — gets the same descriptor-derived
check.

**The guard could pass for the wrong reason.** It asserted string.pattern fired
somewhere on the message, not on the field under test; a sibling's required
recipient firing on the same bare message would have satisfied it. Matching is
now on the violation's field path, which is also the only form that works for a
repeated field's item, where the descriptor is nil.

**field_errors scoping was prose in four places and enforced in none.** It now
carries a message rule, mirrored into both SDKs with its own corpus mutant and
valid instances — the mechanism this branch already established for the manifest
rule, applied to the second case that needed it.

**The port question was unanswered.** The contract said the value is
case-normalised and nothing about the port, leaving two implementations free to
disagree on whether exchange.example and exchange.example:443 name the same
recipient. Stated: the port is compared, with default-port folding.

Also: Discover's doc comment now names the recipient obligation it created, and
the new recording double takes the mutex its six siblings use.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…-226)

**Dispute never filled the field this branch made required.** ReportUsage reads
its destination off the message, and the comment beside it explains why that is
structural: there is no parameter a configured origin could ride in as. Dispute
took the domain as an argument, and its own comment gave the reason —
"DisputeRequest carries no exchange field to read it from — an asymmetry forced
by the message shape, not chosen." This branch added that field, so the reason
stopped holding and the asymmetry became a choice, while every SDK-built dispute
went out with an empty recipient that a conforming Exchange must reject. The
destination now comes off the request, the signature loses the argument, and an
unaddressed dispute is refused before anything is signed.

**TransactionDenial.exchange was an unsigned redirect.** It tells an agent where
to call Register, and the SDK is meant to make that call unattended — but the
field rides in a response, a relayed response passed through an intermediary, and
nothing signs it. That is the same unsigned addressing the request-side field
exists to refuse, pointed the other way: a hostile Broker answering
ACCOUNT_NOT_REGISTERED with its own host steers an agent into handing over the
operator's business data and a signed acceptance of the attacker's terms. It is
now documented as a hint the caller MUST check against a domain it already trusts
for the transaction before acting.

**The port group counted digits instead of naming the range**, so `:0` and
`:99999` passed. Spelled out as 1-65535 across all seventeen fields, with both
shapes added to the corpus killers and the descriptor guard. Checked against the
45 accept/reject cases the SDK vectors publish: none of them exercises a port the
narrowing newly refuses, so the tightening does not contradict them — but the
pattern string now differs from `helpers.BareDomainPattern` on the unmerged
audience branch, which has to adopt it before the drift gate lands.

**A doc example did not compile.** `verified.Offer(.GetExchange())` — my own
regex from the optional-to-plain-string migration cut the call in half on a
comment line. It is the line callers are told to copy.

Also: two ext-legal offers carried the EU exchange as issuer for a US PACER
filing and a US patent aggregator, from the bulk patch's per-file default; three
module headers still counted seven rules and five messages; the guard test
swallowed non-validation errors behind a single-statement type assertion,
re-implemented ResolveFieldRules, and built its message twice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The signature proves the sender signed the URL it dialled, not that the
URL was right; the body field states whom the sender meant, stamped by
whoever authors each request. Remove the two over-claims (relay
tamper-evidence, cross-recipient replay), record the verbatim-forwarding
signing convention, and keep the narrow replay backstop in the design
history only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…226)

gen/descriptor.binpb embeds source comments, so the rationale correction
in ramp.proto changes its bytes; regenerated with the pinned buf 1.66.1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@noxlesh
noxlesh merged commit eb17975 into main Aug 14, 2026
4 checks passed
@legendko
legendko deleted the feat/ramp-226-multi-exchange-revision branch August 14, 2026 12:12
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.

2 participants