Skip to content

feat(proto): let an Exchange publish and enforce a registration schema (RAMP-217) - #31

Merged
noxlesh merged 3 commits into
mainfrom
feat/ramp-217-registration-schema
Aug 11, 2026
Merged

feat(proto): let an Exchange publish and enforce a registration schema (RAMP-217)#31
noxlesh merged 3 commits into
mainfrom
feat/ramp-217-registration-schema

Conversation

@noxlesh

@noxlesh noxlesh commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Why

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. 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 the registration_data object the Exchange expects on Register. Publication and enforcement are one decision:

  • Present → the Exchange validates registration_data against it and refuses a non-conforming payload.
  • Absent → the payload is accepted uninspected and passed to the system of record exactly as before.

Existing Exchanges therefore stay conformant with no change.

REGISTRATION_FAILURE_REASON_INVALID_REGISTRATION_DATA (value 6), plus RegistrationFailure.field_errors (field 2, ≤64 items) carrying the new top-level RegistrationFieldError {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 kind enum. 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-readable kind can join at field 3 later without a wire break.

Why RFC 6901 pointers. registration_data is 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 no min_len on path.

Leakage. error is developer-facing and NON-authoritative (wording is validator-defined and varies across Exchanges; clients branch on reason). Like ErrorDetail.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_errors is the contract's first repeated message field carrying its own repeated.max_items, which conformance/corpusgen 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. The corpus gains RegistrationFailure/field_errors/too_many plus the path/error length edges.

Verification

./scripts/ci-local.sh passes 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 breaking is clean; the change is purely additive.

Hand-written cases added to conformance/validate_test.go: a refusal carrying field errors, the valid empty root path, an empty error rejected on min_len, and the max_items boundary.

Open for review

  1. Schema dialect and size cap. Pinned as draft 2020-12, max 16KB in 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.
  2. Local buf version trap (pre-existing, not introduced here). scripts/ci-local.sh flags a mismatched buf but then continues and regenerates gen/ with the wrong version, silently corrupting byte-exact artifacts. Consider making that check fatal.

🤖 Generated with Claude Code

…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
noxlesh force-pushed the feat/ramp-217-registration-schema branch from 1f00c41 to 98eb25b Compare August 10, 2026 13:14
noxlesh and others added 2 commits August 10, 2026 16:47
… (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>
@noxlesh
noxlesh merged commit 1e90acd into main Aug 11, 2026
4 checks passed
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