diff --git a/conformance/corpus/cases.json b/conformance/corpus/cases.json index dd47b2a5..c928ac40 100644 --- a/conformance/corpus/cases.json +++ b/conformance/corpus/cases.json @@ -1707,6 +1707,278 @@ "metric": "accesses" } }, + { + "id": "RegistrationFailure/field_errors/too_many", + "message": "RegistrationFailure", + "valid": false, + "rules": [ + "repeated.max_items" + ], + "json": { + "field_errors": [ + { + "error": "matched 2 branches of oneOf, exactly 1 required" + }, + { + "error": "x", + "path": "x" + }, + { + "error": "x", + "path": "x" + }, + { + "error": "x", + "path": "x" + }, + { + "error": "x", + "path": "x" + }, + { + "error": "x", + "path": "x" + }, + { + "error": "x", + "path": "x" + }, + { + "error": "x", + "path": "x" + }, + { + "error": "x", + "path": "x" + }, + { + "error": "x", + "path": "x" + }, + { + "error": "x", + "path": "x" + }, + { + "error": "x", + "path": "x" + }, + { + "error": "x", + "path": "x" + }, + { + "error": "x", + "path": "x" + }, + { + "error": "x", + "path": "x" + }, + { + "error": "x", + "path": "x" + }, + { + "error": "x", + "path": "x" + }, + { + "error": "x", + "path": "x" + }, + { + "error": "x", + "path": "x" + }, + { + "error": "x", + "path": "x" + }, + { + "error": "x", + "path": "x" + }, + { + "error": "x", + "path": "x" + }, + { + "error": "x", + "path": "x" + }, + { + "error": "x", + "path": "x" + }, + { + "error": "x", + "path": "x" + }, + { + "error": "x", + "path": "x" + }, + { + "error": "x", + "path": "x" + }, + { + "error": "x", + "path": "x" + }, + { + "error": "x", + "path": "x" + }, + { + "error": "x", + "path": "x" + }, + { + "error": "x", + "path": "x" + }, + { + "error": "x", + "path": "x" + }, + { + "error": "x", + "path": "x" + }, + { + "error": "x", + "path": "x" + }, + { + "error": "x", + "path": "x" + }, + { + "error": "x", + "path": "x" + }, + { + "error": "x", + "path": "x" + }, + { + "error": "x", + "path": "x" + }, + { + "error": "x", + "path": "x" + }, + { + "error": "x", + "path": "x" + }, + { + "error": "x", + "path": "x" + }, + { + "error": "x", + "path": "x" + }, + { + "error": "x", + "path": "x" + }, + { + "error": "x", + "path": "x" + }, + { + "error": "x", + "path": "x" + }, + { + "error": "x", + "path": "x" + }, + { + "error": "x", + "path": "x" + }, + { + "error": "x", + "path": "x" + }, + { + "error": "x", + "path": "x" + }, + { + "error": "x", + "path": "x" + }, + { + "error": "x", + "path": "x" + }, + { + "error": "x", + "path": "x" + }, + { + "error": "x", + "path": "x" + }, + { + "error": "x", + "path": "x" + }, + { + "error": "x", + "path": "x" + }, + { + "error": "x", + "path": "x" + }, + { + "error": "x", + "path": "x" + }, + { + "error": "x", + "path": "x" + }, + { + "error": "x", + "path": "x" + }, + { + "error": "x", + "path": "x" + }, + { + "error": "x", + "path": "x" + }, + { + "error": "x", + "path": "x" + }, + { + "error": "x", + "path": "x" + }, + { + "error": "x", + "path": "x" + }, + { + "error": "x", + "path": "x" + } + ], + "reason": "REGISTRATION_FAILURE_REASON_INVALID_REGISTRATION_DATA" + } + }, { "id": "RegistrationFailure/reason/not_in", "message": "RegistrationFailure", @@ -1714,7 +1986,13 @@ "rules": [ "enum.not_in" ], - "json": {} + "json": { + "field_errors": [ + { + "error": "matched 2 branches of oneOf, exactly 1 required" + } + ] + } }, { "id": "RegistrationFailure/reason/undefined", @@ -1724,7 +2002,12 @@ "enum.defined_only" ], "json": { - "reason": 6 + "field_errors": [ + { + "error": "matched 2 branches of oneOf, exactly 1 required" + } + ], + "reason": 7 } }, { @@ -1732,7 +2015,56 @@ "message": "RegistrationFailure", "valid": true, "json": { - "reason": "REGISTRATION_FAILURE_REASON_DOMAIN_NOT_VERIFIED" + "field_errors": [ + { + "error": "matched 2 branches of oneOf, exactly 1 required" + } + ], + "reason": "REGISTRATION_FAILURE_REASON_INVALID_REGISTRATION_DATA" + } + }, + { + "id": "RegistrationFieldError/error/too_long", + "message": "RegistrationFieldError", + "valid": false, + "rules": [ + "string.max_len" + ], + "json": { + "error": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "path": "x" + } + }, + { + "id": "RegistrationFieldError/error/too_short", + "message": "RegistrationFieldError", + "valid": false, + "rules": [ + "string.min_len" + ], + "json": { + "path": "x" + } + }, + { + "id": "RegistrationFieldError/path/too_long", + "message": "RegistrationFieldError", + "valid": false, + "rules": [ + "string.max_len" + ], + "json": { + "error": "x", + "path": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + }, + { + "id": "RegistrationFieldError/valid", + "message": "RegistrationFieldError", + "valid": true, + "json": { + "error": "x", + "path": "x" } }, { diff --git a/conformance/corpusgen/main.go b/conformance/corpusgen/main.go index 529d80e4..4ab5ba1a 100644 --- a/conformance/corpusgen/main.go +++ b/conformance/corpusgen/main.go @@ -89,6 +89,19 @@ func seeds() map[string]proto.Message { // envelopes. RequiredFields MUST be exactly ["x"]: the repeated.unique // duplicate_item edge appends the auto-filled good item (stringSamples[0]=="x") // and relies on the baseline already holding it, so the mutant is ["x","x"]. + // The refusal that carries per-member detail. Auto-fill would pick the + // FIRST allowed reason (DOMAIN_NOT_VERIFIED) and still populate + // field_errors, publishing as VALID the pairing the field comment rules + // out — and the branch's own reason would reach no corpus case, so a + // client that dropped it would stay green. The empty path is the + // whole-object failure (oneOf, minProperties) that belongs to no single + // member; seeding it pins that accept boundary in all three languages. + "RegistrationFailure": &rampv1.RegistrationFailure{ + Reason: rampv1.RegistrationFailureReason_REGISTRATION_FAILURE_REASON_INVALID_REGISTRATION_DATA, + FieldErrors: []*rampv1.RegistrationFieldError{ + {Path: "", Error: "matched 2 branches of oneOf, exactly 1 required"}, + }, + }, "TenantFeeRate": &rampadminv1.TenantFeeRate{TenantId: "tenant-seed", FeeRateBps: 0}, "ReportingPolicy": &rampadminv1.ReportingPolicy{TenantId: "tenant-seed", RequiredFields: []string{"x"}}, } @@ -141,7 +154,7 @@ func main() { cases = append(cases, mkCase(short+"/valid", short, base.Interface(), true, nil, v)) for _, fd := range constrained { - for _, e := range edges(fd, rules(fd)) { + for _, e := range edges(fd, rules(fd), sd) { m := proto.Clone(base.Interface()).ProtoReflect() e.apply(m) verr := v.Validate(m.Interface()) @@ -303,14 +316,41 @@ func enrichWKT(m protoreflect.Message) { } } +// validItem builds ONE valid element for a repeated field. Scalar items come +// straight from validScalar; a message item is built the same way a top-level +// baseline is — from its seed when one exists, otherwise auto-filled — because +// validScalar deliberately returns an unset Value for MessageKind and appending +// that to a list panics. +// +// It auto-fills where setValid's singular-message branch instead demands a seed. +// The split is deliberate: a singular message field is usually a required +// sub-message whose validity depends on cross-field CEL that auto-fill cannot +// satisfy (the reason seeds exist at all), whereas a repeated element only has +// to clear its own field rules, which auto-fill does handle. A repeated element +// that needs more can still be seeded — the seed map is consulted first. +// +// Recursion terminates on the seed map or on a message whose constrained fields +// are all scalar. The contract has no message cycle; if one is ever introduced +// without a seed this recurses until the stack overflows, which is loud but +// unhelpful — seed the cycle's entry point. +func validItem(fd protoreflect.FieldDescriptor, item *validate.FieldRules, sd map[string]proto.Message) (protoreflect.Value, error) { + if fd.Kind() != protoreflect.MessageKind { + return validScalar(fd, item) + } + sub, err := baseline(fd.Message(), sd) + if err != nil { + return protoreflect.Value{}, fmt.Errorf("repeated message item %s: %w", fd.Message().Name(), err) + } + return protoreflect.ValueOfMessage(sub), nil +} + func setValid(m protoreflect.Message, fd protoreflect.FieldDescriptor, fr *validate.FieldRules, sd map[string]proto.Message) error { if fd.IsList() { - l := m.Mutable(fd).List() - v, err := validScalar(fd, itemRules(fr)) + v, err := validItem(fd, itemRules(fr), sd) if err != nil { return err } - l.Append(v) + m.Mutable(fd).List().Append(v) return nil } v, err := validScalar(fd, fr) @@ -379,13 +419,13 @@ type edge struct { valid bool // a POSITIVE edge: Go must ACCEPT it (e.g. "" on a money field). want is unused. } -func edges(fd protoreflect.FieldDescriptor, fr *validate.FieldRules) []edge { +func edges(fd protoreflect.FieldDescriptor, fr *validate.FieldRules, sd map[string]proto.Message) []edge { var es []edge if fr.GetRequired() { es = append(es, edge{label: "missing", want: "required", apply: func(m protoreflect.Message) { m.Clear(fd) }}) } if fd.IsList() { - return append(es, listEdges(fd, fr)...) + return append(es, listEdges(fd, fr, sd)...) } switch fd.Kind() { case protoreflect.EnumKind: @@ -562,11 +602,12 @@ func failingBadStringIdxs(pattern string) []int { return idxs } -func listEdges(fd protoreflect.FieldDescriptor, fr *validate.FieldRules) []edge { +func listEdges(fd protoreflect.FieldDescriptor, fr *validate.FieldRules, sd map[string]proto.Message) []edge { var es []edge r := fr.GetRepeated() item := itemRules(fr) - good, _ := validScalar(fd, item) // a valid item value + good, err := validItem(fd, item, sd) // a valid item value + must(err) if r != nil && r.GetMaxItems() > 0 { n := int(r.GetMaxItems()) + 1 es = append(es, edge{label: "too_many", want: "repeated.max_items", apply: func(m protoreflect.Message) { diff --git a/conformance/validate_test.go b/conformance/validate_test.go index ae473fae..669f7f0f 100644 --- a/conformance/validate_test.go +++ b/conformance/validate_test.go @@ -234,6 +234,22 @@ func errorDetailCases() []validationCase { {"catalog_rejection unspecified rejected", &rampv1.CatalogRejection{Reason: rampv1.CatalogRejectionReason_CATALOG_REJECTION_REASON_UNSPECIFIED}, false, "enum.not_in"}, {"registration_failure valid", &rampv1.RegistrationFailure{Reason: rampv1.RegistrationFailureReason_REGISTRATION_FAILURE_REASON_INVALID_KEY}, true, ""}, {"registration_failure unspecified rejected", &rampv1.RegistrationFailure{Reason: rampv1.RegistrationFailureReason_REGISTRATION_FAILURE_REASON_UNSPECIFIED}, false, "enum.not_in"}, + // Schema-enforcement refusal: the reason travels with the offending + // registration_data members. The per-field length bounds and the + // max_items boundary are generated corpus coverage + // (RegistrationFieldError/*, RegistrationFailure/field_errors/too_many); + // what the generator cannot express is a MULTI-member refusal, and that + // the empty path — a legal RFC 6901 pointer to registration_data itself, + // for the whole-object failure that belongs to no single member — is + // accepted rather than read as an unset field. + {"registration_failure invalid data with field errors valid", &rampv1.RegistrationFailure{ + Reason: rampv1.RegistrationFailureReason_REGISTRATION_FAILURE_REASON_INVALID_REGISTRATION_DATA, + FieldErrors: []*rampv1.RegistrationFieldError{ + {Path: "/vat_id", Error: "must match ^[A-Z]{2}[0-9]+$"}, + {Path: "/address/postal_code", Error: "required"}, + {Path: "", Error: "matched 2 branches of oneOf, exactly 1 required"}, + }, + }, true, ""}, {"dispute_failure valid", &rampv1.DisputeFailure{Reason: rampv1.DisputeFailureReason_DISPUTE_FAILURE_REASON_REPORT_NOT_FILED}, true, ""}, {"dispute_failure unspecified rejected", &rampv1.DisputeFailure{Reason: rampv1.DisputeFailureReason_DISPUTE_FAILURE_REASON_UNSPECIFIED}, false, "enum.not_in"}, {"domain_verification_failure valid", &rampv1.DomainVerificationFailure{Reason: rampv1.DomainVerificationFailureReason_DOMAIN_VERIFICATION_FAILURE_REASON_CHALLENGE_MISMATCH}, true, ""}, diff --git a/gen/descriptor.binpb b/gen/descriptor.binpb index 02438e08..31e82678 100644 Binary files a/gen/descriptor.binpb and b/gen/descriptor.binpb differ diff --git a/gen/go/ramp/v1/ramp.pb.go b/gen/go/ramp/v1/ramp.pb.go index 14685939..62454d55 100644 --- a/gen/go/ramp/v1/ramp.pb.go +++ b/gen/go/ramp/v1/ramp.pb.go @@ -1572,12 +1572,13 @@ func (CatalogRejectionReason) EnumDescriptor() ([]byte, []int) { type RegistrationFailureReason int32 const ( - RegistrationFailureReason_REGISTRATION_FAILURE_REASON_UNSPECIFIED RegistrationFailureReason = 0 // unset — rejected at ingest - RegistrationFailureReason_REGISTRATION_FAILURE_REASON_DOMAIN_NOT_VERIFIED RegistrationFailureReason = 1 // caller domain is not verified - RegistrationFailureReason_REGISTRATION_FAILURE_REASON_INVALID_KEY RegistrationFailureReason = 2 // signing key malformed or unsupported - RegistrationFailureReason_REGISTRATION_FAILURE_REASON_SIGNATURE_INVALID RegistrationFailureReason = 3 // request signature invalid - RegistrationFailureReason_REGISTRATION_FAILURE_REASON_ALREADY_REGISTERED RegistrationFailureReason = 4 // identity already registered - RegistrationFailureReason_REGISTRATION_FAILURE_REASON_QUOTA_EXCEEDED RegistrationFailureReason = 5 // registration quota exceeded + RegistrationFailureReason_REGISTRATION_FAILURE_REASON_UNSPECIFIED RegistrationFailureReason = 0 // unset — rejected at ingest + RegistrationFailureReason_REGISTRATION_FAILURE_REASON_DOMAIN_NOT_VERIFIED RegistrationFailureReason = 1 // caller domain is not verified + RegistrationFailureReason_REGISTRATION_FAILURE_REASON_INVALID_KEY RegistrationFailureReason = 2 // signing key malformed or unsupported + RegistrationFailureReason_REGISTRATION_FAILURE_REASON_SIGNATURE_INVALID RegistrationFailureReason = 3 // request signature invalid + RegistrationFailureReason_REGISTRATION_FAILURE_REASON_ALREADY_REGISTERED RegistrationFailureReason = 4 // identity already registered + RegistrationFailureReason_REGISTRATION_FAILURE_REASON_QUOTA_EXCEEDED RegistrationFailureReason = 5 // registration quota exceeded + RegistrationFailureReason_REGISTRATION_FAILURE_REASON_INVALID_REGISTRATION_DATA RegistrationFailureReason = 6 // registration_data does not conform to the Exchange's published registration_schema ) // Enum value maps for RegistrationFailureReason. @@ -1589,14 +1590,16 @@ var ( 3: "REGISTRATION_FAILURE_REASON_SIGNATURE_INVALID", 4: "REGISTRATION_FAILURE_REASON_ALREADY_REGISTERED", 5: "REGISTRATION_FAILURE_REASON_QUOTA_EXCEEDED", + 6: "REGISTRATION_FAILURE_REASON_INVALID_REGISTRATION_DATA", } RegistrationFailureReason_value = map[string]int32{ - "REGISTRATION_FAILURE_REASON_UNSPECIFIED": 0, - "REGISTRATION_FAILURE_REASON_DOMAIN_NOT_VERIFIED": 1, - "REGISTRATION_FAILURE_REASON_INVALID_KEY": 2, - "REGISTRATION_FAILURE_REASON_SIGNATURE_INVALID": 3, - "REGISTRATION_FAILURE_REASON_ALREADY_REGISTERED": 4, - "REGISTRATION_FAILURE_REASON_QUOTA_EXCEEDED": 5, + "REGISTRATION_FAILURE_REASON_UNSPECIFIED": 0, + "REGISTRATION_FAILURE_REASON_DOMAIN_NOT_VERIFIED": 1, + "REGISTRATION_FAILURE_REASON_INVALID_KEY": 2, + "REGISTRATION_FAILURE_REASON_SIGNATURE_INVALID": 3, + "REGISTRATION_FAILURE_REASON_ALREADY_REGISTERED": 4, + "REGISTRATION_FAILURE_REASON_QUOTA_EXCEEDED": 5, + "REGISTRATION_FAILURE_REASON_INVALID_REGISTRATION_DATA": 6, } ) @@ -6726,6 +6729,26 @@ type WellKnownManifest struct { // publish their chain-depth tolerance so Brokers prune before forwarding. // Absent = no published limit (Exchange applies its own default policy). MaxIntermediaryHops *int32 `protobuf:"varint,28,opt,name=max_intermediary_hops,json=maxIntermediaryHops,proto3,oneof" json:"max_intermediary_hops,omitempty"` + // Exchange-only. JSON Schema (draft 2020-12) describing the + // RegisterRequest.registration_data object this Exchange expects. This field + // is the single home of the enforce/pass-through contract, and publishing it + // IS the enforcement switch. Present: this Exchange validates + // registration_data against the schema and refuses a non-conforming payload + // with REGISTRATION_FAILURE_REASON_INVALID_REGISTRATION_DATA, naming the + // offending members in RegistrationFailure.field_errors. Absent: + // registration_data is passed through to the system of record uninspected, + // so an Exchange that publishes no schema needs no change to stay + // conformant. Safety rules, because a consumer reads this schema out of a + // third party's manifest: it MUST be self-contained, and a consumer MUST NOT + // resolve a remote $ref out of it — doing so turns every reader into an SSRF + // vector aimed at a URL the schema's author chose. A consumer SHOULD bound + // validation time and recursion depth; draft 2020-12 `pattern` admits + // regexes with catastrophic backtracking. Size is capped at 16KB, measured + // as the UTF-8 bytes of this member as served in ramp.json; a consumer + // SHOULD reject an oversized schema and skip its local pre-check rather than + // truncate it, which leaves the Exchange's own enforcement the deciding + // check exactly as when no schema is published. + RegistrationSchema *structpb.Struct `protobuf:"bytes,29,opt,name=registration_schema,json=registrationSchema,proto3" json:"registration_schema,omitempty"` // Extension point Ext *structpb.Struct `protobuf:"bytes,15,opt,name=ext,proto3" json:"ext,omitempty"` // Critical extension keys (COSE crit pattern, RFC 9052). Lists keys @@ -6941,6 +6964,13 @@ func (x *WellKnownManifest) GetMaxIntermediaryHops() int32 { return 0 } +func (x *WellKnownManifest) GetRegistrationSchema() *structpb.Struct { + if x != nil { + return x.RegistrationSchema + } + return nil +} + func (x *WellKnownManifest) GetExt() *structpb.Struct { if x != nil { return x.Ext @@ -7981,9 +8011,10 @@ type RegisterRequest struct { // constant; advisory on receive. See "Protocol version" in the file header. Ver string `protobuf:"bytes,1,opt,name=ver,proto3" json:"ver,omitempty"` // Operator-defined registration payload; the business fields are not fixed - // in the wire contract. The Exchange passes it through to its system of - // record without inspecting it. The caller's identity is taken from the - // verified request signature, never from this payload. + // in the wire contract. Whether the Exchange inspects it follows its + // manifest — see WellKnownManifest.registration_schema. The caller's + // identity is taken from the verified request signature, never from this + // payload. RegistrationData *structpb.Struct `protobuf:"bytes,2,opt,name=registration_data,json=registrationData,proto3" json:"registration_data,omitempty"` // Extension point Ext *structpb.Struct `protobuf:"bytes,15,opt,name=ext,proto3" json:"ext,omitempty"` @@ -8634,7 +8665,10 @@ func (x *CatalogRejection) GetRejectedPaths() []string { type RegistrationFailure struct { state protoimpl.MessageState `protogen:"open.v1"` // The failure reason (defined-only, non-zero) - Reason RegistrationFailureReason `protobuf:"varint,1,opt,name=reason,proto3,enum=ramp.v1.RegistrationFailureReason" json:"reason,omitempty"` + Reason RegistrationFailureReason `protobuf:"varint,1,opt,name=reason,proto3,enum=ramp.v1.RegistrationFailureReason" json:"reason,omitempty"` + // When reason = INVALID_REGISTRATION_DATA: the registration_data members + // that are missing or do not conform. Empty for every other reason. + FieldErrors []*RegistrationFieldError `protobuf:"bytes,2,rep,name=field_errors,json=fieldErrors,proto3" json:"field_errors,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -8676,6 +8710,77 @@ func (x *RegistrationFailure) GetReason() RegistrationFailureReason { return RegistrationFailureReason_REGISTRATION_FAILURE_REASON_UNSPECIFIED } +func (x *RegistrationFailure) GetFieldErrors() []*RegistrationFieldError { + if x != nil { + return x.FieldErrors + } + return nil +} + +// RegistrationFieldError — one registration_data member that failed the +// Exchange's published registration_schema (WellKnownManifest. +// registration_schema). +type RegistrationFieldError struct { + state protoimpl.MessageState `protogen:"open.v1"` + // RFC 6901 JSON Pointer to the offending member, relative to + // registration_data (e.g. "/vat_id", "/address/postal_code"). The empty + // string addresses registration_data itself, for whole-object failures + // (oneOf, minProperties) that belong to no single member. + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` + // Developer-facing, NON-authoritative description of what failed + // (e.g. "required", "must match ^[A-Z]{2}[0-9]+$"). Wording is + // validator-defined and not stable across Exchanges; clients branch on + // `reason`, never on this text. States the constraint, NEVER the submitted + // value — the ErrorDetail leakage rule applies here too. + Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RegistrationFieldError) Reset() { + *x = RegistrationFieldError{} + mi := &file_ramp_v1_ramp_proto_msgTypes[61] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RegistrationFieldError) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RegistrationFieldError) ProtoMessage() {} + +func (x *RegistrationFieldError) ProtoReflect() protoreflect.Message { + mi := &file_ramp_v1_ramp_proto_msgTypes[61] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RegistrationFieldError.ProtoReflect.Descriptor instead. +func (*RegistrationFieldError) Descriptor() ([]byte, []int) { + return file_ramp_v1_ramp_proto_rawDescGZIP(), []int{61} +} + +func (x *RegistrationFieldError) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *RegistrationFieldError) GetError() string { + if x != nil { + return x.Error + } + return "" +} + // DisputeFailure — a dispute could not be filed. type DisputeFailure struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -8687,7 +8792,7 @@ type DisputeFailure struct { func (x *DisputeFailure) Reset() { *x = DisputeFailure{} - mi := &file_ramp_v1_ramp_proto_msgTypes[61] + mi := &file_ramp_v1_ramp_proto_msgTypes[62] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8699,7 +8804,7 @@ func (x *DisputeFailure) String() string { func (*DisputeFailure) ProtoMessage() {} func (x *DisputeFailure) ProtoReflect() protoreflect.Message { - mi := &file_ramp_v1_ramp_proto_msgTypes[61] + mi := &file_ramp_v1_ramp_proto_msgTypes[62] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8712,7 +8817,7 @@ func (x *DisputeFailure) ProtoReflect() protoreflect.Message { // Deprecated: Use DisputeFailure.ProtoReflect.Descriptor instead. func (*DisputeFailure) Descriptor() ([]byte, []int) { - return file_ramp_v1_ramp_proto_rawDescGZIP(), []int{61} + return file_ramp_v1_ramp_proto_rawDescGZIP(), []int{62} } func (x *DisputeFailure) GetReason() DisputeFailureReason { @@ -8733,7 +8838,7 @@ type DomainVerificationFailure struct { func (x *DomainVerificationFailure) Reset() { *x = DomainVerificationFailure{} - mi := &file_ramp_v1_ramp_proto_msgTypes[62] + mi := &file_ramp_v1_ramp_proto_msgTypes[63] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8745,7 +8850,7 @@ func (x *DomainVerificationFailure) String() string { func (*DomainVerificationFailure) ProtoMessage() {} func (x *DomainVerificationFailure) ProtoReflect() protoreflect.Message { - mi := &file_ramp_v1_ramp_proto_msgTypes[62] + mi := &file_ramp_v1_ramp_proto_msgTypes[63] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8758,7 +8863,7 @@ func (x *DomainVerificationFailure) ProtoReflect() protoreflect.Message { // Deprecated: Use DomainVerificationFailure.ProtoReflect.Descriptor instead. func (*DomainVerificationFailure) Descriptor() ([]byte, []int) { - return file_ramp_v1_ramp_proto_rawDescGZIP(), []int{62} + return file_ramp_v1_ramp_proto_rawDescGZIP(), []int{63} } func (x *DomainVerificationFailure) GetReason() DomainVerificationFailureReason { @@ -8779,7 +8884,7 @@ type RetrievalAuthFailure struct { func (x *RetrievalAuthFailure) Reset() { *x = RetrievalAuthFailure{} - mi := &file_ramp_v1_ramp_proto_msgTypes[63] + mi := &file_ramp_v1_ramp_proto_msgTypes[64] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8791,7 +8896,7 @@ func (x *RetrievalAuthFailure) String() string { func (*RetrievalAuthFailure) ProtoMessage() {} func (x *RetrievalAuthFailure) ProtoReflect() protoreflect.Message { - mi := &file_ramp_v1_ramp_proto_msgTypes[63] + mi := &file_ramp_v1_ramp_proto_msgTypes[64] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8804,7 +8909,7 @@ func (x *RetrievalAuthFailure) ProtoReflect() protoreflect.Message { // Deprecated: Use RetrievalAuthFailure.ProtoReflect.Descriptor instead. func (*RetrievalAuthFailure) Descriptor() ([]byte, []int) { - return file_ramp_v1_ramp_proto_rawDescGZIP(), []int{63} + return file_ramp_v1_ramp_proto_rawDescGZIP(), []int{64} } func (x *RetrievalAuthFailure) GetReason() RetrievalAuthFailureReason { @@ -8825,7 +8930,7 @@ type UsageReportRejection struct { func (x *UsageReportRejection) Reset() { *x = UsageReportRejection{} - mi := &file_ramp_v1_ramp_proto_msgTypes[64] + mi := &file_ramp_v1_ramp_proto_msgTypes[65] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8837,7 +8942,7 @@ func (x *UsageReportRejection) String() string { func (*UsageReportRejection) ProtoMessage() {} func (x *UsageReportRejection) ProtoReflect() protoreflect.Message { - mi := &file_ramp_v1_ramp_proto_msgTypes[64] + mi := &file_ramp_v1_ramp_proto_msgTypes[65] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8850,7 +8955,7 @@ func (x *UsageReportRejection) ProtoReflect() protoreflect.Message { // Deprecated: Use UsageReportRejection.ProtoReflect.Descriptor instead. func (*UsageReportRejection) Descriptor() ([]byte, []int) { - return file_ramp_v1_ramp_proto_rawDescGZIP(), []int{64} + return file_ramp_v1_ramp_proto_rawDescGZIP(), []int{65} } func (x *UsageReportRejection) GetReason() UsageReportRejectionReason { @@ -9317,7 +9422,7 @@ const file_ramp_v1_ramp_proto_rawDesc = "" + "\x01x\x18\x06 \x01(\tR\x01x\x12\x1d\n" + "\n" + "not_before\x18\a \x01(\tR\tnotBefore\x12\x1b\n" + - "\tnot_after\x18\b \x01(\tR\bnotAfter\"\xf4\v\n" + + "\tnot_after\x18\b \x01(\tR\bnotAfter\"\xbe\f\n" + "\x11WellKnownManifest\x12\x10\n" + "\x03ver\x18\x01 \x01(\tR\x03ver\x12+\n" + "\x04role\x18\x02 \x01(\x0e2\r.ramp.v1.RoleB\b\xbaH\x05\x82\x01\x02 \x00R\x04role\x12\x16\n" + @@ -9347,7 +9452,8 @@ const file_ramp_v1_ramp_proto_rawDesc = "" + "\x13gnap_grant_endpoint\x18\x1a \x01(\tH\n" + "R\x11gnapGrantEndpoint\x88\x01\x01\x12(\n" + "\rbase_currency\x18\x1b \x01(\tH\vR\fbaseCurrency\x88\x01\x01\x127\n" + - "\x15max_intermediary_hops\x18\x1c \x01(\x05H\fR\x13maxIntermediaryHops\x88\x01\x01\x12)\n" + + "\x15max_intermediary_hops\x18\x1c \x01(\x05H\fR\x13maxIntermediaryHops\x88\x01\x01\x12H\n" + + "\x13registration_schema\x18\x1d \x01(\v2\x17.google.protobuf.StructR\x12registrationSchema\x12)\n" + "\x03ext\x18\x0f \x01(\v2\x17.google.protobuf.StructR\x03ext\x12!\n" + "\fext_critical\x18Z \x03(\tR\vextCriticalB\n" + "\n" + @@ -9503,10 +9609,15 @@ const file_ramp_v1_ramp_proto_rawDesc = "" + "\x10CatalogRejection\x12C\n" + "\x06reason\x18\x01 \x01(\x0e2\x1f.ramp.v1.CatalogRejectionReasonB\n" + "\xbaH\a\x82\x01\x04\x10\x01 \x00R\x06reason\x12%\n" + - "\x0erejected_paths\x18\x02 \x03(\tR\rrejectedPaths\"]\n" + + "\x0erejected_paths\x18\x02 \x03(\tR\rrejectedPaths\"\xab\x01\n" + "\x13RegistrationFailure\x12F\n" + "\x06reason\x18\x01 \x01(\x0e2\".ramp.v1.RegistrationFailureReasonB\n" + - "\xbaH\a\x82\x01\x04\x10\x01 \x00R\x06reason\"S\n" + + "\xbaH\a\x82\x01\x04\x10\x01 \x00R\x06reason\x12L\n" + + "\ffield_errors\x18\x02 \x03(\v2\x1f.ramp.v1.RegistrationFieldErrorB\b\xbaH\x05\x92\x01\x02\x10@R\vfieldErrors\"X\n" + + "\x16RegistrationFieldError\x12\x1c\n" + + "\x04path\x18\x01 \x01(\tB\b\xbaH\x05r\x03\x18\xff\x01R\x04path\x12 \n" + + "\x05error\x18\x02 \x01(\tB\n" + + "\xbaH\ar\x05\x10\x01\x18\xff\x01R\x05error\"S\n" + "\x0eDisputeFailure\x12A\n" + "\x06reason\x18\x01 \x01(\x0e2\x1d.ramp.v1.DisputeFailureReasonB\n" + "\xbaH\a\x82\x01\x04\x10\x01 \x00R\x06reason\"i\n" + @@ -9687,14 +9798,15 @@ const file_ramp_v1_ramp_proto_rawDesc = "" + ",CATALOG_REJECTION_REASON_UNKNOWN_VOCAB_TOKEN\x10\x06\x12+\n" + "'CATALOG_REJECTION_REASON_QUOTA_EXCEEDED\x10\a\x121\n" + "-CATALOG_REJECTION_REASON_TERMS_LIMIT_EXCEEDED\x10\b\x12,\n" + - "(CATALOG_REJECTION_REASON_URI_UNAVAILABLE\x10\t*\xc1\x02\n" + + "(CATALOG_REJECTION_REASON_URI_UNAVAILABLE\x10\t*\xfc\x02\n" + "\x19RegistrationFailureReason\x12+\n" + "'REGISTRATION_FAILURE_REASON_UNSPECIFIED\x10\x00\x123\n" + "/REGISTRATION_FAILURE_REASON_DOMAIN_NOT_VERIFIED\x10\x01\x12+\n" + "'REGISTRATION_FAILURE_REASON_INVALID_KEY\x10\x02\x121\n" + "-REGISTRATION_FAILURE_REASON_SIGNATURE_INVALID\x10\x03\x122\n" + ".REGISTRATION_FAILURE_REASON_ALREADY_REGISTERED\x10\x04\x12.\n" + - "*REGISTRATION_FAILURE_REASON_QUOTA_EXCEEDED\x10\x05*\x95\x02\n" + + "*REGISTRATION_FAILURE_REASON_QUOTA_EXCEEDED\x10\x05\x129\n" + + "5REGISTRATION_FAILURE_REASON_INVALID_REGISTRATION_DATA\x10\x06*\x95\x02\n" + "\x14DisputeFailureReason\x12&\n" + "\"DISPUTE_FAILURE_REASON_UNSPECIFIED\x10\x00\x120\n" + ",DISPUTE_FAILURE_REASON_TRANSACTION_NOT_FOUND\x10\x01\x12+\n" + @@ -9762,7 +9874,7 @@ func file_ramp_v1_ramp_proto_rawDescGZIP() []byte { } var file_ramp_v1_ramp_proto_enumTypes = make([]protoimpl.EnumInfo, 28) -var file_ramp_v1_ramp_proto_msgTypes = make([]protoimpl.MessageInfo, 66) +var file_ramp_v1_ramp_proto_msgTypes = make([]protoimpl.MessageInfo, 67) var file_ramp_v1_ramp_proto_goTypes = []any{ (DiscoveryMethod)(0), // 0: ramp.v1.DiscoveryMethod (OfferAbsenceReason)(0), // 1: ramp.v1.OfferAbsenceReason @@ -9853,48 +9965,49 @@ var file_ramp_v1_ramp_proto_goTypes = []any{ (*TransactionDenial)(nil), // 86: ramp.v1.TransactionDenial (*CatalogRejection)(nil), // 87: ramp.v1.CatalogRejection (*RegistrationFailure)(nil), // 88: ramp.v1.RegistrationFailure - (*DisputeFailure)(nil), // 89: ramp.v1.DisputeFailure - (*DomainVerificationFailure)(nil), // 90: ramp.v1.DomainVerificationFailure - (*RetrievalAuthFailure)(nil), // 91: ramp.v1.RetrievalAuthFailure - (*UsageReportRejection)(nil), // 92: ramp.v1.UsageReportRejection - nil, // 93: ramp.v1.ErrorDetail.MetadataEntry - (*durationpb.Duration)(nil), // 94: google.protobuf.Duration - (*structpb.Struct)(nil), // 95: google.protobuf.Struct - (*timestamppb.Timestamp)(nil), // 96: google.protobuf.Timestamp + (*RegistrationFieldError)(nil), // 89: ramp.v1.RegistrationFieldError + (*DisputeFailure)(nil), // 90: ramp.v1.DisputeFailure + (*DomainVerificationFailure)(nil), // 91: ramp.v1.DomainVerificationFailure + (*RetrievalAuthFailure)(nil), // 92: ramp.v1.RetrievalAuthFailure + (*UsageReportRejection)(nil), // 93: ramp.v1.UsageReportRejection + nil, // 94: ramp.v1.ErrorDetail.MetadataEntry + (*durationpb.Duration)(nil), // 95: google.protobuf.Duration + (*structpb.Struct)(nil), // 96: google.protobuf.Struct + (*timestamppb.Timestamp)(nil), // 97: google.protobuf.Timestamp } var file_ramp_v1_ramp_proto_depIdxs = []int32{ 3, // 0: ramp.v1.AcceptableRestriction.axis:type_name -> ramp.v1.RestrictionKind 44, // 1: ramp.v1.ResourceQuery.requester:type_name -> ramp.v1.Requester 28, // 2: ramp.v1.ResourceQuery.acceptable_restrictions:type_name -> ramp.v1.AcceptableRestriction - 94, // 3: ramp.v1.ResourceQuery.deadline:type_name -> google.protobuf.Duration - 95, // 4: ramp.v1.ResourceQuery.ext:type_name -> google.protobuf.Struct + 95, // 3: ramp.v1.ResourceQuery.deadline:type_name -> google.protobuf.Duration + 96, // 4: ramp.v1.ResourceQuery.ext:type_name -> google.protobuf.Struct 34, // 5: ramp.v1.ResourceResponse.offers:type_name -> ramp.v1.Offer 31, // 6: ramp.v1.ResourceResponse.offer_groups:type_name -> ramp.v1.OfferGroup 32, // 7: ramp.v1.ResourceResponse.rate_limit:type_name -> ramp.v1.RateLimitInfo - 95, // 8: ramp.v1.ResourceResponse.ext:type_name -> google.protobuf.Struct + 96, // 8: ramp.v1.ResourceResponse.ext:type_name -> google.protobuf.Struct 34, // 9: ramp.v1.OfferGroup.offers:type_name -> ramp.v1.Offer 0, // 10: ramp.v1.OfferGroup.discovery_method:type_name -> ramp.v1.DiscoveryMethod 1, // 11: ramp.v1.OfferGroup.absence_reason:type_name -> ramp.v1.OfferAbsenceReason 3, // 12: ramp.v1.OfferGroup.restriction_filters:type_name -> ramp.v1.RestrictionKind - 96, // 13: ramp.v1.RateLimitInfo.reset_at:type_name -> google.protobuf.Timestamp - 94, // 14: ramp.v1.RateLimitInfo.window:type_name -> google.protobuf.Duration - 96, // 15: ramp.v1.SubscriptionQuotaInfo.resets_at:type_name -> google.protobuf.Timestamp + 97, // 13: ramp.v1.RateLimitInfo.reset_at:type_name -> google.protobuf.Timestamp + 95, // 14: ramp.v1.RateLimitInfo.window:type_name -> google.protobuf.Duration + 97, // 15: ramp.v1.SubscriptionQuotaInfo.resets_at:type_name -> google.protobuf.Timestamp 43, // 16: ramp.v1.Offer.pricing:type_name -> ramp.v1.Pricing 9, // 17: ramp.v1.Offer.delivery_method:type_name -> ramp.v1.DeliveryMethod 60, // 18: ramp.v1.Offer.reporting:type_name -> ramp.v1.ReportingObligation - 96, // 19: ramp.v1.Offer.expires_at:type_name -> google.protobuf.Timestamp + 97, // 19: ramp.v1.Offer.expires_at:type_name -> google.protobuf.Timestamp 35, // 20: ramp.v1.Offer.identity:type_name -> ramp.v1.ResourceIdentity 36, // 21: ramp.v1.Offer.attestations:type_name -> ramp.v1.ResourceAttestation - 96, // 22: ramp.v1.Offer.data_as_of:type_name -> google.protobuf.Timestamp + 97, // 22: ramp.v1.Offer.data_as_of:type_name -> google.protobuf.Timestamp 33, // 23: ramp.v1.Offer.subscription_quota:type_name -> ramp.v1.SubscriptionQuotaInfo 42, // 24: ramp.v1.Offer.previews:type_name -> ramp.v1.Preview 41, // 25: ramp.v1.Offer.terms:type_name -> ramp.v1.LicenseTerm - 95, // 26: ramp.v1.Offer.ext:type_name -> google.protobuf.Struct + 96, // 26: ramp.v1.Offer.ext:type_name -> google.protobuf.Struct 12, // 27: ramp.v1.ResourceIdentity.resource_mutability:type_name -> ramp.v1.ResourceMutability 11, // 28: ramp.v1.ResourceIdentity.c2pa_status:type_name -> ramp.v1.C2PAStatus - 95, // 29: ramp.v1.ResourceIdentity.ext:type_name -> google.protobuf.Struct - 96, // 30: ramp.v1.ResourceAttestation.attested_at:type_name -> google.protobuf.Timestamp - 95, // 31: ramp.v1.ResourceAttestation.claims:type_name -> google.protobuf.Struct + 96, // 29: ramp.v1.ResourceIdentity.ext:type_name -> google.protobuf.Struct + 97, // 30: ramp.v1.ResourceAttestation.attested_at:type_name -> google.protobuf.Timestamp + 96, // 31: ramp.v1.ResourceAttestation.claims:type_name -> google.protobuf.Struct 3, // 32: ramp.v1.Restriction.kind:type_name -> ramp.v1.RestrictionKind 4, // 33: ramp.v1.Quota.window:type_name -> ramp.v1.QuotaWindow 5, // 34: ramp.v1.Obligation.kind:type_name -> ramp.v1.ObligationKind @@ -9910,130 +10023,132 @@ var file_ramp_v1_ramp_proto_depIdxs = []int32{ 8, // 44: ramp.v1.Pricing.metering:type_name -> ramp.v1.PricingMetering 10, // 45: ramp.v1.Requester.type:type_name -> ramp.v1.RequesterType 45, // 46: ramp.v1.Requester.delegation:type_name -> ramp.v1.Delegation - 95, // 47: ramp.v1.Requester.ext:type_name -> google.protobuf.Struct - 96, // 48: ramp.v1.Delegation.expires_at:type_name -> google.protobuf.Timestamp - 94, // 49: ramp.v1.Delegation.quota_period:type_name -> google.protobuf.Duration - 95, // 50: ramp.v1.Delegation.ext:type_name -> google.protobuf.Struct + 96, // 47: ramp.v1.Requester.ext:type_name -> google.protobuf.Struct + 97, // 48: ramp.v1.Delegation.expires_at:type_name -> google.protobuf.Timestamp + 95, // 49: ramp.v1.Delegation.quota_period:type_name -> google.protobuf.Duration + 96, // 50: ramp.v1.Delegation.ext:type_name -> google.protobuf.Struct 44, // 51: ramp.v1.TransactionRequest.requester:type_name -> ramp.v1.Requester 49, // 52: ramp.v1.TransactionRequest.items:type_name -> ramp.v1.TransactionItem - 95, // 53: ramp.v1.TransactionRequest.ext:type_name -> google.protobuf.Struct + 96, // 53: ramp.v1.TransactionRequest.ext:type_name -> google.protobuf.Struct 34, // 54: ramp.v1.TransactionItem.offer:type_name -> ramp.v1.Offer 46, // 55: ramp.v1.TransactionItem.agent_acceptance:type_name -> ramp.v1.AgentAcceptance 51, // 56: ramp.v1.TransactionResponse.items:type_name -> ramp.v1.TransactionResultItem 52, // 57: ramp.v1.TransactionResponse.total_cost:type_name -> ramp.v1.Cost 33, // 58: ramp.v1.TransactionResponse.subscription_quota:type_name -> ramp.v1.SubscriptionQuotaInfo - 95, // 59: ramp.v1.TransactionResponse.ext:type_name -> google.protobuf.Struct + 96, // 59: ramp.v1.TransactionResponse.ext:type_name -> google.protobuf.Struct 52, // 60: ramp.v1.TransactionResultItem.cost:type_name -> ramp.v1.Cost 52, // 61: ramp.v1.TransactionResultItem.subscription_unit_value:type_name -> ramp.v1.Cost 13, // 62: ramp.v1.TransactionResultItem.denial_reason:type_name -> ramp.v1.DenialReason 3, // 63: ramp.v1.TransactionResultItem.restriction_mismatches:type_name -> ramp.v1.RestrictionKind - 96, // 64: ramp.v1.TransactionResultItem.expires_at:type_name -> google.protobuf.Timestamp + 97, // 64: ramp.v1.TransactionResultItem.expires_at:type_name -> google.protobuf.Timestamp 9, // 65: ramp.v1.TransactionResultItem.delivery_method:type_name -> ramp.v1.DeliveryMethod 60, // 66: ramp.v1.TransactionResultItem.reporting_obligation:type_name -> ramp.v1.ReportingObligation 54, // 67: ramp.v1.PushResourcesRequest.entries:type_name -> ramp.v1.ResourceEntry - 95, // 68: ramp.v1.PushResourcesRequest.ext:type_name -> google.protobuf.Struct + 96, // 68: ramp.v1.PushResourcesRequest.ext:type_name -> google.protobuf.Struct 14, // 69: ramp.v1.ResourceEntry.source:type_name -> ramp.v1.IngestionSource - 96, // 70: ramp.v1.ResourceEntry.provenance_timestamp:type_name -> google.protobuf.Timestamp + 97, // 70: ramp.v1.ResourceEntry.provenance_timestamp:type_name -> google.protobuf.Timestamp 36, // 71: ramp.v1.ResourceEntry.attestations:type_name -> ramp.v1.ResourceAttestation 41, // 72: ramp.v1.ResourceEntry.terms:type_name -> ramp.v1.LicenseTerm 12, // 73: ramp.v1.ResourceEntry.resource_mutability:type_name -> ramp.v1.ResourceMutability - 95, // 74: ramp.v1.ResourceEntry.ext:type_name -> google.protobuf.Struct - 95, // 75: ramp.v1.PushResourcesResponse.ext:type_name -> google.protobuf.Struct - 94, // 76: ramp.v1.ReportingObligation.window:type_name -> google.protobuf.Duration - 95, // 77: ramp.v1.ReportingObligation.ext:type_name -> google.protobuf.Struct + 96, // 74: ramp.v1.ResourceEntry.ext:type_name -> google.protobuf.Struct + 96, // 75: ramp.v1.PushResourcesResponse.ext:type_name -> google.protobuf.Struct + 95, // 76: ramp.v1.ReportingObligation.window:type_name -> google.protobuf.Duration + 96, // 77: ramp.v1.ReportingObligation.ext:type_name -> google.protobuf.Struct 63, // 78: ramp.v1.UsageReport.usage:type_name -> ramp.v1.Usage - 96, // 79: ramp.v1.UsageReport.timestamp:type_name -> google.protobuf.Timestamp + 97, // 79: ramp.v1.UsageReport.timestamp:type_name -> google.protobuf.Timestamp 64, // 80: ramp.v1.UsageReport.assets:type_name -> ramp.v1.UsageAsset - 95, // 81: ramp.v1.UsageReport.ext:type_name -> google.protobuf.Struct + 96, // 81: ramp.v1.UsageReport.ext:type_name -> google.protobuf.Struct 15, // 82: ramp.v1.AttributionDetail.format:type_name -> ramp.v1.CitationFormat 62, // 83: ramp.v1.Usage.attribution:type_name -> ramp.v1.AttributionDetail - 95, // 84: ramp.v1.UsageReportResponse.ext:type_name -> google.protobuf.Struct + 96, // 84: ramp.v1.UsageReportResponse.ext:type_name -> google.protobuf.Struct 44, // 85: ramp.v1.DiscoveryRequest.requester:type_name -> ramp.v1.Requester 28, // 86: ramp.v1.DiscoveryRequest.acceptable_restrictions:type_name -> ramp.v1.AcceptableRestriction 67, // 87: ramp.v1.DiscoveryRequest.constraints:type_name -> ramp.v1.RequestConstraints - 95, // 88: ramp.v1.DiscoveryRequest.search_filters:type_name -> google.protobuf.Struct - 95, // 89: ramp.v1.DiscoveryRequest.ext:type_name -> google.protobuf.Struct + 96, // 88: ramp.v1.DiscoveryRequest.search_filters:type_name -> google.protobuf.Struct + 96, // 89: ramp.v1.DiscoveryRequest.ext:type_name -> google.protobuf.Struct 52, // 90: ramp.v1.RequestConstraints.max_price:type_name -> ramp.v1.Cost 9, // 91: ramp.v1.RequestConstraints.delivery_preference:type_name -> ramp.v1.DeliveryMethod 52, // 92: ramp.v1.RequestConstraints.period_budget:type_name -> ramp.v1.Cost - 94, // 93: ramp.v1.RequestConstraints.budget_period:type_name -> google.protobuf.Duration - 94, // 94: ramp.v1.RequestConstraints.max_data_age:type_name -> google.protobuf.Duration + 95, // 93: ramp.v1.RequestConstraints.budget_period:type_name -> google.protobuf.Duration + 95, // 94: ramp.v1.RequestConstraints.max_data_age:type_name -> google.protobuf.Duration 16, // 95: ramp.v1.WellKnownManifest.role:type_name -> ramp.v1.Role 73, // 96: ramp.v1.WellKnownManifest.exchanges:type_name -> ramp.v1.AuthorizedExchange 72, // 97: ramp.v1.WellKnownManifest.catalog_contributors:type_name -> ramp.v1.CatalogContributor 7, // 98: ramp.v1.WellKnownManifest.pricing_models_supported:type_name -> ramp.v1.PricingModel 9, // 99: ramp.v1.WellKnownManifest.delivery_methods_supported:type_name -> ramp.v1.DeliveryMethod 18, // 100: ramp.v1.WellKnownManifest.supported_auth_methods:type_name -> ramp.v1.AuthMethod - 95, // 101: ramp.v1.WellKnownManifest.ext:type_name -> google.protobuf.Struct - 68, // 102: ramp.v1.WBAFile.keys:type_name -> ramp.v1.JsonWebKey - 96, // 103: ramp.v1.KeyRevocationList.as_of:type_name -> google.protobuf.Timestamp - 17, // 104: ramp.v1.AuthorizedExchange.relationship:type_name -> ramp.v1.ProviderRelationship - 95, // 105: ramp.v1.AuthorizedExchange.ext:type_name -> google.protobuf.Struct - 31, // 106: ramp.v1.DiscoveryResponse.offer_groups:type_name -> ramp.v1.OfferGroup - 1, // 107: ramp.v1.DiscoveryResponse.absence_reason:type_name -> ramp.v1.OfferAbsenceReason - 95, // 108: ramp.v1.DiscoveryResponse.ext:type_name -> google.protobuf.Struct - 19, // 109: ramp.v1.DisputeRequest.reason:type_name -> ramp.v1.DisputeReason - 95, // 110: ramp.v1.DisputeRequest.ext:type_name -> google.protobuf.Struct - 94, // 111: ramp.v1.DisputeResponse.estimated_resolution:type_name -> google.protobuf.Duration - 20, // 112: ramp.v1.DisputeResponse.status:type_name -> ramp.v1.DisputeStatus - 21, // 113: ramp.v1.DisputeResponse.resolution:type_name -> ramp.v1.ResolutionType - 95, // 114: ramp.v1.DisputeResponse.ext:type_name -> google.protobuf.Struct - 95, // 115: ramp.v1.DomainVerificationRequest.ext:type_name -> google.protobuf.Struct - 96, // 116: ramp.v1.DomainVerificationChallenge.expires_at:type_name -> google.protobuf.Timestamp - 95, // 117: ramp.v1.DomainVerificationChallenge.ext:type_name -> google.protobuf.Struct - 95, // 118: ramp.v1.DomainVerificationConfirmation.ext:type_name -> google.protobuf.Struct - 96, // 119: ramp.v1.DomainVerificationResult.valid_until:type_name -> google.protobuf.Timestamp - 95, // 120: ramp.v1.DomainVerificationResult.ext:type_name -> google.protobuf.Struct - 95, // 121: ramp.v1.RegisterRequest.registration_data:type_name -> google.protobuf.Struct - 95, // 122: ramp.v1.RegisterRequest.ext:type_name -> google.protobuf.Struct - 95, // 123: ramp.v1.RegisterResponse.ext:type_name -> google.protobuf.Struct - 95, // 124: ramp.v1.GetAccountStatusRequest.ext:type_name -> google.protobuf.Struct - 95, // 125: ramp.v1.GetAccountStatusResponse.ext:type_name -> google.protobuf.Struct - 93, // 126: ramp.v1.ErrorDetail.metadata:type_name -> ramp.v1.ErrorDetail.MetadataEntry - 86, // 127: ramp.v1.ErrorDetail.transaction_denial:type_name -> ramp.v1.TransactionDenial - 87, // 128: ramp.v1.ErrorDetail.catalog_rejection:type_name -> ramp.v1.CatalogRejection - 88, // 129: ramp.v1.ErrorDetail.registration_failure:type_name -> ramp.v1.RegistrationFailure - 89, // 130: ramp.v1.ErrorDetail.dispute_failure:type_name -> ramp.v1.DisputeFailure - 90, // 131: ramp.v1.ErrorDetail.domain_verification_failure:type_name -> ramp.v1.DomainVerificationFailure - 91, // 132: ramp.v1.ErrorDetail.retrieval_auth_failure:type_name -> ramp.v1.RetrievalAuthFailure - 92, // 133: ramp.v1.ErrorDetail.usage_report_rejection:type_name -> ramp.v1.UsageReportRejection - 13, // 134: ramp.v1.TransactionDenial.reason:type_name -> ramp.v1.DenialReason - 3, // 135: ramp.v1.TransactionDenial.restriction_mismatches:type_name -> ramp.v1.RestrictionKind - 22, // 136: ramp.v1.CatalogRejection.reason:type_name -> ramp.v1.CatalogRejectionReason - 23, // 137: ramp.v1.RegistrationFailure.reason:type_name -> ramp.v1.RegistrationFailureReason - 24, // 138: ramp.v1.DisputeFailure.reason:type_name -> ramp.v1.DisputeFailureReason - 25, // 139: ramp.v1.DomainVerificationFailure.reason:type_name -> ramp.v1.DomainVerificationFailureReason - 26, // 140: ramp.v1.RetrievalAuthFailure.reason:type_name -> ramp.v1.RetrievalAuthFailureReason - 27, // 141: ramp.v1.UsageReportRejection.reason:type_name -> ramp.v1.UsageReportRejectionReason - 29, // 142: ramp.v1.ExchangeService.DiscoverResources:input_type -> ramp.v1.ResourceQuery - 48, // 143: ramp.v1.ExchangeService.ExecuteTransaction:input_type -> ramp.v1.TransactionRequest - 61, // 144: ramp.v1.ExchangeService.ReportUsage:input_type -> ramp.v1.UsageReport - 75, // 145: ramp.v1.ExchangeService.DisputeTransaction:input_type -> ramp.v1.DisputeRequest - 77, // 146: ramp.v1.ExchangeService.RequestDomainVerification:input_type -> ramp.v1.DomainVerificationRequest - 79, // 147: ramp.v1.ExchangeService.ConfirmDomainVerification:input_type -> ramp.v1.DomainVerificationConfirmation - 81, // 148: ramp.v1.ExchangeService.Register:input_type -> ramp.v1.RegisterRequest - 83, // 149: ramp.v1.ExchangeService.GetAccountStatus:input_type -> ramp.v1.GetAccountStatusRequest - 53, // 150: ramp.v1.CatalogService.PushResources:input_type -> ramp.v1.PushResourcesRequest - 56, // 151: ramp.v1.CatalogService.RemoveResources:input_type -> ramp.v1.RemoveResourcesRequest - 58, // 152: ramp.v1.CatalogService.RefreshCatalog:input_type -> ramp.v1.RefreshCatalogRequest - 66, // 153: ramp.v1.BrokerService.Resolve:input_type -> ramp.v1.DiscoveryRequest - 30, // 154: ramp.v1.ExchangeService.DiscoverResources:output_type -> ramp.v1.ResourceResponse - 50, // 155: ramp.v1.ExchangeService.ExecuteTransaction:output_type -> ramp.v1.TransactionResponse - 65, // 156: ramp.v1.ExchangeService.ReportUsage:output_type -> ramp.v1.UsageReportResponse - 76, // 157: ramp.v1.ExchangeService.DisputeTransaction:output_type -> ramp.v1.DisputeResponse - 78, // 158: ramp.v1.ExchangeService.RequestDomainVerification:output_type -> ramp.v1.DomainVerificationChallenge - 80, // 159: ramp.v1.ExchangeService.ConfirmDomainVerification:output_type -> ramp.v1.DomainVerificationResult - 82, // 160: ramp.v1.ExchangeService.Register:output_type -> ramp.v1.RegisterResponse - 84, // 161: ramp.v1.ExchangeService.GetAccountStatus:output_type -> ramp.v1.GetAccountStatusResponse - 55, // 162: ramp.v1.CatalogService.PushResources:output_type -> ramp.v1.PushResourcesResponse - 57, // 163: ramp.v1.CatalogService.RemoveResources:output_type -> ramp.v1.RemoveResourcesResponse - 59, // 164: ramp.v1.CatalogService.RefreshCatalog:output_type -> ramp.v1.RefreshCatalogResponse - 74, // 165: ramp.v1.BrokerService.Resolve:output_type -> ramp.v1.DiscoveryResponse - 154, // [154:166] is the sub-list for method output_type - 142, // [142:154] is the sub-list for method input_type - 142, // [142:142] is the sub-list for extension type_name - 142, // [142:142] is the sub-list for extension extendee - 0, // [0:142] is the sub-list for field type_name + 96, // 101: ramp.v1.WellKnownManifest.registration_schema:type_name -> google.protobuf.Struct + 96, // 102: ramp.v1.WellKnownManifest.ext:type_name -> google.protobuf.Struct + 68, // 103: ramp.v1.WBAFile.keys:type_name -> ramp.v1.JsonWebKey + 97, // 104: ramp.v1.KeyRevocationList.as_of:type_name -> google.protobuf.Timestamp + 17, // 105: ramp.v1.AuthorizedExchange.relationship:type_name -> ramp.v1.ProviderRelationship + 96, // 106: ramp.v1.AuthorizedExchange.ext:type_name -> google.protobuf.Struct + 31, // 107: ramp.v1.DiscoveryResponse.offer_groups:type_name -> ramp.v1.OfferGroup + 1, // 108: ramp.v1.DiscoveryResponse.absence_reason:type_name -> ramp.v1.OfferAbsenceReason + 96, // 109: ramp.v1.DiscoveryResponse.ext:type_name -> google.protobuf.Struct + 19, // 110: ramp.v1.DisputeRequest.reason:type_name -> ramp.v1.DisputeReason + 96, // 111: ramp.v1.DisputeRequest.ext:type_name -> google.protobuf.Struct + 95, // 112: ramp.v1.DisputeResponse.estimated_resolution:type_name -> google.protobuf.Duration + 20, // 113: ramp.v1.DisputeResponse.status:type_name -> ramp.v1.DisputeStatus + 21, // 114: ramp.v1.DisputeResponse.resolution:type_name -> ramp.v1.ResolutionType + 96, // 115: ramp.v1.DisputeResponse.ext:type_name -> google.protobuf.Struct + 96, // 116: ramp.v1.DomainVerificationRequest.ext:type_name -> google.protobuf.Struct + 97, // 117: ramp.v1.DomainVerificationChallenge.expires_at:type_name -> google.protobuf.Timestamp + 96, // 118: ramp.v1.DomainVerificationChallenge.ext:type_name -> google.protobuf.Struct + 96, // 119: ramp.v1.DomainVerificationConfirmation.ext:type_name -> google.protobuf.Struct + 97, // 120: ramp.v1.DomainVerificationResult.valid_until:type_name -> google.protobuf.Timestamp + 96, // 121: ramp.v1.DomainVerificationResult.ext:type_name -> google.protobuf.Struct + 96, // 122: ramp.v1.RegisterRequest.registration_data:type_name -> google.protobuf.Struct + 96, // 123: ramp.v1.RegisterRequest.ext:type_name -> google.protobuf.Struct + 96, // 124: ramp.v1.RegisterResponse.ext:type_name -> google.protobuf.Struct + 96, // 125: ramp.v1.GetAccountStatusRequest.ext:type_name -> google.protobuf.Struct + 96, // 126: ramp.v1.GetAccountStatusResponse.ext:type_name -> google.protobuf.Struct + 94, // 127: ramp.v1.ErrorDetail.metadata:type_name -> ramp.v1.ErrorDetail.MetadataEntry + 86, // 128: ramp.v1.ErrorDetail.transaction_denial:type_name -> ramp.v1.TransactionDenial + 87, // 129: ramp.v1.ErrorDetail.catalog_rejection:type_name -> ramp.v1.CatalogRejection + 88, // 130: ramp.v1.ErrorDetail.registration_failure:type_name -> ramp.v1.RegistrationFailure + 90, // 131: ramp.v1.ErrorDetail.dispute_failure:type_name -> ramp.v1.DisputeFailure + 91, // 132: ramp.v1.ErrorDetail.domain_verification_failure:type_name -> ramp.v1.DomainVerificationFailure + 92, // 133: ramp.v1.ErrorDetail.retrieval_auth_failure:type_name -> ramp.v1.RetrievalAuthFailure + 93, // 134: ramp.v1.ErrorDetail.usage_report_rejection:type_name -> ramp.v1.UsageReportRejection + 13, // 135: ramp.v1.TransactionDenial.reason:type_name -> ramp.v1.DenialReason + 3, // 136: ramp.v1.TransactionDenial.restriction_mismatches:type_name -> ramp.v1.RestrictionKind + 22, // 137: ramp.v1.CatalogRejection.reason:type_name -> ramp.v1.CatalogRejectionReason + 23, // 138: ramp.v1.RegistrationFailure.reason:type_name -> ramp.v1.RegistrationFailureReason + 89, // 139: ramp.v1.RegistrationFailure.field_errors:type_name -> ramp.v1.RegistrationFieldError + 24, // 140: ramp.v1.DisputeFailure.reason:type_name -> ramp.v1.DisputeFailureReason + 25, // 141: ramp.v1.DomainVerificationFailure.reason:type_name -> ramp.v1.DomainVerificationFailureReason + 26, // 142: ramp.v1.RetrievalAuthFailure.reason:type_name -> ramp.v1.RetrievalAuthFailureReason + 27, // 143: ramp.v1.UsageReportRejection.reason:type_name -> ramp.v1.UsageReportRejectionReason + 29, // 144: ramp.v1.ExchangeService.DiscoverResources:input_type -> ramp.v1.ResourceQuery + 48, // 145: ramp.v1.ExchangeService.ExecuteTransaction:input_type -> ramp.v1.TransactionRequest + 61, // 146: ramp.v1.ExchangeService.ReportUsage:input_type -> ramp.v1.UsageReport + 75, // 147: ramp.v1.ExchangeService.DisputeTransaction:input_type -> ramp.v1.DisputeRequest + 77, // 148: ramp.v1.ExchangeService.RequestDomainVerification:input_type -> ramp.v1.DomainVerificationRequest + 79, // 149: ramp.v1.ExchangeService.ConfirmDomainVerification:input_type -> ramp.v1.DomainVerificationConfirmation + 81, // 150: ramp.v1.ExchangeService.Register:input_type -> ramp.v1.RegisterRequest + 83, // 151: ramp.v1.ExchangeService.GetAccountStatus:input_type -> ramp.v1.GetAccountStatusRequest + 53, // 152: ramp.v1.CatalogService.PushResources:input_type -> ramp.v1.PushResourcesRequest + 56, // 153: ramp.v1.CatalogService.RemoveResources:input_type -> ramp.v1.RemoveResourcesRequest + 58, // 154: ramp.v1.CatalogService.RefreshCatalog:input_type -> ramp.v1.RefreshCatalogRequest + 66, // 155: ramp.v1.BrokerService.Resolve:input_type -> ramp.v1.DiscoveryRequest + 30, // 156: ramp.v1.ExchangeService.DiscoverResources:output_type -> ramp.v1.ResourceResponse + 50, // 157: ramp.v1.ExchangeService.ExecuteTransaction:output_type -> ramp.v1.TransactionResponse + 65, // 158: ramp.v1.ExchangeService.ReportUsage:output_type -> ramp.v1.UsageReportResponse + 76, // 159: ramp.v1.ExchangeService.DisputeTransaction:output_type -> ramp.v1.DisputeResponse + 78, // 160: ramp.v1.ExchangeService.RequestDomainVerification:output_type -> ramp.v1.DomainVerificationChallenge + 80, // 161: ramp.v1.ExchangeService.ConfirmDomainVerification:output_type -> ramp.v1.DomainVerificationResult + 82, // 162: ramp.v1.ExchangeService.Register:output_type -> ramp.v1.RegisterResponse + 84, // 163: ramp.v1.ExchangeService.GetAccountStatus:output_type -> ramp.v1.GetAccountStatusResponse + 55, // 164: ramp.v1.CatalogService.PushResources:output_type -> ramp.v1.PushResourcesResponse + 57, // 165: ramp.v1.CatalogService.RemoveResources:output_type -> ramp.v1.RemoveResourcesResponse + 59, // 166: ramp.v1.CatalogService.RefreshCatalog:output_type -> ramp.v1.RefreshCatalogResponse + 74, // 167: ramp.v1.BrokerService.Resolve:output_type -> ramp.v1.DiscoveryResponse + 156, // [156:168] is the sub-list for method output_type + 144, // [144:156] is the sub-list for method input_type + 144, // [144:144] is the sub-list for extension type_name + 144, // [144:144] is the sub-list for extension extendee + 0, // [0:144] is the sub-list for field type_name } func init() { file_ramp_v1_ramp_proto_init() } @@ -10092,7 +10207,7 @@ func file_ramp_v1_ramp_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_ramp_v1_ramp_proto_rawDesc), len(file_ramp_v1_ramp_proto_rawDesc)), NumEnums: 28, - NumMessages: 66, + NumMessages: 67, NumExtensions: 0, NumServices: 3, }, diff --git a/gen/python/wire/models.py b/gen/python/wire/models.py index 600c76d1..742de083 100644 --- a/gen/python/wire/models.py +++ b/gen/python/wire/models.py @@ -602,7 +602,7 @@ class RegisterRequest(WireModel): ) registration_data: dict[str, Any] | None = Field( None, - description="Operator-defined registration payload; the business fields are not fixed\n in the wire contract. The Exchange passes it through to its system of\n record without inspecting it. The caller's identity is taken from the\n verified request signature, never from this payload.", + description="Operator-defined registration payload; the business fields are not fixed\n in the wire contract. Whether the Exchange inspects it follows its\n manifest — see WellKnownManifest.registration_schema. The caller's\n identity is taken from the verified request signature, never from this\n payload.", ) ver: str | None = Field( '', @@ -644,6 +644,20 @@ class RegistrationFailureReason(Enum): REGISTRATION_FAILURE_REASON_QUOTA_EXCEEDED = ( 'REGISTRATION_FAILURE_REASON_QUOTA_EXCEEDED' ) + REGISTRATION_FAILURE_REASON_INVALID_REGISTRATION_DATA = ( + 'REGISTRATION_FAILURE_REASON_INVALID_REGISTRATION_DATA' + ) + + +class RegistrationFieldError(WireModel): + error: constr(min_length=1, max_length=255) = Field( + ..., + description='Developer-facing, NON-authoritative description of what failed\n (e.g. "required", "must match ^[A-Z]{2}[0-9]+$"). Wording is\n validator-defined and not stable across Exchanges; clients branch on\n `reason`, never on this text. States the constraint, NEVER the submitted\n value — the ErrorDetail leakage rule applies here too.', + ) + path: constr(max_length=255) | None = Field( + '', + description='RFC 6901 JSON Pointer to the offending member, relative to\n registration_data (e.g. "/vat_id", "/address/postal_code"). The empty\n string addresses registration_data itself, for whole-object failures\n (oneOf, minProperties) that belong to no single member.', + ) class RemoveResourcesRequest(WireModel): @@ -1185,6 +1199,11 @@ class Quota(WireModel): class RegistrationFailure(WireModel): + field_errors: list[RegistrationFieldError] | None = Field( + None, + description='When reason = INVALID_REGISTRATION_DATA: the registration_data members\n that are missing or do not conform. Empty for every other reason.', + max_length=64, + ) reason: RegistrationFailureReason = Field( ..., description='The failure reason (defined-only, non-zero)' ) @@ -1523,6 +1542,10 @@ class WellKnownManifest(WireModel): None, description='Exchange-only. Supported RAMP protocol versions (e.g. ["1.0"]).', ) + registration_schema: dict[str, Any] | None = Field( + None, + description="Exchange-only. JSON Schema (draft 2020-12) describing the\n RegisterRequest.registration_data object this Exchange expects. This field\n is the single home of the enforce/pass-through contract, and publishing it\n IS the enforcement switch. Present: this Exchange validates\n registration_data against the schema and refuses a non-conforming payload\n with REGISTRATION_FAILURE_REASON_INVALID_REGISTRATION_DATA, naming the\n offending members in RegistrationFailure.field_errors. Absent:\n registration_data is passed through to the system of record uninspected,\n so an Exchange that publishes no schema needs no change to stay\n conformant. Safety rules, because a consumer reads this schema out of a\n third party's manifest: it MUST be self-contained, and a consumer MUST NOT\n resolve a remote $ref out of it — doing so turns every reader into an SSRF\n vector aimed at a URL the schema's author chose. A consumer SHOULD bound\n validation time and recursion depth; draft 2020-12 `pattern` admits\n regexes with catastrophic backtracking. Size is capped at 16KB, measured\n as the UTF-8 bytes of this member as served in ramp.json; a consumer\n SHOULD reject an oversized schema and skip its local pre-check rather than\n truncate it, which leaves the Exchange's own enforcement the deciding\n check exactly as when no schema is published.", + ) role: Role = Field(..., description='Role this manifest describes.') supported_auth_methods: list[AuthMethod] | None = Field( None, diff --git a/gen/ts/wire/schemas.ts b/gen/ts/wire/schemas.ts index 61aa27c4..4bc9ada0 100644 --- a/gen/ts/wire/schemas.ts +++ b/gen/ts/wire/schemas.ts @@ -66,7 +66,7 @@ export const DomainVerificationRequestSchema = wire(z.object({ "caller_id": z.st export const DomainVerificationResultSchema = wire(z.object({ "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "key_id": z.string().describe("If signing_key was provided: confirmation of key registration.").optional(), "valid_until": z.string().datetime({ offset: true }).describe("Verification is valid until this time. Provider must re-verify periodically.").optional(), "ver": z.string().describe("RAMP protocol version — \"1.0\". Stamped by the sender from a single\n constant; advisory on receive. See \"Protocol version\" in the file header.").default("") }).describe("DomainVerificationResult — Exchange confirms verification.")); -export const ErrorDetailSchema = wire(z.object({ "catalog_rejection": z.object({ "reason": z.enum(["CATALOG_REJECTION_REASON_NOT_CATALOG_CONTRIBUTOR","CATALOG_REJECTION_REASON_TENANT_MISMATCH","CATALOG_REJECTION_REASON_DOMAIN_NOT_VERIFIED","CATALOG_REJECTION_REASON_SIGNATURE_INVALID","CATALOG_REJECTION_REASON_MALFORMED_ENTRY","CATALOG_REJECTION_REASON_UNKNOWN_VOCAB_TOKEN","CATALOG_REJECTION_REASON_QUOTA_EXCEEDED","CATALOG_REJECTION_REASON_TERMS_LIMIT_EXCEEDED","CATALOG_REJECTION_REASON_URI_UNAVAILABLE"]).describe("The rejection reason (defined-only, non-zero)"), "rejected_paths": z.array(z.string()).describe("For partial-batch failures: the entry paths that were rejected.").optional() }).describe("`reason` oneof — CatalogService rejection").optional(), "dispute_failure": z.object({ "reason": z.enum(["DISPUTE_FAILURE_REASON_TRANSACTION_NOT_FOUND","DISPUTE_FAILURE_REASON_REPORT_NOT_FILED","DISPUTE_FAILURE_REASON_WINDOW_EXPIRED","DISPUTE_FAILURE_REASON_DUPLICATE","DISPUTE_FAILURE_REASON_INELIGIBLE"]).describe("The failure reason (defined-only, non-zero)") }).describe("`reason` oneof — DisputeTransaction filing refused").optional(), "domain": z.string().describe("Stable grouping for the failing surface, e.g. \"ramp.v1.ExchangeService\".\n Mirrors google.rpc.ErrorInfo.domain so generic tooling can group errors.").default(""), "domain_verification_failure": z.object({ "reason": z.enum(["DOMAIN_VERIFICATION_FAILURE_REASON_CHALLENGE_NOT_FOUND","DOMAIN_VERIFICATION_FAILURE_REASON_CHALLENGE_MISMATCH","DOMAIN_VERIFICATION_FAILURE_REASON_CHALLENGE_EXPIRED","DOMAIN_VERIFICATION_FAILURE_REASON_FETCH_FAILED","DOMAIN_VERIFICATION_FAILURE_REASON_EXCHANGE_NOT_AUTHORIZED","DOMAIN_VERIFICATION_FAILURE_REASON_KEY_REGISTRATION_FAILED"]).describe("The failure reason (defined-only, non-zero)") }).describe("`reason` oneof — domain verification failed").optional(), "message": z.string().describe("Developer-facing, NON-authoritative human message. Clients MUST branch on\n the typed reason below, never on this text. Servers SHOULD NOT place secrets,\n PII, or existence/authorization detail here that the closed typed reason\n deliberately withholds: unlike the enum, this free text is unbounded and\n easily becomes an existence oracle or leak channel (see `metadata`).").default(""), "metadata": z.record(z.string(), z.string()).describe("Dynamic key/value context that also appears in `message` (ids, limits,\n axes). Mirrors google.rpc.ErrorInfo.metadata. Strongly-typed context rides\n in the per-domain reason block below instead. Same leakage rule as `message`:\n servers SHOULD NOT put secrets, PII, or withheld existence/authorization\n detail here — it is the same potential side channel as the absence oracle.").optional(), "registration_failure": z.object({ "reason": z.enum(["REGISTRATION_FAILURE_REASON_DOMAIN_NOT_VERIFIED","REGISTRATION_FAILURE_REASON_INVALID_KEY","REGISTRATION_FAILURE_REASON_SIGNATURE_INVALID","REGISTRATION_FAILURE_REASON_ALREADY_REGISTERED","REGISTRATION_FAILURE_REASON_QUOTA_EXCEEDED"]).describe("The failure reason (defined-only, non-zero)") }).describe("`reason` oneof — agent/provider registration refused").optional(), "retrieval_auth_failure": z.object({ "reason": z.enum(["RETRIEVAL_AUTH_FAILURE_REASON_URL_EXPIRED","RETRIEVAL_AUTH_FAILURE_REASON_URL_SIGNATURE_MISSING","RETRIEVAL_AUTH_FAILURE_REASON_URL_EXPIRY_MISSING","RETRIEVAL_AUTH_FAILURE_REASON_URL_SIGNATURE_MISMATCH","RETRIEVAL_AUTH_FAILURE_REASON_AGENT_KEY_MISSING","RETRIEVAL_AUTH_FAILURE_REASON_PROOF_SIGNATURE_MISSING","RETRIEVAL_AUTH_FAILURE_REASON_KEYID_MISMATCH","RETRIEVAL_AUTH_FAILURE_REASON_THUMBPRINT_MISMATCH","RETRIEVAL_AUTH_FAILURE_REASON_PROOF_CREATED_MISSING","RETRIEVAL_AUTH_FAILURE_REASON_PROOF_EXPIRY_MISSING","RETRIEVAL_AUTH_FAILURE_REASON_PROOF_EXPIRED","RETRIEVAL_AUTH_FAILURE_REASON_PROOF_SIGNATURE_INVALID"]).describe("The failure reason (defined-only, non-zero)") }).describe("`reason` oneof — signed-URL / proof-of-possession check failed").optional(), "transaction_denial": z.object({ "offer_id": z.string().describe("Batch mode: the offer this denial pertains to.").optional(), "reason": z.enum(["DENIAL_REASON_BILLING_REF_INACTIVE","DENIAL_REASON_INSUFFICIENT_BALANCE","DENIAL_REASON_RATE_LIMITED","DENIAL_REASON_CONTENT_UNAVAILABLE","DENIAL_REASON_RESTRICTION_NOT_SATISFIED","DENIAL_REASON_REPORTING_OVERDUE","DENIAL_REASON_OFFER_EXPIRED","DENIAL_REASON_SIGNATURE_INVALID","DENIAL_REASON_QUOTA_EXCEEDED","DENIAL_REASON_DELEGATION_INVALID","DENIAL_REASON_SCOPE_INSUFFICIENT","DENIAL_REASON_ENTITLEMENT_MISSING","DENIAL_REASON_ENTITLEMENT_MALFORMED","DENIAL_REASON_ENTITLEMENT_EXPIRED","DENIAL_REASON_ENTITLEMENT_WRONG_BUYER","DENIAL_REASON_SUBSCRIPTION_LAPSED","DENIAL_REASON_ENTITLEMENT_NOT_GRANTED"]).describe("The denial reason (defined-only, non-zero)"), "restriction_mismatches": z.array(z.enum(["RESTRICTION_KIND_FUNCTION","RESTRICTION_KIND_GEOGRAPHY","RESTRICTION_KIND_USER_TYPE","RESTRICTION_KIND_OTHER"])).describe("When reason = RESTRICTION_NOT_SATISFIED, the failed axes (same\n RestrictionKind vocabulary the terms use).").optional() }).describe("`reason` oneof — ExecuteTransaction denial").optional(), "usage_report_rejection": z.object({ "reason": z.enum(["USAGE_REPORT_REJECTION_REASON_TRANSACTION_NOT_FOUND","USAGE_REPORT_REJECTION_REASON_DUPLICATE","USAGE_REPORT_REJECTION_REASON_WINDOW_EXPIRED","USAGE_REPORT_REJECTION_REASON_MISSING_REQUIRED_FIELDS","USAGE_REPORT_REJECTION_REASON_MALFORMED"]).describe("The rejection reason (defined-only, non-zero)") }).describe("`reason` oneof — ReportUsage filing rejected").optional() }).describe("ErrorDetail — the structured detail attached to every non-OK transport error.")); +export const ErrorDetailSchema = wire(z.object({ "catalog_rejection": z.object({ "reason": z.enum(["CATALOG_REJECTION_REASON_NOT_CATALOG_CONTRIBUTOR","CATALOG_REJECTION_REASON_TENANT_MISMATCH","CATALOG_REJECTION_REASON_DOMAIN_NOT_VERIFIED","CATALOG_REJECTION_REASON_SIGNATURE_INVALID","CATALOG_REJECTION_REASON_MALFORMED_ENTRY","CATALOG_REJECTION_REASON_UNKNOWN_VOCAB_TOKEN","CATALOG_REJECTION_REASON_QUOTA_EXCEEDED","CATALOG_REJECTION_REASON_TERMS_LIMIT_EXCEEDED","CATALOG_REJECTION_REASON_URI_UNAVAILABLE"]).describe("The rejection reason (defined-only, non-zero)"), "rejected_paths": z.array(z.string()).describe("For partial-batch failures: the entry paths that were rejected.").optional() }).describe("`reason` oneof — CatalogService rejection").optional(), "dispute_failure": z.object({ "reason": z.enum(["DISPUTE_FAILURE_REASON_TRANSACTION_NOT_FOUND","DISPUTE_FAILURE_REASON_REPORT_NOT_FILED","DISPUTE_FAILURE_REASON_WINDOW_EXPIRED","DISPUTE_FAILURE_REASON_DUPLICATE","DISPUTE_FAILURE_REASON_INELIGIBLE"]).describe("The failure reason (defined-only, non-zero)") }).describe("`reason` oneof — DisputeTransaction filing refused").optional(), "domain": z.string().describe("Stable grouping for the failing surface, e.g. \"ramp.v1.ExchangeService\".\n Mirrors google.rpc.ErrorInfo.domain so generic tooling can group errors.").default(""), "domain_verification_failure": z.object({ "reason": z.enum(["DOMAIN_VERIFICATION_FAILURE_REASON_CHALLENGE_NOT_FOUND","DOMAIN_VERIFICATION_FAILURE_REASON_CHALLENGE_MISMATCH","DOMAIN_VERIFICATION_FAILURE_REASON_CHALLENGE_EXPIRED","DOMAIN_VERIFICATION_FAILURE_REASON_FETCH_FAILED","DOMAIN_VERIFICATION_FAILURE_REASON_EXCHANGE_NOT_AUTHORIZED","DOMAIN_VERIFICATION_FAILURE_REASON_KEY_REGISTRATION_FAILED"]).describe("The failure reason (defined-only, non-zero)") }).describe("`reason` oneof — domain verification failed").optional(), "message": z.string().describe("Developer-facing, NON-authoritative human message. Clients MUST branch on\n the typed reason below, never on this text. Servers SHOULD NOT place secrets,\n PII, or existence/authorization detail here that the closed typed reason\n deliberately withholds: unlike the enum, this free text is unbounded and\n easily becomes an existence oracle or leak channel (see `metadata`).").default(""), "metadata": z.record(z.string(), z.string()).describe("Dynamic key/value context that also appears in `message` (ids, limits,\n axes). Mirrors google.rpc.ErrorInfo.metadata. Strongly-typed context rides\n in the per-domain reason block below instead. Same leakage rule as `message`:\n servers SHOULD NOT put secrets, PII, or withheld existence/authorization\n detail here — it is the same potential side channel as the absence oracle.").optional(), "registration_failure": z.object({ "field_errors": z.array(z.object({ "error": z.string().min(1).max(255).describe("Developer-facing, NON-authoritative description of what failed\n (e.g. \"required\", \"must match ^[A-Z]{2}[0-9]+$\"). Wording is\n validator-defined and not stable across Exchanges; clients branch on\n `reason`, never on this text. States the constraint, NEVER the submitted\n value — the ErrorDetail leakage rule applies here too."), "path": z.string().max(255).describe("RFC 6901 JSON Pointer to the offending member, relative to\n registration_data (e.g. \"/vat_id\", \"/address/postal_code\"). The empty\n string addresses registration_data itself, for whole-object failures\n (oneOf, minProperties) that belong to no single member.").default("") }).describe("RegistrationFieldError — one registration_data member that failed the\n Exchange's published registration_schema (WellKnownManifest.\n registration_schema).")).max(64).describe("When reason = INVALID_REGISTRATION_DATA: the registration_data members\n that are missing or do not conform. Empty for every other reason.").optional(), "reason": z.enum(["REGISTRATION_FAILURE_REASON_DOMAIN_NOT_VERIFIED","REGISTRATION_FAILURE_REASON_INVALID_KEY","REGISTRATION_FAILURE_REASON_SIGNATURE_INVALID","REGISTRATION_FAILURE_REASON_ALREADY_REGISTERED","REGISTRATION_FAILURE_REASON_QUOTA_EXCEEDED","REGISTRATION_FAILURE_REASON_INVALID_REGISTRATION_DATA"]).describe("The failure reason (defined-only, non-zero)") }).describe("`reason` oneof — agent/provider registration refused").optional(), "retrieval_auth_failure": z.object({ "reason": z.enum(["RETRIEVAL_AUTH_FAILURE_REASON_URL_EXPIRED","RETRIEVAL_AUTH_FAILURE_REASON_URL_SIGNATURE_MISSING","RETRIEVAL_AUTH_FAILURE_REASON_URL_EXPIRY_MISSING","RETRIEVAL_AUTH_FAILURE_REASON_URL_SIGNATURE_MISMATCH","RETRIEVAL_AUTH_FAILURE_REASON_AGENT_KEY_MISSING","RETRIEVAL_AUTH_FAILURE_REASON_PROOF_SIGNATURE_MISSING","RETRIEVAL_AUTH_FAILURE_REASON_KEYID_MISMATCH","RETRIEVAL_AUTH_FAILURE_REASON_THUMBPRINT_MISMATCH","RETRIEVAL_AUTH_FAILURE_REASON_PROOF_CREATED_MISSING","RETRIEVAL_AUTH_FAILURE_REASON_PROOF_EXPIRY_MISSING","RETRIEVAL_AUTH_FAILURE_REASON_PROOF_EXPIRED","RETRIEVAL_AUTH_FAILURE_REASON_PROOF_SIGNATURE_INVALID"]).describe("The failure reason (defined-only, non-zero)") }).describe("`reason` oneof — signed-URL / proof-of-possession check failed").optional(), "transaction_denial": z.object({ "offer_id": z.string().describe("Batch mode: the offer this denial pertains to.").optional(), "reason": z.enum(["DENIAL_REASON_BILLING_REF_INACTIVE","DENIAL_REASON_INSUFFICIENT_BALANCE","DENIAL_REASON_RATE_LIMITED","DENIAL_REASON_CONTENT_UNAVAILABLE","DENIAL_REASON_RESTRICTION_NOT_SATISFIED","DENIAL_REASON_REPORTING_OVERDUE","DENIAL_REASON_OFFER_EXPIRED","DENIAL_REASON_SIGNATURE_INVALID","DENIAL_REASON_QUOTA_EXCEEDED","DENIAL_REASON_DELEGATION_INVALID","DENIAL_REASON_SCOPE_INSUFFICIENT","DENIAL_REASON_ENTITLEMENT_MISSING","DENIAL_REASON_ENTITLEMENT_MALFORMED","DENIAL_REASON_ENTITLEMENT_EXPIRED","DENIAL_REASON_ENTITLEMENT_WRONG_BUYER","DENIAL_REASON_SUBSCRIPTION_LAPSED","DENIAL_REASON_ENTITLEMENT_NOT_GRANTED"]).describe("The denial reason (defined-only, non-zero)"), "restriction_mismatches": z.array(z.enum(["RESTRICTION_KIND_FUNCTION","RESTRICTION_KIND_GEOGRAPHY","RESTRICTION_KIND_USER_TYPE","RESTRICTION_KIND_OTHER"])).describe("When reason = RESTRICTION_NOT_SATISFIED, the failed axes (same\n RestrictionKind vocabulary the terms use).").optional() }).describe("`reason` oneof — ExecuteTransaction denial").optional(), "usage_report_rejection": z.object({ "reason": z.enum(["USAGE_REPORT_REJECTION_REASON_TRANSACTION_NOT_FOUND","USAGE_REPORT_REJECTION_REASON_DUPLICATE","USAGE_REPORT_REJECTION_REASON_WINDOW_EXPIRED","USAGE_REPORT_REJECTION_REASON_MISSING_REQUIRED_FIELDS","USAGE_REPORT_REJECTION_REASON_MALFORMED"]).describe("The rejection reason (defined-only, non-zero)") }).describe("`reason` oneof — ReportUsage filing rejected").optional() }).describe("ErrorDetail — the structured detail attached to every non-OK transport error.")); export const GetAccountStatusRequestSchema = wire(z.object({ "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "ver": z.string().describe("RAMP protocol version — \"1.0\". Stamped by the sender from a single\n constant; advisory on receive. See \"Protocol version\" in the file header.").default("") }).describe("GetAccountStatusRequest — Agent asks whether its account is active.")); @@ -118,13 +118,15 @@ export const RefreshCatalogRequestSchema = wire(z.object({ "tenant_id": z.string export const RefreshCatalogResponseSchema = wire(z.object({ "started": z.boolean().describe("Whether the refresh was started").default(false), "ver": z.string().describe("RAMP protocol version — \"1.0\". Stamped by the sender from a single\n constant; advisory on receive. See \"Protocol version\" in the file header.").default("") })); -export const RegisterRequestSchema = wire(z.object({ "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "registration_data": z.record(z.string(), z.any()).describe("Operator-defined registration payload; the business fields are not fixed\n in the wire contract. The Exchange passes it through to its system of\n record without inspecting it. The caller's identity is taken from the\n verified request signature, never from this payload.").optional(), "ver": z.string().describe("RAMP protocol version — \"1.0\". Stamped by the sender from a single\n constant; advisory on receive. See \"Protocol version\" in the file header.").default("") }).describe("RegisterRequest — Agent asks the Exchange to create its account.")); +export const RegisterRequestSchema = wire(z.object({ "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "registration_data": z.record(z.string(), z.any()).describe("Operator-defined registration payload; the business fields are not fixed\n in the wire contract. Whether the Exchange inspects it follows its\n manifest — see WellKnownManifest.registration_schema. The caller's\n identity is taken from the verified request signature, never from this\n payload.").optional(), "ver": z.string().describe("RAMP protocol version — \"1.0\". Stamped by the sender from a single\n constant; advisory on receive. See \"Protocol version\" in the file header.").default("") }).describe("RegisterRequest — Agent asks the Exchange to create its account.")); export const RegisterResponseSchema = wire(z.object({ "active": z.boolean().describe("Whether the account is currently active. Accounts may start inactive\n and be activated out-of-band by the Exchange operator.").default(false), "billing_ref": z.string().describe("Opaque, long-lived, per-Exchange account handle minted by the Exchange.\n Means nothing on its own and is never accepted as caller input. A repeat\n Register for the same agent returns the same value.").default(""), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "ver": z.string().describe("RAMP protocol version — \"1.0\". Stamped by the sender from a single\n constant; advisory on receive. See \"Protocol version\" in the file header.").default("") }).describe("RegisterResponse — Exchange returns the minted account handle.")); -export const RegistrationFailureSchema = wire(z.object({ "reason": z.enum(["REGISTRATION_FAILURE_REASON_DOMAIN_NOT_VERIFIED","REGISTRATION_FAILURE_REASON_INVALID_KEY","REGISTRATION_FAILURE_REASON_SIGNATURE_INVALID","REGISTRATION_FAILURE_REASON_ALREADY_REGISTERED","REGISTRATION_FAILURE_REASON_QUOTA_EXCEEDED"]).describe("The failure reason (defined-only, non-zero)") }).describe("RegistrationFailure — a registration request could not be completed.")); +export const RegistrationFailureSchema = wire(z.object({ "field_errors": z.array(z.object({ "error": z.string().min(1).max(255).describe("Developer-facing, NON-authoritative description of what failed\n (e.g. \"required\", \"must match ^[A-Z]{2}[0-9]+$\"). Wording is\n validator-defined and not stable across Exchanges; clients branch on\n `reason`, never on this text. States the constraint, NEVER the submitted\n value — the ErrorDetail leakage rule applies here too."), "path": z.string().max(255).describe("RFC 6901 JSON Pointer to the offending member, relative to\n registration_data (e.g. \"/vat_id\", \"/address/postal_code\"). The empty\n string addresses registration_data itself, for whole-object failures\n (oneOf, minProperties) that belong to no single member.").default("") }).describe("RegistrationFieldError — one registration_data member that failed the\n Exchange's published registration_schema (WellKnownManifest.\n registration_schema).")).max(64).describe("When reason = INVALID_REGISTRATION_DATA: the registration_data members\n that are missing or do not conform. Empty for every other reason.").optional(), "reason": z.enum(["REGISTRATION_FAILURE_REASON_DOMAIN_NOT_VERIFIED","REGISTRATION_FAILURE_REASON_INVALID_KEY","REGISTRATION_FAILURE_REASON_SIGNATURE_INVALID","REGISTRATION_FAILURE_REASON_ALREADY_REGISTERED","REGISTRATION_FAILURE_REASON_QUOTA_EXCEEDED","REGISTRATION_FAILURE_REASON_INVALID_REGISTRATION_DATA"]).describe("The failure reason (defined-only, non-zero)") }).describe("RegistrationFailure — a registration request could not be completed.")); -export const RegistrationFailureReasonSchema = wire(z.enum(["REGISTRATION_FAILURE_REASON_DOMAIN_NOT_VERIFIED","REGISTRATION_FAILURE_REASON_INVALID_KEY","REGISTRATION_FAILURE_REASON_SIGNATURE_INVALID","REGISTRATION_FAILURE_REASON_ALREADY_REGISTERED","REGISTRATION_FAILURE_REASON_QUOTA_EXCEEDED"])); +export const RegistrationFailureReasonSchema = wire(z.enum(["REGISTRATION_FAILURE_REASON_DOMAIN_NOT_VERIFIED","REGISTRATION_FAILURE_REASON_INVALID_KEY","REGISTRATION_FAILURE_REASON_SIGNATURE_INVALID","REGISTRATION_FAILURE_REASON_ALREADY_REGISTERED","REGISTRATION_FAILURE_REASON_QUOTA_EXCEEDED","REGISTRATION_FAILURE_REASON_INVALID_REGISTRATION_DATA"])); + +export const RegistrationFieldErrorSchema = wire(z.object({ "error": z.string().min(1).max(255).describe("Developer-facing, NON-authoritative description of what failed\n (e.g. \"required\", \"must match ^[A-Z]{2}[0-9]+$\"). Wording is\n validator-defined and not stable across Exchanges; clients branch on\n `reason`, never on this text. States the constraint, NEVER the submitted\n value — the ErrorDetail leakage rule applies here too."), "path": z.string().max(255).describe("RFC 6901 JSON Pointer to the offending member, relative to\n registration_data (e.g. \"/vat_id\", \"/address/postal_code\"). The empty\n string addresses registration_data itself, for whole-object failures\n (oneOf, minProperties) that belong to no single member.").default("") }).describe("RegistrationFieldError — one registration_data member that failed the\n Exchange's published registration_schema (WellKnownManifest.\n registration_schema).")); export const RemoveResourcesRequestSchema = wire(z.object({ "paths": z.array(z.string()).describe("Paths to remove").optional(), "tenant_id": z.string().describe("Tenant identifier").default(""), "ver": z.string().describe("RAMP protocol version — \"1.0\". Stamped by the sender from a single\n constant; advisory on receive. See \"Protocol version\" in the file header.").default("") })); @@ -202,5 +204,5 @@ export const UsageReportResponseSchema = wire(z.object({ "ext": z.record(z.strin export const WBAFileSchema = wire(z.object({ "keys": z.array(z.object({ "alg": z.string().describe("Signing algorithm. RAMP v1.0: MUST be \"EdDSA\".").default(""), "crv": z.string().describe("Curve. RAMP v1.0: MUST be \"Ed25519\".").default(""), "kty": z.string().describe("Key type. RAMP v1.0: MUST be \"OKP\".").default(""), "not_after": z.string().describe("RFC3339 timestamp. Key is invalid at and after this instant\n (strict upper bound).").default(""), "not_before": z.string().describe("RFC3339 timestamp. Key is invalid before this instant.").default(""), "use": z.string().describe("Intended key use. RAMP v1.0: MUST be \"sig\".").default(""), "x": z.string().describe("base64url-encoded 32-byte Ed25519 public key.").default("") }).describe("RAMP v1.0 supports Ed25519 only: kty=\"OKP\", crv=\"Ed25519\", alg=\"EdDSA\".\n Additional curves are a later concern.\n\n Time bounds are RFC3339 strings (sortable, ops-debuggable, avoids the\n JWT nbf/exp collision). At least one key in the served key set (WBAFile.keys)\n MUST have `not_before <= now < not_after`. Verification MUST reject\n signatures whose key falls outside its window.\n\n Keys carry no `kid`: the RFC 9421 keyid is the RFC 7638 JWK Thumbprint,\n computed locally by the verifier. Carrying a kid alongside the thumbprint\n created a drift surface and is removed.")).describe("RFC 7517 JWK Set \"keys\" member. RAMP v1: Ed25519 (OKP) keys, each with\n not_before/not_after RAMP extension members.").optional(), "revocation_url": z.string().describe("Directory-level emergency revocation channel. One per directory; the list\n it points to enumerates revoked key thumbprints. Consumers poll on a 300s\n cadence (±10% jitter) and replace their local revoked set with the response.").optional() }).describe("WBAFile — Pure Web Bot Auth directory served at the WBA-canonical well-known\n path (/.well-known/http-message-signatures-directory). A JOSE JWK Set per\n RFC 7517 §5 plus a directory-level revocation pointer. JWKs carry no kid; the\n RFC 9421 keyid is the RFC 7638 JWK Thumbprint. Off-the-shelf WBA verifiers\n read the `keys` array and ignore RAMP's extra members (per-key\n not_before/not_after, and revocation_url) per RFC 7517 §5.")); -export const WellKnownManifestSchema = wire(z.object({ "accepted_verifiers": z.array(z.string()).describe("Exchange-only. Trusted attestation verification vendors (domains).").optional(), "base_currency": z.string().describe("Exchange-only. Base currency for pricing (ISO 4217). All unit_cost\n values from this Exchange are denominated in this currency.").optional(), "catalog_contributors": z.array(z.object({ "domain": z.string().describe("Canonical domain of the authorized contributor (e.g., \"doubleverify.com\").").default(""), "relationship": z.string().describe("Relationship of this contributor to the provider.\n Examples: \"verifier\" (resource intelligence vendor that attests to resource\n properties), \"exchange\" (an Exchange that enriches catalog entries).").default("") }).describe("CatalogContributor — A third party authorized to push catalog metadata\n (including attestations) on behalf of a provider.")).describe("Publisher-only. Authorized third-party catalog contributors.\n MUST be empty for non-publisher roles.").optional(), "catalog_endpoint": z.string().describe("Exchange-only. CatalogService endpoint URL (if exposed).").optional(), "contact": z.string().describe("Contact email (licensing, integration, security).").optional(), "delivery_methods_supported": z.array(z.enum(["DELIVERY_METHOD_DIRECT","DELIVERY_METHOD_INSTRUCTIONS","DELIVERY_METHOD_STREAMING"])).describe("Exchange-only. Supported delivery methods.").optional(), "domain": z.string().describe("Canonical domain serving this manifest.").default(""), "endpoint": z.string().describe("Exchange-only. ExchangeService endpoint URL.").optional(), "exchanges": z.array(z.object({ "domain": z.string().describe("Canonical domain of the Exchange.").default(""), "endpoint": z.string().describe("RAMP ExchangeService endpoint URL.").default(""), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "relationship": z.enum(["PROVIDER_RELATIONSHIP_DIRECT","PROVIDER_RELATIONSHIP_RESELLER"]).describe("Relationship type (mirrors ads.txt DIRECT/RESELLER).") }).describe("AuthorizedExchange — A Exchange authorized to sell this provider's resources.")).describe("Publisher-only. Authorized exchanges for this publisher's resources.\n Like ads.txt — declares who may sell. MUST be empty for non-publisher\n roles.").optional(), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052). Lists keys\n within ext that the consumer MUST understand. Unknown values reject\n with UNKNOWN_CRITICAL_EXTENSION. Empty (default) → ignore-unknown.").optional(), "gnap_grant_endpoint": z.string().describe("Exchange-only. GNAP grant endpoint when GNAP is supported.").optional(), "hash_methods_supported": z.array(z.string()).describe("Exchange-only. Accepted resource hash methods for attestation\n verification.").optional(), "health_endpoint": z.string().describe("Exchange-only. Health check endpoint URL.").optional(), "max_intermediary_hops": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Exchange-only. Maximum forwarding hops this Exchange tolerates on an inbound\n request (Agent → Broker → … → Exchange), counted as RFC 9421 HTTP Message\n Signatures. A request carrying more SHOULD be rejected. Lets Exchanges\n publish their chain-depth tolerance so Brokers prune before forwarding.\n Absent = no published limit (Exchange applies its own default policy).").optional(), "name": z.string().describe("Exchange-only. Human-readable Exchange name.").optional(), "oidc_issuer": z.string().describe("Exchange-only. OIDC Discovery URL when OAuth methods are supported.").optional(), "operator": z.string().describe("Exchange-only. Organization operating this Exchange.").optional(), "operator_domain": z.string().describe("Exchange-only. Operator's corporate domain (may differ from domain).").optional(), "pricing_models_supported": z.array(z.enum(["PRICING_MODEL_FREE","PRICING_MODEL_PER_UNIT","PRICING_MODEL_FLAT"])).describe("Exchange-only. Supported pricing models.").optional(), "privacy_uri": z.string().describe("Exchange-only. Privacy policy URL.").optional(), "protocol_versions_supported": z.array(z.string()).describe("Exchange-only. Supported RAMP protocol versions (e.g. [\"1.0\"]).").optional(), "role": z.enum(["ROLE_AGENT","ROLE_EXCHANGE","ROLE_BROKER","ROLE_PUBLISHER"]).describe("Role this manifest describes."), "supported_auth_methods": z.array(z.enum(["AUTH_METHOD_GNAP","AUTH_METHOD_OAUTH_DPOP","AUTH_METHOD_OAUTH_BEARER","AUTH_METHOD_OAUTH_MTLS"])).describe("Exchange-only. Authorization methods this Exchange supports\n (ordered by preference).").optional(), "supported_profiles": z.array(z.string()).describe("Exchange-only. Domain extension profiles this Exchange conforms to.\n See standards-layering docs.").optional(), "terms_uri": z.string().describe("Exchange-only. Terms of service URL.").optional(), "ver": z.string().describe("RAMP protocol version of THIS MANIFEST DOCUMENT's schema — a namespace\n separate from the RPC envelope `ver`, deliberately not coupled to it.\n MUST equal \"1.0\"; consumers REJECT unrecognised major versions.").default("") }).describe("Commercial graph only: role, authorized exchanges/contributors, and exchange\n capability fields. Identity keys are NOT here — they live in the WBA directory\n (WBAFile) served at /.well-known/http-message-signatures-directory and are\n referenced by RFC 7638 thumbprint, never republished here.\n Per-role fields are populated only when that role applies; consumers\n MUST ignore non-applicable fields based on `role`.")); +export const WellKnownManifestSchema = wire(z.object({ "accepted_verifiers": z.array(z.string()).describe("Exchange-only. Trusted attestation verification vendors (domains).").optional(), "base_currency": z.string().describe("Exchange-only. Base currency for pricing (ISO 4217). All unit_cost\n values from this Exchange are denominated in this currency.").optional(), "catalog_contributors": z.array(z.object({ "domain": z.string().describe("Canonical domain of the authorized contributor (e.g., \"doubleverify.com\").").default(""), "relationship": z.string().describe("Relationship of this contributor to the provider.\n Examples: \"verifier\" (resource intelligence vendor that attests to resource\n properties), \"exchange\" (an Exchange that enriches catalog entries).").default("") }).describe("CatalogContributor — A third party authorized to push catalog metadata\n (including attestations) on behalf of a provider.")).describe("Publisher-only. Authorized third-party catalog contributors.\n MUST be empty for non-publisher roles.").optional(), "catalog_endpoint": z.string().describe("Exchange-only. CatalogService endpoint URL (if exposed).").optional(), "contact": z.string().describe("Contact email (licensing, integration, security).").optional(), "delivery_methods_supported": z.array(z.enum(["DELIVERY_METHOD_DIRECT","DELIVERY_METHOD_INSTRUCTIONS","DELIVERY_METHOD_STREAMING"])).describe("Exchange-only. Supported delivery methods.").optional(), "domain": z.string().describe("Canonical domain serving this manifest.").default(""), "endpoint": z.string().describe("Exchange-only. ExchangeService endpoint URL.").optional(), "exchanges": z.array(z.object({ "domain": z.string().describe("Canonical domain of the Exchange.").default(""), "endpoint": z.string().describe("RAMP ExchangeService endpoint URL.").default(""), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052).\n Lists keys within ext that the consumer MUST understand.\n Unknown keys in this list → reject with UNKNOWN_CRITICAL_EXTENSION.\n Empty (default) → all ext keys are safe to ignore.").optional(), "relationship": z.enum(["PROVIDER_RELATIONSHIP_DIRECT","PROVIDER_RELATIONSHIP_RESELLER"]).describe("Relationship type (mirrors ads.txt DIRECT/RESELLER).") }).describe("AuthorizedExchange — A Exchange authorized to sell this provider's resources.")).describe("Publisher-only. Authorized exchanges for this publisher's resources.\n Like ads.txt — declares who may sell. MUST be empty for non-publisher\n roles.").optional(), "ext": z.record(z.string(), z.any()).describe("Extension point").optional(), "ext_critical": z.array(z.string()).describe("Critical extension keys (COSE crit pattern, RFC 9052). Lists keys\n within ext that the consumer MUST understand. Unknown values reject\n with UNKNOWN_CRITICAL_EXTENSION. Empty (default) → ignore-unknown.").optional(), "gnap_grant_endpoint": z.string().describe("Exchange-only. GNAP grant endpoint when GNAP is supported.").optional(), "hash_methods_supported": z.array(z.string()).describe("Exchange-only. Accepted resource hash methods for attestation\n verification.").optional(), "health_endpoint": z.string().describe("Exchange-only. Health check endpoint URL.").optional(), "max_intermediary_hops": z.coerce.number().int().gte(-2147483648).lte(2147483647).describe("Exchange-only. Maximum forwarding hops this Exchange tolerates on an inbound\n request (Agent → Broker → … → Exchange), counted as RFC 9421 HTTP Message\n Signatures. A request carrying more SHOULD be rejected. Lets Exchanges\n publish their chain-depth tolerance so Brokers prune before forwarding.\n Absent = no published limit (Exchange applies its own default policy).").optional(), "name": z.string().describe("Exchange-only. Human-readable Exchange name.").optional(), "oidc_issuer": z.string().describe("Exchange-only. OIDC Discovery URL when OAuth methods are supported.").optional(), "operator": z.string().describe("Exchange-only. Organization operating this Exchange.").optional(), "operator_domain": z.string().describe("Exchange-only. Operator's corporate domain (may differ from domain).").optional(), "pricing_models_supported": z.array(z.enum(["PRICING_MODEL_FREE","PRICING_MODEL_PER_UNIT","PRICING_MODEL_FLAT"])).describe("Exchange-only. Supported pricing models.").optional(), "privacy_uri": z.string().describe("Exchange-only. Privacy policy URL.").optional(), "protocol_versions_supported": z.array(z.string()).describe("Exchange-only. Supported RAMP protocol versions (e.g. [\"1.0\"]).").optional(), "registration_schema": z.record(z.string(), z.any()).describe("Exchange-only. JSON Schema (draft 2020-12) describing the\n RegisterRequest.registration_data object this Exchange expects. This field\n is the single home of the enforce/pass-through contract, and publishing it\n IS the enforcement switch. Present: this Exchange validates\n registration_data against the schema and refuses a non-conforming payload\n with REGISTRATION_FAILURE_REASON_INVALID_REGISTRATION_DATA, naming the\n offending members in RegistrationFailure.field_errors. Absent:\n registration_data is passed through to the system of record uninspected,\n so an Exchange that publishes no schema needs no change to stay\n conformant. Safety rules, because a consumer reads this schema out of a\n third party's manifest: it MUST be self-contained, and a consumer MUST NOT\n resolve a remote $ref out of it — doing so turns every reader into an SSRF\n vector aimed at a URL the schema's author chose. A consumer SHOULD bound\n validation time and recursion depth; draft 2020-12 `pattern` admits\n regexes with catastrophic backtracking. Size is capped at 16KB, measured\n as the UTF-8 bytes of this member as served in ramp.json; a consumer\n SHOULD reject an oversized schema and skip its local pre-check rather than\n truncate it, which leaves the Exchange's own enforcement the deciding\n check exactly as when no schema is published.").optional(), "role": z.enum(["ROLE_AGENT","ROLE_EXCHANGE","ROLE_BROKER","ROLE_PUBLISHER"]).describe("Role this manifest describes."), "supported_auth_methods": z.array(z.enum(["AUTH_METHOD_GNAP","AUTH_METHOD_OAUTH_DPOP","AUTH_METHOD_OAUTH_BEARER","AUTH_METHOD_OAUTH_MTLS"])).describe("Exchange-only. Authorization methods this Exchange supports\n (ordered by preference).").optional(), "supported_profiles": z.array(z.string()).describe("Exchange-only. Domain extension profiles this Exchange conforms to.\n See standards-layering docs.").optional(), "terms_uri": z.string().describe("Exchange-only. Terms of service URL.").optional(), "ver": z.string().describe("RAMP protocol version of THIS MANIFEST DOCUMENT's schema — a namespace\n separate from the RPC envelope `ver`, deliberately not coupled to it.\n MUST equal \"1.0\"; consumers REJECT unrecognised major versions.").default("") }).describe("Commercial graph only: role, authorized exchanges/contributors, and exchange\n capability fields. Identity keys are NOT here — they live in the WBA directory\n (WBAFile) served at /.well-known/http-message-signatures-directory and are\n referenced by RFC 7638 thumbprint, never republished here.\n Per-role fields are populated only when that role applies; consumers\n MUST ignore non-applicable fields based on `role`.")); diff --git a/proto/CHANGELOG.md b/proto/CHANGELOG.md index d70415f7..e335c003 100644 --- a/proto/CHANGELOG.md +++ b/proto/CHANGELOG.md @@ -2,6 +2,84 @@ ## Unreleased +**SDK (all 3 languages): the registration-failure builder can carry the field errors +(additive, no wire change).** `helpers.RegistrationFailureDetail` (Go), +`registration_failure_detail` (Python) and `registrationFailureDetail` (TS) now accept the +offending `registration_data` members alongside the reason — variadic in Go, an optional +trailing argument in Python and TS, so the six reasons that carry no per-member detail keep +their three-argument call. Without this 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: one place per language where the ADR-019 envelope is constructed. This is +the only `*Detail` builder that reaches past the reason enum — the schema refusal is useless +without naming what failed, whereas the sibling detail lists +(`TransactionDenial.restriction_mismatches`, `CatalogRejection.rejected_paths`) stay +caller-set after construction. + +The shared oracle gains a `registration_failure_field_errors` vector and a `field_errors` +projection, replayed on both halves in all three languages: the construct replays feed the +members back through the 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 one caught a real divergence: canonical proto-JSON omits an empty scalar, so the wire +form of a root-pointer entry has no `path` key at all, while the generated Pydantic model +defaults `path` to `""` and the generated Zod schema declares `.default("")`, both +materializing a member Go omits. Both builders now map an empty path to unpopulated, the +exact inverse of the read side normalizing an absent path to `""`. + +**Registration data becomes schema-enforceable: `WellKnownManifest.registration_schema` +(field 29) + `REGISTRATION_FAILURE_REASON_INVALID_REGISTRATION_DATA` (additive, no wire +break).** An Exchange MAY publish, in its `ramp.json`, a JSON Schema (draft 2020-12, max +16KB) describing the `registration_data` object it expects on `Register`. Publication and +enforcement are one decision: an Exchange that publishes the schema validates incoming +`registration_data` against it and refuses a non-conforming payload with the new failure +reason; an Exchange that publishes none accepts the payload uninspected and passes it to +its system of record exactly as before, so existing Exchanges stay conformant with no +change. This replaces the former unconditional contract text ("the Exchange passes it +through to its system of record without inspecting it") on the Agent Account Registration +banner and on `RegisterRequest.registration_data`, both of which now defer to the field +that owns the contract rather than restating it. + +The field carries normative safety rules, because a consumer reads this schema out of a +third party's manifest: the schema MUST be self-contained and a consumer MUST NOT resolve +a remote `$ref` out of it — doing so would turn every reader into an SSRF vector aimed at +a URL the schema's author chose — and a consumer SHOULD bound validation time and +recursion depth, since draft 2020-12 `pattern` admits regexes with catastrophic +backtracking. The 16KB cap is measured as the UTF-8 bytes of the member as served in +`ramp.json`; an oversized schema SHOULD be rejected and its local pre-check skipped rather +than truncated, which leaves the Exchange's own enforcement deciding exactly as it does +when no schema is published. These are prose, not protovalidate rules: the field is a +`Struct`, and no field-level rule can reach inside it. + +The refusal names what to fix: `RegistrationFailure` gains +`field_errors` (field 2, ≤64 items) carrying the new top-level `RegistrationFieldError` +`{path, error}`. `path` is an RFC 6901 JSON Pointer relative to `registration_data` +(`"/vat_id"`, `"/address/postal_code"`); the empty string addresses `registration_data` +itself, which is how whole-object failures (`oneOf`, `minProperties`) that belong to no +single member are reported. A free-text pair rather than a closed `kind` enum because +JSON Schema's composite keywords do not attach to any one 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 and varies across +Exchanges, clients branch on `reason` — and, 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. A machine-readable `kind` can join at field 3 later +without a wire break. + +Motivation: 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 fields a given Exchange +expects. Both now resolve against the manifest the agent already fetches to find the +Exchange's endpoint. + +*Tooling:* `RegistrationFailure` is now seeded in the corpus generator with the new reason +and an empty-path field error, so the cross-language oracle exercises the reason this +change adds and pins the empty-path accept boundary in all three languages; without the +seed the auto-filled baseline picked the first allowed reason and published a +`DOMAIN_NOT_VERIFIED` refusal carrying `field_errors` as valid — the pairing the field +comment rules out. The generator also gained valid-item construction for repeated +**message** fields (seed-or-autofill, mirroring the top-level baseline). `field_errors` is the +contract's first repeated message field carrying its own `repeated.max_items`, and the +generator previously produced only scalar list items. + **The `ver` envelope field states its contract, and the version string gets one owner (no wire change).** All 29 `ver` fields — 25 in `ramp.proto`, 4 in `admin.proto` — now name the expected value `"1.0"` and the receive-side rule. Before this, 27 of them said only diff --git a/proto/ramp/v1/ramp.proto b/proto/ramp/v1/ramp.proto index 96128390..4044f268 100644 --- a/proto/ramp/v1/ramp.proto +++ b/proto/ramp/v1/ramp.proto @@ -2618,6 +2618,27 @@ message WellKnownManifest { // Absent = no published limit (Exchange applies its own default policy). optional int32 max_intermediary_hops = 28; + // Exchange-only. JSON Schema (draft 2020-12) describing the + // RegisterRequest.registration_data object this Exchange expects. This field + // is the single home of the enforce/pass-through contract, and publishing it + // IS the enforcement switch. Present: this Exchange validates + // registration_data against the schema and refuses a non-conforming payload + // with REGISTRATION_FAILURE_REASON_INVALID_REGISTRATION_DATA, naming the + // offending members in RegistrationFailure.field_errors. Absent: + // registration_data is passed through to the system of record uninspected, + // so an Exchange that publishes no schema needs no change to stay + // conformant. Safety rules, because a consumer reads this schema out of a + // third party's manifest: it MUST be self-contained, and a consumer MUST NOT + // resolve a remote $ref out of it — doing so turns every reader into an SSRF + // vector aimed at a URL the schema's author chose. A consumer SHOULD bound + // validation time and recursion depth; draft 2020-12 `pattern` admits + // regexes with catastrophic backtracking. Size is capped at 16KB, measured + // as the UTF-8 bytes of this member as served in ramp.json; a consumer + // SHOULD reject an oversized schema and skip its local pre-check rather than + // truncate it, which leaves the Exchange's own enforcement the deciding + // check exactly as when no schema is published. + google.protobuf.Struct registration_schema = 29; + // Extension point google.protobuf.Struct ext = 15; @@ -3072,9 +3093,9 @@ message DomainVerificationResult { // afterwards; the Exchange resolves the account from the verified request // signature on every call, never from anything the caller sends. // -// The Exchange does not inspect or validate the business payload -// (registration_data): its contents are operator-defined and enforced by the -// registration front-end, not by this contract. +// Whether the Exchange inspects the business payload (registration_data) +// follows its manifest — see WellKnownManifest.registration_schema, which +// states that contract. // ============================================================================ // RegisterRequest — Agent asks the Exchange to create its account. @@ -3084,9 +3105,10 @@ message RegisterRequest { string ver = 1; // Operator-defined registration payload; the business fields are not fixed - // in the wire contract. The Exchange passes it through to its system of - // record without inspecting it. The caller's identity is taken from the - // verified request signature, never from this payload. + // in the wire contract. Whether the Exchange inspects it follows its + // manifest — see WellKnownManifest.registration_schema. The caller's + // identity is taken from the verified request signature, never from this + // payload. google.protobuf.Struct registration_data = 2; // Extension point @@ -3299,6 +3321,7 @@ enum RegistrationFailureReason { REGISTRATION_FAILURE_REASON_SIGNATURE_INVALID = 3; // request signature invalid REGISTRATION_FAILURE_REASON_ALREADY_REGISTERED = 4; // identity already registered REGISTRATION_FAILURE_REASON_QUOTA_EXCEEDED = 5; // registration quota exceeded + REGISTRATION_FAILURE_REASON_INVALID_REGISTRATION_DATA = 6; // registration_data does not conform to the Exchange's published registration_schema } // RegistrationFailure — a registration request could not be completed. @@ -3307,6 +3330,30 @@ message RegistrationFailure { RegistrationFailureReason reason = 1 [ (buf.validate.field).enum = {defined_only: true, not_in: [0]} ]; + + // When reason = INVALID_REGISTRATION_DATA: the registration_data members + // that are missing or do not conform. Empty for every other reason. + repeated RegistrationFieldError field_errors = 2 [ + (buf.validate.field).repeated.max_items = 64 + ]; +} + +// RegistrationFieldError — one registration_data member that failed the +// Exchange's published registration_schema (WellKnownManifest. +// registration_schema). +message RegistrationFieldError { + // RFC 6901 JSON Pointer to the offending member, relative to + // registration_data (e.g. "/vat_id", "/address/postal_code"). The empty + // string addresses registration_data itself, for whole-object failures + // (oneOf, minProperties) that belong to no single member. + string path = 1 [(buf.validate.field).string.max_len = 255]; + + // Developer-facing, NON-authoritative description of what failed + // (e.g. "required", "must match ^[A-Z]{2}[0-9]+$"). Wording is + // validator-defined and not stable across Exchanges; clients branch on + // `reason`, never on this text. States the constraint, NEVER the submitted + // value — the ErrorDetail leakage rule applies here too. + string error = 2 [(buf.validate.field).string = {min_len: 1, max_len: 255}]; } // DisputeFailureReason — why a DisputeTransaction filing was refused. Distinct diff --git a/sdk/go/helpers/errordetail.go b/sdk/go/helpers/errordetail.go index 52cf3107..c0b406e1 100644 --- a/sdk/go/helpers/errordetail.go +++ b/sdk/go/helpers/errordetail.go @@ -46,10 +46,23 @@ func CatalogRejectionDetail(domain, message string, reason rampv1.CatalogRejecti return d } -// RegistrationFailureDetail builds an ErrorDetail carrying a typed RegistrationFailureReason. -func RegistrationFailureDetail(domain, message string, reason rampv1.RegistrationFailureReason) *rampv1.ErrorDetail { +// RegistrationFailureDetail builds an ErrorDetail carrying a typed +// RegistrationFailureReason, plus the offending registration_data members when the +// reason is REGISTRATION_FAILURE_REASON_INVALID_REGISTRATION_DATA. +// +// fieldErrors is variadic rather than a fourth required parameter so the six +// reasons that carry no per-member detail keep calling this with three arguments. +// It is the one *Detail builder that reaches past the reason enum: the schema +// refusal is useless without naming what failed, whereas the sibling detail lists +// (TransactionDenial.restriction_mismatches, CatalogRejection.rejected_paths) are +// still caller-set post-construction. Passing field errors with any other reason +// is a caller error — the field's contract says the list is empty otherwise. +func RegistrationFailureDetail(domain, message string, reason rampv1.RegistrationFailureReason, fieldErrors ...*rampv1.RegistrationFieldError) *rampv1.ErrorDetail { d := base(domain, message) - d.Reason = &rampv1.ErrorDetail_RegistrationFailure{RegistrationFailure: &rampv1.RegistrationFailure{Reason: reason}} + d.Reason = &rampv1.ErrorDetail_RegistrationFailure{RegistrationFailure: &rampv1.RegistrationFailure{ + Reason: reason, + FieldErrors: fieldErrors, + }} return d } diff --git a/sdk/go/helpers/errordetail_corpus_test.go b/sdk/go/helpers/errordetail_corpus_test.go index 214cf9de..58f5d540 100644 --- a/sdk/go/helpers/errordetail_corpus_test.go +++ b/sdk/go/helpers/errordetail_corpus_test.go @@ -66,10 +66,31 @@ func TestErrorDetailCorpusReplay(t *testing.T) { } assertMetadataEqual(t, got.GetMetadata(), v.Metadata) assertReasonEqual(t, &got, v.ReasonField, v.ReasonEnum) + assertFieldErrorsEqual(t, &got, v.FieldErrors) }) } } +// assertFieldErrorsEqual compares the RegistrationFailure per-member detail a +// reader extracts against the recorded projection. An absent list and an empty one +// are equal (proto3 omits an empty repeated field on the wire). The empty path is +// asserted positionally, so a decoder that drops "" as unset fails here rather +// than shifting the list silently. +func assertFieldErrorsEqual(t *testing.T, got *rampv1.ErrorDetail, want []errorDetailFieldError) { + t.Helper() + fes := got.GetRegistrationFailure().GetFieldErrors() + if len(fes) != len(want) { + t.Errorf("field_errors count = %d, want %d", len(fes), len(want)) + return + } + for i, w := range want { + if fes[i].GetPath() != w.Path || fes[i].GetError() != w.Error { + t.Errorf("field_errors[%d] = {%q, %q}, want {%q, %q}", + i, fes[i].GetPath(), fes[i].GetError(), w.Path, w.Error) + } + } +} + // assertMetadataEqual compares the extracted metadata against the recorded // projection, treating a nil map and an empty map as equal (proto3 omits an empty // map on the wire, so a reader legitimately extracts either). diff --git a/sdk/go/helpers/gen_errordetail_vectors_test.go b/sdk/go/helpers/gen_errordetail_vectors_test.go index 05ea2b11..ade93855 100644 --- a/sdk/go/helpers/gen_errordetail_vectors_test.go +++ b/sdk/go/helpers/gen_errordetail_vectors_test.go @@ -57,7 +57,35 @@ type errorDetailVector struct { Metadata map[string]string `json:"metadata"` ReasonField string `json:"reason_field"` ReasonEnum string `json:"reason_enum"` - WireJSON any `json:"wire_json"` + // FieldErrors is the RegistrationFailure per-member detail, null on every + // vector that carries none. It is projected separately from wire_json because + // the construct-side replays feed it BACK to the builder (the one builder that + // takes more than a reason), and the read-side replays assert a reader + // extracts it — the same double duty metadata already does. + FieldErrors []errorDetailFieldError `json:"field_errors"` + WireJSON any `json:"wire_json"` +} + +// errorDetailFieldError is the projection of one RegistrationFieldError. +type errorDetailFieldError struct { + Path string `json:"path"` + Error string `json:"error"` +} + +// fieldErrorProjection reads the per-member detail back off d through the real +// getters, so the projection cannot drift from what the builder set. Returns nil +// when the detail carries none (every non-registration vector, and a registration +// refusal whose reason needs no member list). +func fieldErrorProjection(d *rampv1.ErrorDetail) []errorDetailFieldError { + fes := d.GetRegistrationFailure().GetFieldErrors() + if len(fes) == 0 { + return nil + } + out := make([]errorDetailFieldError, len(fes)) + for i, fe := range fes { + out[i] = errorDetailFieldError{Path: fe.GetPath(), Error: fe.GetError()} + } + return out } // errorDetailJSONOptions is the PINNED proto-JSON option set the ErrorDetail wire @@ -137,6 +165,7 @@ func vectorFrom(t *testing.T, name string, d *rampv1.ErrorDetail) errorDetailVec Metadata: d.GetMetadata(), // nil when the detail carries none ReasonField: field, ReasonEnum: enum, + FieldErrors: fieldErrorProjection(d), // nil when the detail carries none WireJSON: wireOf(t, d), } } @@ -195,6 +224,19 @@ func buildErrorDetailVectors(t *testing.T) []errorDetailVector { "ramp.v1.RegistrationService", "domain not verified", rampv1.RegistrationFailureReason_REGISTRATION_FAILURE_REASON_DOMAIN_NOT_VERIFIED) + // The schema-enforcement refusal: the one detail that carries per-member + // context, so it is the only vector proving a builder emits more than the + // reason enum. Both member shapes are present — a pointer INTO the payload, + // and the empty root pointer for a whole-object failure (oneOf, minProperties) + // that belongs to no single member, which is the boundary a client most + // plausibly gets wrong by treating "" as unset. + registrationInvalidData := RegistrationFailureDetail( + "ramp.v1.ExchangeService", "registration_data does not match the published registration_schema", + rampv1.RegistrationFailureReason_REGISTRATION_FAILURE_REASON_INVALID_REGISTRATION_DATA, + &rampv1.RegistrationFieldError{Path: "/vat_id", Error: "must match ^[A-Z]{2}[0-9]+$"}, + &rampv1.RegistrationFieldError{Path: "", Error: "matched 2 branches of oneOf, exactly 1 required"}, + ) + disputeFailure := DisputeFailureDetail( "ramp.v1.ExchangeService", "no such transaction", rampv1.DisputeFailureReason_DISPUTE_FAILURE_REASON_TRANSACTION_NOT_FOUND) @@ -219,6 +261,7 @@ func buildErrorDetailVectors(t *testing.T) []errorDetailVector { {"multi_key_metadata_with_reason", multiKey}, {"catalog_rejection_reason", catalogRejection}, {"registration_failure_reason", registrationFailure}, + {"registration_failure_field_errors", registrationInvalidData}, {"dispute_failure_reason", disputeFailure}, {"domain_verification_failure_reason", domainVerificationFailure}, {"usage_report_rejection_reason", usageReportRejection}, diff --git a/sdk/go/helpers/testdata/error-detail-vectors.json b/sdk/go/helpers/testdata/error-detail-vectors.json index 4fd4efbf..0470c0d4 100644 --- a/sdk/go/helpers/testdata/error-detail-vectors.json +++ b/sdk/go/helpers/testdata/error-detail-vectors.json @@ -8,6 +8,7 @@ "metadata": null, "reason_field": "", "reason_enum": "", + "field_errors": null, "wire_json": { "domain": "ramp.v1.ExchangeService", "message": "internal error" @@ -22,6 +23,7 @@ }, "reason_field": "", "reason_enum": "", + "field_errors": null, "wire_json": { "domain": "ramp.v1.CatalogService", "message": "quota exceeded", @@ -37,6 +39,7 @@ "metadata": {}, "reason_field": "", "reason_enum": "", + "field_errors": null, "wire_json": { "domain": "ramp.v1.ExchangeService", "message": "no metadata here" @@ -49,6 +52,7 @@ "metadata": null, "reason_field": "transaction_denial", "reason_enum": "DENIAL_REASON_INSUFFICIENT_BALANCE", + "field_errors": null, "wire_json": { "domain": "ramp.v1.ExchangeService", "message": "balance too low", @@ -64,6 +68,7 @@ "metadata": null, "reason_field": "retrieval_auth_failure", "reason_enum": "RETRIEVAL_AUTH_FAILURE_REASON_URL_EXPIRED", + "field_errors": null, "wire_json": { "domain": "ramp.v1.Edge", "message": "signed URL expired", @@ -83,6 +88,7 @@ }, "reason_field": "transaction_denial", "reason_enum": "DENIAL_REASON_RATE_LIMITED", + "field_errors": null, "wire_json": { "domain": "ramp.v1.ExchangeService", "message": "rate limited", @@ -103,6 +109,7 @@ "metadata": null, "reason_field": "catalog_rejection", "reason_enum": "CATALOG_REJECTION_REASON_TENANT_MISMATCH", + "field_errors": null, "wire_json": { "catalog_rejection": { "reason": "CATALOG_REJECTION_REASON_TENANT_MISMATCH" @@ -118,6 +125,7 @@ "metadata": null, "reason_field": "registration_failure", "reason_enum": "REGISTRATION_FAILURE_REASON_DOMAIN_NOT_VERIFIED", + "field_errors": null, "wire_json": { "domain": "ramp.v1.RegistrationService", "message": "domain not verified", @@ -126,6 +134,40 @@ } } }, + { + "name": "registration_failure_field_errors", + "domain": "ramp.v1.ExchangeService", + "message": "registration_data does not match the published registration_schema", + "metadata": null, + "reason_field": "registration_failure", + "reason_enum": "REGISTRATION_FAILURE_REASON_INVALID_REGISTRATION_DATA", + "field_errors": [ + { + "path": "/vat_id", + "error": "must match ^[A-Z]{2}[0-9]+$" + }, + { + "path": "", + "error": "matched 2 branches of oneOf, exactly 1 required" + } + ], + "wire_json": { + "domain": "ramp.v1.ExchangeService", + "message": "registration_data does not match the published registration_schema", + "registration_failure": { + "field_errors": [ + { + "error": "must match ^[A-Z]{2}[0-9]+$", + "path": "/vat_id" + }, + { + "error": "matched 2 branches of oneOf, exactly 1 required" + } + ], + "reason": "REGISTRATION_FAILURE_REASON_INVALID_REGISTRATION_DATA" + } + } + }, { "name": "dispute_failure_reason", "domain": "ramp.v1.ExchangeService", @@ -133,6 +175,7 @@ "metadata": null, "reason_field": "dispute_failure", "reason_enum": "DISPUTE_FAILURE_REASON_TRANSACTION_NOT_FOUND", + "field_errors": null, "wire_json": { "dispute_failure": { "reason": "DISPUTE_FAILURE_REASON_TRANSACTION_NOT_FOUND" @@ -148,6 +191,7 @@ "metadata": null, "reason_field": "domain_verification_failure", "reason_enum": "DOMAIN_VERIFICATION_FAILURE_REASON_CHALLENGE_MISMATCH", + "field_errors": null, "wire_json": { "domain": "ramp.v1.ExchangeService", "domain_verification_failure": { @@ -163,6 +207,7 @@ "metadata": null, "reason_field": "usage_report_rejection", "reason_enum": "USAGE_REPORT_REJECTION_REASON_DUPLICATE", + "field_errors": null, "wire_json": { "domain": "ramp.v1.ExchangeService", "message": "duplicate report", diff --git a/sdk/python/ramp_sdk/errordetail.py b/sdk/python/ramp_sdk/errordetail.py index 05520b55..c422627e 100644 --- a/sdk/python/ramp_sdk/errordetail.py +++ b/sdk/python/ramp_sdk/errordetail.py @@ -48,7 +48,7 @@ ) if TYPE_CHECKING: - from collections.abc import Iterable, Mapping + from collections.abc import Iterable, Mapping, Sequence from enum import Enum # The fully-qualified proto name Connect stamps on an ErrorDetail transport detail. @@ -119,21 +119,32 @@ def error_detail_from(err: Mapping[str, Any] | Iterable[Any]) -> ErrorDetail | N return None -def _reason_detail(domain: str, message: str, reason_field: str, reason: Enum) -> ErrorDetail: +def _reason_detail( + domain: str, + message: str, + reason_field: str, + reason: Enum, + extra: dict[str, Any] | None = None, +) -> ErrorDetail: """Build an ErrorDetail carrying exactly one typed reason oneof block. The single place the WRITE half constructs the generated model — the analogue of Go's private ``base()`` plus a oneof assignment. Construction goes through ``ErrorDetail.model_validate`` (the same generated-model surface the READ half's :func:`parse_error_detail` uses), so the generated schema owns field names, enum - coercion, and — via :meth:`WireModel.model_dump` — the canonical proto-JSON. Only - the reason block is set; the reason message's extra sub-fields (``offer_id``, - ``rejected_paths``, …) and ``metadata`` are omitted, mirroring the Go builders. A - caller that needs them mutates the returned model post-construction. + coercion, and — via :meth:`WireModel.model_dump` — the canonical proto-JSON. + + ``extra`` merges additional members into the reason block; only + :func:`registration_failure_detail` passes it, for the per-member detail its + reason is useless without. Every other reason message's sub-fields + (``offer_id``, ``restriction_mismatches``, ``rejected_paths``, …) and + ``metadata`` stay omitted, mirroring the Go builders — a caller that needs them + mutates the returned model post-construction. """ - return ErrorDetail.model_validate( - {"domain": domain, "message": message, reason_field: {"reason": reason}} - ) + block: dict[str, Any] = {"reason": reason} + if extra: + block.update(extra) + return ErrorDetail.model_validate({"domain": domain, "message": message, reason_field: block}) def transaction_denial_detail(domain: str, message: str, reason: DenialReason) -> ErrorDetail: @@ -166,14 +177,38 @@ def catalog_rejection_detail( def registration_failure_detail( - domain: str, message: str, reason: RegistrationFailureReason + domain: str, + message: str, + reason: RegistrationFailureReason, + field_errors: Sequence[Mapping[str, str]] | None = None, ) -> ErrorDetail: """Build an ErrorDetail carrying a typed RegistrationFailureReason. Python peer of Go ``helpers.RegistrationFailureDetail`` (agent/provider registration refused). + + ``field_errors`` carries the offending ``registration_data`` members when the + reason is ``REGISTRATION_FAILURE_REASON_INVALID_REGISTRATION_DATA`` — each a + ``{"path", "error"}`` mapping, ``path`` an RFC 6901 JSON Pointer relative to + ``registration_data`` (``""`` addresses the whole object). It is optional + rather than positional so the six reasons that carry no per-member detail keep + the three-argument call, matching Go's variadic. Passing it with any other + reason is a caller error — the field's contract says the list is empty then. """ - return _reason_detail(domain, message, "registration_failure", reason) + extra = None + if field_errors: + # An empty path is "the whole object", and canonical proto-JSON omits an + # empty scalar — so the wire form of a root-pointer entry carries no `path` + # key at all. The generated model defaults `path` to "" rather than None, + # and model_dump only drops None, so passing "" through would emit a member + # Go omits. Map empty -> None here (this repo's proto3 "unpopulated"), which + # is the exact inverse of the read side normalizing absent -> "". + extra = { + "field_errors": [ + {"path": fe.get("path") or None, "error": fe["error"]} for fe in field_errors + ] + } + return _reason_detail(domain, message, "registration_failure", reason, extra) def dispute_failure_detail(domain: str, message: str, reason: DisputeFailureReason) -> ErrorDetail: diff --git a/sdk/python/tests/test_errordetail_construct_parity.py b/sdk/python/tests/test_errordetail_construct_parity.py index 3cee2b9d..71664be1 100644 --- a/sdk/python/tests/test_errordetail_construct_parity.py +++ b/sdk/python/tests/test_errordetail_construct_parity.py @@ -95,9 +95,14 @@ def test_builder_emits_go_oracle_wire(vector: dict) -> None: and assert it serializes byte-for-byte to the Go oracle wire_json.""" build = _BUILDERS[vector["reason_field"]] - # arity is exactly (domain, message, reason); the reason NAME string coerces to - # the generated enum member (proto-JSON enums are NAME strings). - detail = build(vector["domain"], vector["message"], vector["reason_enum"]) + # Base arity is (domain, message, reason); the reason NAME string coerces to the + # generated enum member (proto-JSON enums are NAME strings). registration_failure + # is the one family whose builder takes a fourth argument — the per-member detail + # its INVALID_REGISTRATION_DATA reason is useless without — so a vector carrying + # field_errors feeds them BACK to the builder rather than setting them + # post-construction. Every other family's sub-fields stay caller-set. + extra = {"field_errors": vector["field_errors"]} if vector.get("field_errors") else {} + detail = build(vector["domain"], vector["message"], vector["reason_enum"], **extra) # metadata is set POST-construction, mirroring the Go emitter (multiKey.Metadata # after base()); the builder omits it by design. diff --git a/sdk/python/tests/test_errordetail_parity.py b/sdk/python/tests/test_errordetail_parity.py index d3685c97..86ca4c39 100644 --- a/sdk/python/tests/test_errordetail_parity.py +++ b/sdk/python/tests/test_errordetail_parity.py @@ -62,6 +62,16 @@ def test_reader_extracts_go_projection(vector: dict) -> None: assert got is not None assert got.value == vector["reason_enum"] + # The RegistrationFailure per-member detail, positionally. The empty path is the + # boundary a decoder most plausibly gets wrong: proto3 omits an empty string, so + # wire_json carries an entry with no "path" key at all and a reader must still + # extract "" rather than dropping or shifting the entry. + want_field_errors = vector.get("field_errors") or [] + got_field_errors = getattr(detail.registration_failure, "field_errors", None) or [] + assert [ + {"path": fe.path or "", "error": fe.error} for fe in got_field_errors + ] == want_field_errors + def test_error_detail_from_locates_detail_among_connect_error_details() -> None: """The ErrorDetailFrom-contract analog: find the ramp.v1.ErrorDetail in a diff --git a/sdk/ts/src/errordetail.ts b/sdk/ts/src/errordetail.ts index 158d2462..67e3325e 100644 --- a/sdk/ts/src/errordetail.ts +++ b/sdk/ts/src/errordetail.ts @@ -209,21 +209,50 @@ export function catalogRejectionDetail( }); } +/** One offending `registration_data` member on a schema-enforcement refusal. */ +export type RegistrationFieldError = { + /** RFC 6901 JSON Pointer relative to `registration_data`; "" is the whole object. */ + path: string; + /** Non-authoritative description of what failed; states the constraint, never the value. */ + error: string; +}; + /** * Build an ErrorDetail carrying a typed RegistrationFailureReason. * TS peer of Go `helpers.RegistrationFailureDetail` (agent/provider registration * refused). + * + * `fieldErrors` carries the offending `registration_data` members when the reason + * is `REGISTRATION_FAILURE_REASON_INVALID_REGISTRATION_DATA`. It is optional + * rather than positional so the six reasons that carry no per-member detail keep + * the three-argument call, matching Go's variadic. Passing it with any other + * reason is a caller error — the field's contract says the list is empty then. */ export function registrationFailureDetail( domain: string, message: string, reason: RegistrationFailureReason, + fieldErrors?: readonly RegistrationFieldError[], ): ErrorDetail { - return ErrorDetailSchema.parse({ + const detail = ErrorDetailSchema.parse({ domain, message, - registration_failure: { reason }, + registration_failure: { + reason, + ...(fieldErrors?.length ? { field_errors: [...fieldErrors] } : {}), + }, }); + // An empty path is "the whole object", and canonical proto-JSON omits an empty + // scalar — so the wire form of a root-pointer entry carries no `path` key at + // all. The generated Zod schema declares `.default("")`, which materializes the + // member on parse, so it is dropped back off here. This is the exact inverse of + // the read side normalizing an absent path to "". + const rf = (detail as { registration_failure?: { field_errors?: { path?: string }[] } }) + .registration_failure; + for (const fe of rf?.field_errors ?? []) { + if (fe.path === "") delete fe.path; + } + return detail; } /** diff --git a/sdk/ts/tests/errordetail-construct.parity.test.ts b/sdk/ts/tests/errordetail-construct.parity.test.ts index e2d3929a..b091f333 100644 --- a/sdk/ts/tests/errordetail-construct.parity.test.ts +++ b/sdk/ts/tests/errordetail-construct.parity.test.ts @@ -47,11 +47,17 @@ type ErrorDetailVector = { metadata: Record | null; reason_field: string; reason_enum: string; + field_errors: { path: string; error: string }[] | null; wire_json: unknown; }; type VectorsFile = { canonicalization: string; vectors: ErrorDetailVector[] }; -type Builder = (domain: string, message: string, reason: string) => ErrorDetail; +type Builder = ( + domain: string, + message: string, + reason: string, + fieldErrors?: readonly { path: string; error: string }[], +) => ErrorDetail; // reason-oneof family (the vector's reason_field) -> the typed builder that owns it. // Covers all 7 families even though the corpus vectors only some of them today. The @@ -92,12 +98,19 @@ describe("sdk/ts ErrorDetail builders emit the sdk/go oracle wire", () => { expect(build, `no builder for ${v.reason_field}`).toBeTypeOf("function"); if (build === undefined) throw new Error(`no builder for ${v.reason_field}`); - // arity is exactly (domain, message, reason); the reason NAME string is - // validated by the generated schema at construction. - const detail = build(v.domain, v.message, v.reason_enum) as Record< - string, - unknown - >; + // Base arity is (domain, message, reason); the reason NAME string is + // validated by the generated schema at construction. registration_failure + // is the one family whose builder takes a fourth argument — the per-member + // detail its INVALID_REGISTRATION_DATA reason is useless without — so a + // vector carrying field_errors feeds them BACK to the builder instead of + // setting them post-construction. Other families' sub-fields stay + // caller-set. + const detail = build( + v.domain, + v.message, + v.reason_enum, + v.field_errors ?? undefined, + ) as Record; // metadata is set POST-construction, mirroring the Go emitter; the builder // omits it by design. if (v.metadata) detail.metadata = v.metadata; diff --git a/sdk/ts/tests/errordetail.parity.test.ts b/sdk/ts/tests/errordetail.parity.test.ts index aff3d5ec..a04290b2 100644 --- a/sdk/ts/tests/errordetail.parity.test.ts +++ b/sdk/ts/tests/errordetail.parity.test.ts @@ -32,6 +32,7 @@ type ErrorDetailVector = { metadata: Record | null; reason_field: string; reason_enum: string; + field_errors: { path: string; error: string }[] | null; wire_json: unknown; }; type VectorsFile = { canonicalization: string; vectors: ErrorDetailVector[] }; @@ -66,6 +67,19 @@ describe("sdk/ts ErrorDetail reader matches the sdk/go oracle vectors", () => { expect(got?.field).toBe(v.reason_field); expect(got?.value).toBe(v.reason_enum); } + + // The RegistrationFailure per-member detail, positionally. The empty path + // is the boundary a decoder most plausibly gets wrong: proto3 omits an + // empty string, so wire_json carries an entry with no "path" key at all + // and a reader must still extract "" rather than dropping the entry. + const rf = detail.registration_failure as + | { field_errors?: { path?: string; error: string }[] } + | undefined; + const gotFieldErrors = (rf?.field_errors ?? []).map((fe) => ({ + path: fe.path ?? "", + error: fe.error, + })); + expect(gotFieldErrors).toEqual(v.field_errors ?? []); }); } diff --git a/website/src/content/docs/protocol/exchange-manifest.mdx b/website/src/content/docs/protocol/exchange-manifest.mdx index 2336d16b..95dc2170 100644 --- a/website/src/content/docs/protocol/exchange-manifest.mdx +++ b/website/src/content/docs/protocol/exchange-manifest.mdx @@ -58,6 +58,15 @@ The manifest answers: "What can this Exchange do, where are its endpoints, and w ], "supported_auth_methods": ["gnap", "oauth_dpop"], "oidc_issuer": "https://exchange.example.com", + "registration_schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "required": ["legal_name", "vat_id"], + "properties": { + "legal_name": { "type": "string", "minLength": 2 }, + "vat_id": { "type": "string", "pattern": "^[A-Z]{2}[0-9]+$" } + } + }, "contact": "ramp-integration@examplemedia.com", "terms_uri": "https://examplemedia.com/ramp-terms", "privacy_uri": "https://examplemedia.com/privacy" @@ -111,6 +120,7 @@ The Exchange's offer-signing keys are **not** carried in `ramp.json`. They live | `gnap_grant_endpoint` | string | No | GNAP grant endpoint when GNAP is supported | | `supported_profiles` | string[] | No | Domain extension profiles this Exchange conforms to (e.g., `["ramp-academic-v1", "ramp-news-v1"]`) | | `base_currency` | string | No | ISO 4217 currency code for all `unit_cost` values in offers (e.g., `"USD"`) | +| `registration_schema` | object | No | JSON Schema (draft 2020-12, max 16KB) for the `registration_data` an agent sends to `Register`. Present means this Exchange enforces it; absent means `registration_data` passes through uninspected. Must be self-contained — consumers do not resolve a remote `$ref` out of it | | `contact` | string | No | Contact email for integration inquiries | | `terms_uri` | string | No | Terms of service URL | | `privacy_uri` | string | No | Privacy policy URL | @@ -199,6 +209,7 @@ message WellKnownManifest { optional string oidc_issuer = 25; optional string gnap_grant_endpoint = 26; optional string base_currency = 27; + google.protobuf.Struct registration_schema = 29; // JSON Schema for registration_data google.protobuf.Struct ext = 15; // ... publisher-only fields (exchanges, catalog_contributors) omitted ... } diff --git a/website/src/content/docs/reference/changelog.mdx b/website/src/content/docs/reference/changelog.mdx index c6efbfb1..4fec60c8 100644 --- a/website/src/content/docs/reference/changelog.mdx +++ b/website/src/content/docs/reference/changelog.mdx @@ -5,6 +5,84 @@ description: "RAMP protocol changelog" ## Unreleased +**SDK (all 3 languages): the registration-failure builder can carry the field errors +(additive, no wire change).** `helpers.RegistrationFailureDetail` (Go), +`registration_failure_detail` (Python) and `registrationFailureDetail` (TS) now accept the +offending `registration_data` members alongside the reason — variadic in Go, an optional +trailing argument in Python and TS, so the six reasons that carry no per-member detail keep +their three-argument call. Without this 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: one place per language where the ADR-019 envelope is constructed. This is +the only `*Detail` builder that reaches past the reason enum — the schema refusal is useless +without naming what failed, whereas the sibling detail lists +(`TransactionDenial.restriction_mismatches`, `CatalogRejection.rejected_paths`) stay +caller-set after construction. + +The shared oracle gains a `registration_failure_field_errors` vector and a `field_errors` +projection, replayed on both halves in all three languages: the construct replays feed the +members back through the 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 one caught a real divergence: canonical proto-JSON omits an empty scalar, so the wire +form of a root-pointer entry has no `path` key at all, while the generated Pydantic model +defaults `path` to `""` and the generated Zod schema declares `.default("")`, both +materializing a member Go omits. Both builders now map an empty path to unpopulated, the +exact inverse of the read side normalizing an absent path to `""`. + +**Registration data becomes schema-enforceable: `WellKnownManifest.registration_schema` +(field 29) + `REGISTRATION_FAILURE_REASON_INVALID_REGISTRATION_DATA` (additive, no wire +break).** An Exchange MAY publish, in its `ramp.json`, a JSON Schema (draft 2020-12, max +16KB) describing the `registration_data` object it expects on `Register`. Publication and +enforcement are one decision: an Exchange that publishes the schema validates incoming +`registration_data` against it and refuses a non-conforming payload with the new failure +reason; an Exchange that publishes none accepts the payload uninspected and passes it to +its system of record exactly as before, so existing Exchanges stay conformant with no +change. This replaces the former unconditional contract text ("the Exchange passes it +through to its system of record without inspecting it") on the Agent Account Registration +banner and on `RegisterRequest.registration_data`, both of which now defer to the field +that owns the contract rather than restating it. + +The field carries normative safety rules, because a consumer reads this schema out of a +third party's manifest: the schema MUST be self-contained and a consumer MUST NOT resolve +a remote `$ref` out of it — doing so would turn every reader into an SSRF vector aimed at +a URL the schema's author chose — and a consumer SHOULD bound validation time and +recursion depth, since draft 2020-12 `pattern` admits regexes with catastrophic +backtracking. The 16KB cap is measured as the UTF-8 bytes of the member as served in +`ramp.json`; an oversized schema SHOULD be rejected and its local pre-check skipped rather +than truncated, which leaves the Exchange's own enforcement deciding exactly as it does +when no schema is published. These are prose, not protovalidate rules: the field is a +`Struct`, and no field-level rule can reach inside it. + +The refusal names what to fix: `RegistrationFailure` gains +`field_errors` (field 2, ≤64 items) carrying the new top-level `RegistrationFieldError` +`{path, error}`. `path` is an RFC 6901 JSON Pointer relative to `registration_data` +(`"/vat_id"`, `"/address/postal_code"`); the empty string addresses `registration_data` +itself, which is how whole-object failures (`oneOf`, `minProperties`) that belong to no +single member are reported. A free-text pair rather than a closed `kind` enum because +JSON Schema's composite keywords do not attach to any one 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 and varies across +Exchanges, clients branch on `reason` — and, 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. A machine-readable `kind` can join at field 3 later +without a wire break. + +Motivation: 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 fields a given Exchange +expects. Both now resolve against the manifest the agent already fetches to find the +Exchange's endpoint. + +*Tooling:* `RegistrationFailure` is now seeded in the corpus generator with the new reason +and an empty-path field error, so the cross-language oracle exercises the reason this +change adds and pins the empty-path accept boundary in all three languages; without the +seed the auto-filled baseline picked the first allowed reason and published a +`DOMAIN_NOT_VERIFIED` refusal carrying `field_errors` as valid — the pairing the field +comment rules out. The generator also gained valid-item construction for repeated +**message** fields (seed-or-autofill, mirroring the top-level baseline). `field_errors` is the +contract's first repeated message field carrying its own `repeated.max_items`, and the +generator previously produced only scalar list items. + **The `ver` envelope field states its contract, and the version string gets one owner (no wire change).** All 29 `ver` fields — 25 in `ramp.proto`, 4 in `admin.proto` — now name the expected value `"1.0"` and the receive-side rule. Before this, 27 of them said only diff --git a/website/src/content/docs/reference/proto-ramp.mdx b/website/src/content/docs/reference/proto-ramp.mdx index 9b9c8747..57b2ca13 100644 --- a/website/src/content/docs/reference/proto-ramp.mdx +++ b/website/src/content/docs/reference/proto-ramp.mdx @@ -258,7 +258,7 @@ A successful response means verification succeeded; a failure travels as a non-O ### RegisterRequest -Agent asks the Exchange to create its account. The caller's identity is proven by the verified request signature, never asserted in the body; the operator-defined business payload rides in `registration_data` and is passed through to the Exchange's system of record uninspected. +Agent asks the Exchange to create its account. The caller's identity is proven by the verified request signature, never asserted in the body; the operator-defined business payload rides in `registration_data`. Whether the Exchange inspects that payload follows its manifest: one publishing `WellKnownManifest.registration_schema` validates against that schema and refuses a non-conforming payload, one publishing none passes it through to its system of record uninspected. ::proto-message{name=RegisterRequest} @@ -465,6 +465,12 @@ A registration request could not be completed. ::proto-message{name=RegistrationFailure} +### RegistrationFieldError + +One `registration_data` member that failed the Exchange's published `registration_schema`, carried on `RegistrationFailure.field_errors`. + +::proto-message{name=RegistrationFieldError} + ### DisputeFailure A dispute could not be filed (distinct from `DisputeReason`, why the agent disputes, and `DisputeStatus`, an accepted dispute's lifecycle).