feat(proto): let an Exchange publish and enforce a registration schema (RAMP-217) - #31
Merged
Merged
Conversation
…a (RAMP-217)
An agent integrating the SDK directly signs and sends Register itself and
passes through no registration front-end, so a check only a front-end performs
is a suggestion, not a rule — and the agent had nowhere to learn which business
fields a given Exchange expects. Both gaps resolve against the manifest the
agent already fetches to find the Exchange's endpoint.
WellKnownManifest gains registration_schema (field 29): a JSON Schema
(draft 2020-12, max 16KB) for the registration_data object the Exchange expects
on Register. Publication and enforcement are one decision — publish the schema
and the Exchange validates against it, refusing a non-conforming payload with
the new REGISTRATION_FAILURE_REASON_INVALID_REGISTRATION_DATA; publish none and
the payload is accepted uninspected and passed to the system of record exactly
as before. Existing Exchanges therefore stay conformant with no change. The
former unconditional "passes it through without inspecting it" text, on both the
Agent Account Registration banner and RegisterRequest.registration_data, is now
conditioned on the manifest.
The refusal names what to fix: RegistrationFailure.field_errors (field 2, <=64
items) carries the new top-level RegistrationFieldError {path, error}. path is
an RFC 6901 JSON Pointer relative to registration_data, and the empty string
addresses registration_data itself — how whole-object failures (oneOf,
minProperties) that belong to no single member are reported. A free-text pair
rather than a closed kind enum: JSON Schema's composite keywords attach to no
single member and the standard is extensible by design, so a closed vocabulary
could not stay complete. error is developer-facing and NON-authoritative
(wording is validator-defined; clients branch on reason) and states the violated
constraint, never the submitted value, so a refusal cannot echo an agent's
business data back over the wire. A machine-readable kind can join at field 3
later without a wire break.
field_errors is the contract's first repeated MESSAGE field carrying its own
repeated.max_items, which the corpus generator could not build: it produced
valid list items for scalars only and panicked appending an unset Value for a
message. It now constructs a message item the same way it builds a top-level
baseline — from the seed map when one exists, otherwise auto-filled.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
noxlesh
force-pushed
the
feat/ramp-217-registration-schema
branch
from
August 10, 2026 13:14
1f00c41 to
98eb25b
Compare
… (RAMP-217) Documentation contradicted itself. The reference page still told an integrator that registration_data "is passed through to the Exchange's system of record uninspected" — true at the base commit, made false by this branch — three lines above the RegistrationFieldError section describing the refusal it says cannot happen. The sentence is now conditioned on the manifest like every other copy. The schema is third-party input and said nothing about handling it safely. Draft 2020-12 permits $ref to remote URIs and patterns with catastrophic backtracking, so a consumer pre-validating against a hostile Exchange's schema was an SSRF vector and a hang, with every implementation left to decide on its own. The field now states the rules: self-contained schema, remote $ref MUST NOT be resolved, validation time and recursion depth SHOULD be bounded, the 16KB cap is UTF-8 bytes as served, and an oversized schema is rejected and its local pre-check skipped rather than truncated — which lands on the same place as publishing no schema, the Exchange deciding. Prose rather than protovalidate because the field is a Struct and no field-level rule reaches inside it. The manifest field table carries the cap and the self-contained rule too, matching how the attestation claims cap is stated in both places. The corpus asserted the opposite of the contract. corpusgen auto-fills every constrained field, so the RegistrationFailure baseline paired the FIRST allowed reason (DOMAIN_NOT_VERIFIED) with a populated field_errors — exactly what the field comment rules out — and published it as valid to all three SDK parity suites. Worse, the reason this change exists reached no corpus case at all: a client that dropped enum value 6 stayed green, because only the hand-written Go test named it. Seeding RegistrationFailure with INVALID_REGISTRATION_DATA and an empty-path field error fixes all three: the baseline is coherent, the new reason is exercised cross-language, and the empty path — the whole-object failure that belongs to no single member — is pinned as accepted outside Go. The generated Python and TS bindings silently dropped the dialect and the cap: the export pipeline routes a comment's first paragraph into a JSON-Schema title it then discards, so a multi-paragraph comment loses its opening. Every sibling Exchange-only field is a single paragraph and keeps its text. This one now is too, and "draft 2020-12" reaches all three languages. Also: the enforce/pass-through contract was written out in full three times and had already diverged (only one copy named field_errors); the field comment owns it and the other two defer. The reference page paragraph restated semantics the ::proto-message directive renders from the same comments one line below, so it is a one-line intro like its neighbours. The changelog mirror is verbatim again, matching the convention of the three entries above it. tooManyFieldErrors is gone in favour of the generated max_items coverage, leaving the hand-written case to carry only what the generator cannot express: a multi-member refusal including the empty path. And validItem's comment no longer claims a seedless cycle "says so loudly" when it would in fact recurse into a stack overflow. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…s (RAMP-217)
The contract half of this branch gave RegistrationFailure a field_errors list,
but no language could set it through the SDK. helpers.RegistrationFailureDetail
and its Python/TS peers took only (domain, message, reason), so a service
refusing a non-conforming registration had to build the ErrorDetail by hand or
mutate the builder's result — defeating the rule these helpers exist for, stated
at the top of errordetail.go: one place per language where the ADR-019 envelope
is constructed. The Exchange hits this the moment it enforces a schema; its
registerError currently routes through the reasonless fault shape and will grow
a typed detail mirroring executeTxError, which builds through the helper.
The new argument is variadic in Go and an optional trailing parameter in Python
and TS, so the six reasons that carry no per-member detail keep their
three-argument call and no existing caller breaks. This is deliberately the only
*Detail builder that reaches past the reason enum: a schema refusal is useless
without naming what failed, whereas the sibling detail lists
(TransactionDenial.restriction_mismatches, CatalogRejection.rejected_paths) have
no such coupling and stay caller-set after construction.
Parity is gated on both halves. The shared oracle gains a
registration_failure_field_errors vector plus a field_errors projection: the
construct replays feed the members back through each language's builder and
assert byte-parity with the Go wire, and the read replays assert a reader
extracts them positionally. The vector carries both member shapes — a pointer
into the payload, and the empty root pointer for a whole-object failure.
That second shape caught a real cross-language divergence. Canonical proto-JSON
omits an empty scalar, so the wire form of a root-pointer entry has no path key
at all — but the generated Pydantic model defaults path to "" and the generated
Zod schema declares .default(""), so both materialize a member Go omits and the
byte-parity assertion failed. Both builders now map an empty path to
unpopulated, which is the exact inverse of the read side normalizing an absent
path to "". Left alone, a Python or TS Exchange would have emitted non-canonical
ErrorDetail wire for exactly the case the empty path exists to express.
Co-Authored-By: Claude Opus 5 (1M context) <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.
Why
An agent integrating the SDK directly signs and sends
Registeritself and passes through no registration front-end, so a check only a front-end performs is a suggestion, not a rule. The same agent also had nowhere to learn which business fields a given Exchange expects. Both gaps resolve against the manifest the agent already fetches to find the Exchange's endpoint.This is the protocol half of the multi-Exchange accounts work. The consumers land separately: the Exchange publishing and enforcing the schema, and the identity adapter's pre-check.
What changed
WellKnownManifest.registration_schema(field 29) — a JSON Schema (draft 2020-12, max 16KB) describing theregistration_dataobject the Exchange expects onRegister. Publication and enforcement are one decision:registration_dataagainst it and refuses a non-conforming payload.Existing Exchanges therefore stay conformant with no change.
REGISTRATION_FAILURE_REASON_INVALID_REGISTRATION_DATA(value 6), plusRegistrationFailure.field_errors(field 2, ≤64 items) carrying the new top-levelRegistrationFieldError {path, error}:{ "message": "registration_data does not match the published registration_schema", "domain": "ramp.v1.ExchangeService", "registration_failure": { "reason": "REGISTRATION_FAILURE_REASON_INVALID_REGISTRATION_DATA", "field_errors": [ { "path": "/vat_id", "error": "must match ^[A-Z]{2}[0-9]+$" }, { "path": "/address/postal_code", "error": "required" } ] } }Contract text. The unconditional "the Exchange passes it through to its system of record without inspecting it" is now conditioned on the manifest — on both the Agent Account Registration banner and
RegisterRequest.registration_data.Design notes
Why free text and not a closed
kindenum. JSON Schema's composite keywords (oneOf,anyOf,if/then,minProperties,dependentSchemas) attach to no single member, so a{path, kind}pair degrades to a useless{"", OTHER}for exactly the cases a reader most needs explained. The standard is also extensible by design, so a closed vocabulary in the contract could not stay complete. A machine-readablekindcan join at field 3 later without a wire break.Why RFC 6901 pointers.
registration_datais arbitrary JSON and nests; a bare member name cannot address/address/postal_code. The empty string is a legal root pointer and is how whole-object failures are reported — hence nomin_lenonpath.Leakage.
erroris developer-facing and NON-authoritative (wording is validator-defined and varies across Exchanges; clients branch onreason). LikeErrorDetail.message, it states the violated constraint and never the submitted value, so a refusal cannot echo an agent's business data back over the wire.Tooling change worth a look
field_errorsis the contract's first repeated message field carrying its ownrepeated.max_items, whichconformance/corpusgencould not build — it produced valid list items for scalars only and panicked appending an unsetValuefor a message. It now constructs a message item the same way it builds a top-level baseline: from the seed map when one exists, otherwise auto-filled. The corpus gainsRegistrationFailure/field_errors/too_manyplus thepath/errorlength edges.Verification
./scripts/ci-local.shpasses end to end — lint, generate +gen/drift, corpus regen + drift, build/vet/test, doc conformance, parity matrix, docs guards, Pydantic/Zod export + drift, canonical round-trip.buf breakingis clean; the change is purely additive.Hand-written cases added to
conformance/validate_test.go: a refusal carrying field errors, the valid empty rootpath, an emptyerrorrejected onmin_len, and themax_itemsboundary.Open for review
draft 2020-12, max 16KBin the field comment. The design doc deliberately left this open; I took it as a working assumption because without a fixed dialect the Exchange and the adapter can validate the same payload differently, and a schema read from a third-party manifest is attacker-influenced input. Worth an explicit call.bufversion trap (pre-existing, not introduced here).scripts/ci-local.shflags a mismatchedbufbut then continues and regeneratesgen/with the wrong version, silently corrupting byte-exact artifacts. Consider making that check fatal.🤖 Generated with Claude Code