Pre-v1 revision: name the recipient, block the registration contract, split the account denial (RAMP-226) - #34
Merged
Conversation
`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>
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.
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.
exchangebecomes a required bare hoston ten messages —
ResourceQuery,DisputeRequest,RegisterRequest,GetAccountStatusRequest, the domain-verification pair and the three Catalog requests —and
UsageReport.exchangeis promoted fromoptional, which is the breaking half: anabsent 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 hopthe 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.
DiscoveryRequestterminates at the Broker, whichauthors fresh per-Exchange
ResourceQuerymessages rather than forwarding it, and theagent cannot name the fan-out set anyway.
TransactionRequestneeds no top-level fieldbecause its audience statement is per item — so
Offer.exchangeis presence-enforcedinstead, and the receive rule is that every item's offer names this Exchange.
Registration becomes a manifest block.
registration_schema(field 29) is replaced byaccount_registration(field 30) carryingdata_schema, with field 2 left free for afuture web mode, and the precedence rule fixed now while it is still cheap: an Exchange
publishing
data_schemaMUST accept registration through the API.Terms get a record, not just a pointer.
terms_digest(field 31) pins the document atterms_uri, andRegisterRequestechoes it under the request signature. All four echocases 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_INACTIVEbecomesACCOUNT_INACTIVE(keeping field 1) andACCOUNT_NOT_REGISTERED(field 18) — two statesof the caller with two different remedies — and
TransactionDenial.exchangenames whereto register. This reverses a decision recorded in
docs/design-history.md, so it iswritten up there as a supersession rather than changed silently.
The domain shape is a rule, not prose. One shared
stringconstraint (max_len: 260plus 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.shpasses, as dosdk/ts(522),sdk/python(538) and the websitebuild. The corpus grows 208 → 345 cases;
Offerenters it for the first time, since amessage 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
feat/go-sdk-vectorspublishesBareDomainPatternandMaxBareDomainLenplus an accept/reject vector list. The pattern and length in this PRare 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.signature counts as its operator accepting a third party's terms. The fields are needed
either way; only the wording is contingent.
proto/CHANGELOG.mdand its website mirror are already divergent inmainon historicalentries. The entry added here is byte-identical in both; the older drift is untouched and
has no gate.
🤖 Generated with Claude Code