diff --git a/.github/workflows/proto-ci.yml b/.github/workflows/proto-ci.yml index afaacbca..4c01fd1b 100644 --- a/.github/workflows/proto-ci.yml +++ b/.github/workflows/proto-ci.yml @@ -42,4 +42,6 @@ jobs: # (informational, pre-v1). The remark docs-guard step self-skips here because # node_modules is absent; docs-ci.yml owns the docs build. - name: gating sequence (scripts/ci-local.sh) - run: ./scripts/ci-local.sh + # sdk-types-ci.yml owns the Pydantic/Zod gate (path-filtered), so skip it here + # and keep this job the proto mirror. Locally ci-local.sh runs both. + run: RAMP_CI_SKIP_SDK_TYPES=1 ./scripts/ci-local.sh diff --git a/.github/workflows/sdk-types-ci.yml b/.github/workflows/sdk-types-ci.yml new file mode 100644 index 00000000..49304967 --- /dev/null +++ b/.github/workflows/sdk-types-ci.yml @@ -0,0 +1,75 @@ +name: sdk-types-ci + +# Drift gate for the generated TYPES EXPORT (Pydantic + Zod). Regenerates from the +# proto via scripts/gen-sdk-types.sh and fails if the committed output differs — the +# same regenerate-and-diff contract as the gen/ drift gate, for the SDK types. Runs +# only when the proto, the descriptor, the generators, or the committed output change. +on: + push: + branches: [main] + paths: &paths + - 'proto/**' + - 'gen/descriptor.binpb' + - 'scripts/gen-sdk-types.sh' + - 'scripts/check-canonical.sh' + - 'scripts/sdk-types/**' + - 'conformance/**' + - 'gen/python/**' + - 'gen/ts/**' + - '.github/workflows/sdk-types-ci.yml' + pull_request: + paths: *paths + +jobs: + sdk-types: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - uses: bufbuild/buf-setup-action@v1 + with: + version: 1.66.1 + github_token: ${{ secrets.GITHUB_TOKEN }} + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: regenerate types export + run: ./scripts/gen-sdk-types.sh + + # Only the two generated files; wire/base.py is hand-written (the seam) and the + # vocab constants are generated by buf, gated separately. + - name: assert no drift + run: | + if ! git diff --exit-code -- gen/python/wire/models.py gen/ts/wire/schemas.ts; then + echo "::error::Generated types export is out of sync with the proto. Run scripts/gen-sdk-types.sh and commit gen/python/wire/models.py + gen/ts/wire/schemas.ts." + exit 1 + fi + + # Cross-language validation parity: the generated Pydantic models and Zod schemas + # must reach the SAME verdict as Go protovalidate on every case in the generated + # corpus (conformance/corpus/cases.json) — proving the proto -> JSON Schema -> + # client pipeline carries every field-level rule. The corpus is pinned to + # protovalidate by the Go conformance suite (proto-ci) and gated for drift there. + - name: Python (Pydantic) parity + run: | + python -m pip install -q "pydantic>=2.0" pytest + PYTHONPATH=gen/python pytest gen/python/tests -q + + - name: TypeScript (Zod) parity + working-directory: gen/ts + run: | + npm install + npm test + + # Canonical proto-JSON interop: each client re-serializes every valid corpus + # instance and the emission, read back through Go protojson, must decode to the + # same proto message as the original (incl. Timestamp/Duration). Reuses the + # .sdk-types-work venv + zod provisioned by gen-sdk-types.sh above. + - name: Canonical proto-JSON round-trip + run: ./scripts/check-canonical.sh diff --git a/.gitignore b/.gitignore index 695dfd0e..56d689af 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,5 @@ research-*.md # Local agent/review scratch — never ship AI-review artifacts in the protocol repo .claude/ +__pycache__/ +.sdk-types-work/ diff --git a/README.md b/README.md index a43dabda..48a8c1ce 100644 --- a/README.md +++ b/README.md @@ -35,18 +35,44 @@ A working multi-language stack — Exchange (Go), Broker (Go), Edge (TypeScript) ## SDKs +All three languages are generated from `proto/`: Go is native protobuf + Connect via +`buf generate` (it is the server/runtime); the Python and TypeScript **types exports** +— Pydantic models and Zod schemas — are generated from the same proto via JSON Schema +by `scripts/gen-sdk-types.sh` (the two real consumers, the Python MCP shim and the +TypeScript edge worker, cannot use protobuf natively). All three carry **registered +vocabulary constants** per axis (`pricingunits`, `quotametrics`, `functiontokens`, +`geographytokens`, `usertypes`) so consumers use typed constants and an +`IsRegistered`/`isRegistered`/`is_registered` membership check instead of magic +strings. The vocab is emitted from the single `(ramp.v1.vocab)` source in one pass, so +the three languages cannot drift from each other. + ### Go ```go import ( rampv1 "github.com/RAMP-Protocol/protocol/gen/go/ramp/v1" "github.com/RAMP-Protocol/protocol/gen/go/ramp/v1/rampv1connect" + "github.com/RAMP-Protocol/protocol/gen/go/vocab/pricingunits" ) ``` ### TypeScript -TypeScript message types and a Connect client are generated under [`gen/ts/`](gen/ts) (Protobuf-ES + Connect-ES); the [reference implementation](https://github.com/RAMP-Protocol/reference-implementation) shows them in use. +Zod schemas for every message are generated under [`gen/ts/wire/schemas.ts`](gen/ts/wire/schemas.ts) (validated message types; the edge worker uses them for request validation), with vocabulary constants under [`gen/ts/vocab/`](gen/ts/vocab); the [reference implementation](https://github.com/RAMP-Protocol/reference-implementation) shows them in use. + +```typescript +import { OfferSchema } from "@ramp-protocol/sdk/wire/schemas"; +import { pricingunits } from "@ramp-protocol/sdk/vocab/pricingunits"; +``` + +### Python + +Pydantic v2 models for every message (extending the hand-written `wire.base.WireModel` seam) plus vocabulary constants are generated under [`gen/python/`](gen/python) (`pip install .` from that directory; see its [README](gen/python/README.md)). + +```python +from wire.models import Offer, Pricing +from vocab import pricingunits +``` ## License diff --git a/cmd/protoc-gen-rampvocab/main.go b/cmd/protoc-gen-rampvocab/main.go index 7a79404c..bf1b86cc 100644 --- a/cmd/protoc-gen-rampvocab/main.go +++ b/cmd/protoc-gen-rampvocab/main.go @@ -1,20 +1,24 @@ // Command protoc-gen-rampvocab is a buf/protoc plugin that reads the RAMP // vocabulary options off every annotated field and enum value and emits, per -// axis, a typed Go package with one string constant per registered token, an -// All slice, and an IsRegistered membership check. +// axis and per SDK language (Go, TypeScript, Python), a typed package/module +// with one string constant per registered token, an All/ALL collection, and an +// IsRegistered/isRegistered/is_registered membership check. // -// Two options carry the tokens, one per descriptor kind: -// - (ramp.v1.vocab) — FieldOptions extension 50001, on fields whose -// axis is the field itself (Pricing.unit, Quota.metric). -// - (ramp.v1.vocab_enum) — EnumValueOptions extension 50002, on enum values -// that SELECT an axis (RestrictionKind values select -// the function / geography / user-type token lists -// carried in Restriction.permitted/prohibited). +// Two pairs of options carry the data, one pair per descriptor kind — the +// repeated tokens and the scalar generated-package name: +// - (ramp.v1.vocab) / (ramp.v1.vocab_package) — FieldOptions +// extensions 50001/50003, on fields whose axis is the field itself +// (Pricing.unit, Quota.metric). +// - (ramp.v1.vocab_enum) / (ramp.v1.vocab_enum_package) — EnumValueOptions +// extensions 50002/50004, on enum values that SELECT an axis (RestrictionKind +// values select the function / geography / user-type token lists carried in +// Restriction.permitted/prohibited). // -// The token list is authored in exactly one place — the option entries on the -// field or enum value — so the generated constants and the ingest-time -// membership check both derive from it and cannot drift. The plugin reads the -// options STRUCTURALLY (it does not parse CEL and emits no drift assertion). +// Everything — the tokens AND the target package name — is authored in exactly +// one place, the options on the field or enum value, so the generated constants, +// the membership check, and the package layout all derive from the proto and +// cannot drift. There is no hand-maintained axis→package table in this plugin. +// The plugin reads the options STRUCTURALLY (it does not parse CEL). // // A generic plugin binary does not have ramp.v1's extensions registered in its // global proto registry, so reading options through the global registry would @@ -40,31 +44,22 @@ import ( ) const ( - // vocabFieldExtNumber is FieldOptions extension 50001 — (ramp.v1.vocab). - vocabFieldExtNumber = 50001 - // vocabEnumExtNumber is EnumValueOptions extension 50002 — (ramp.v1.vocab_enum). - vocabEnumExtNumber = 50002 - - vocabFieldExtName = "ramp.v1.vocab" - vocabEnumExtName = "ramp.v1.vocab_enum" + // Token extensions — the repeated registered tokens for an axis. + vocabFieldExtNumber = 50001 // (ramp.v1.vocab) on FieldOptions + vocabEnumExtNumber = 50002 // (ramp.v1.vocab_enum) on EnumValueOptions + // Package extensions — the generated package/module name for the axis. These + // replace the former hand-maintained axis→package maps: the mapping now lives + // in the proto, next to the tokens, so adding an axis touches only the .proto + // and never this plugin. + vocabFieldPkgExtNumber = 50003 // (ramp.v1.vocab_package) on FieldOptions + vocabEnumPkgExtNumber = 50004 // (ramp.v1.vocab_enum_package) on EnumValueOptions + + vocabFieldExtName = "ramp.v1.vocab" + vocabEnumExtName = "ramp.v1.vocab_enum" + vocabFieldPkgExtName = "ramp.v1.vocab_package" + vocabEnumPkgExtName = "ramp.v1.vocab_enum_package" ) -// fieldAxisPackage maps the FULL name of a field carrying (ramp.v1.vocab) to -// its generated Go package. Keyed by full name (not the bare field name) so two -// like-named fields in different messages cannot collide onto one package. -var fieldAxisPackage = map[string]string{ - "ramp.v1.Pricing.unit": "pricingunits", - "ramp.v1.Quota.metric": "quotametrics", -} - -// enumAxisPackage maps the FULL name of an enum value carrying -// (ramp.v1.vocab_enum) to its generated Go package. -var enumAxisPackage = map[string]string{ - "ramp.v1.RESTRICTION_KIND_FUNCTION": "functiontokens", - "ramp.v1.RESTRICTION_KIND_GEOGRAPHY": "geographytokens", - "ramp.v1.RESTRICTION_KIND_USER_TYPE": "usertypes", -} - func main() { protogen.Options{}.Run(func(gen *protogen.Plugin) error { gen.SupportedFeatures = uint64(pluginpb.CodeGeneratorResponse_FEATURE_PROTO3_OPTIONAL) @@ -74,7 +69,9 @@ func main() { return fmt.Errorf("build extension resolver: %w", err) } fieldExt := findExtension(resolver, vocabFieldExtName, vocabFieldExtNumber) + fieldPkgExt := findExtension(resolver, vocabFieldPkgExtName, vocabFieldPkgExtNumber) enumExt := findExtension(resolver, vocabEnumExtName, vocabEnumExtNumber) + enumPkgExt := findExtension(resolver, vocabEnumPkgExtName, vocabEnumPkgExtNumber) if fieldExt == nil && enumExt == nil { // This request carries neither vocab extension descriptor (e.g. a // sibling module split with no vocab-bearing descriptor). Nothing @@ -88,20 +85,20 @@ func main() { } if fieldExt != nil { for _, msg := range f.Messages { - if err := genMessage(gen, fieldExt, msg); err != nil { + if err := genMessage(gen, fieldExt, fieldPkgExt, msg); err != nil { return err } } } if enumExt != nil { for _, enum := range f.Enums { - if err := genEnum(gen, enumExt, enum); err != nil { + if err := genEnum(gen, enumExt, enumPkgExt, enum); err != nil { return err } } // Enums nested in messages. for _, msg := range f.Messages { - if err := genNestedEnums(gen, enumExt, msg); err != nil { + if err := genNestedEnums(gen, enumExt, enumPkgExt, msg); err != nil { return err } } @@ -152,23 +149,26 @@ func findExtension(files *protoregistry.Files, name protoreflect.FullName, numbe return dynamicpb.NewExtensionType(found).TypeDescriptor() } -func genMessage(gen *protogen.Plugin, ext protoreflect.ExtensionTypeDescriptor, msg *protogen.Message) error { +func genMessage(gen *protogen.Plugin, tokensExt, pkgExt protoreflect.ExtensionTypeDescriptor, msg *protogen.Message) error { for _, nested := range msg.Messages { - if err := genMessage(gen, ext, nested); err != nil { + if err := genMessage(gen, tokensExt, pkgExt, nested); err != nil { return err } } for _, field := range msg.Fields { - tokens, err := readVocab(ext, field.Desc.Options()) + tokens, err := readVocabList(tokensExt, field.Desc.Options()) if err != nil { return fmt.Errorf("read (ramp.v1.vocab) on %s: %w", field.Desc.FullName(), err) } if len(tokens) == 0 { continue } - pkg, known := fieldAxisPackage[string(field.Desc.FullName())] - if !known { - return fmt.Errorf("field %s carries (ramp.v1.vocab) but is not mapped to a package; add %q to fieldAxisPackage in protoc-gen-rampvocab", field.Desc.FullName(), field.Desc.FullName()) + pkg, err := readVocabString(pkgExt, field.Desc.Options()) + if err != nil { + return fmt.Errorf("read (ramp.v1.vocab_package) on %s: %w", field.Desc.FullName(), err) + } + if pkg == "" { + return fmt.Errorf("field %s carries (ramp.v1.vocab) but no (ramp.v1.vocab_package); add the package name on the field", field.Desc.FullName()) } if err := emit(gen, pkg, string(field.Desc.FullName()), tokens); err != nil { return err @@ -177,32 +177,35 @@ func genMessage(gen *protogen.Plugin, ext protoreflect.ExtensionTypeDescriptor, return nil } -func genNestedEnums(gen *protogen.Plugin, ext protoreflect.ExtensionTypeDescriptor, msg *protogen.Message) error { +func genNestedEnums(gen *protogen.Plugin, tokensExt, pkgExt protoreflect.ExtensionTypeDescriptor, msg *protogen.Message) error { for _, enum := range msg.Enums { - if err := genEnum(gen, ext, enum); err != nil { + if err := genEnum(gen, tokensExt, pkgExt, enum); err != nil { return err } } for _, nested := range msg.Messages { - if err := genNestedEnums(gen, ext, nested); err != nil { + if err := genNestedEnums(gen, tokensExt, pkgExt, nested); err != nil { return err } } return nil } -func genEnum(gen *protogen.Plugin, ext protoreflect.ExtensionTypeDescriptor, enum *protogen.Enum) error { +func genEnum(gen *protogen.Plugin, tokensExt, pkgExt protoreflect.ExtensionTypeDescriptor, enum *protogen.Enum) error { for _, val := range enum.Values { - tokens, err := readVocab(ext, val.Desc.Options()) + tokens, err := readVocabList(tokensExt, val.Desc.Options()) if err != nil { return fmt.Errorf("read (ramp.v1.vocab_enum) on %s: %w", val.Desc.FullName(), err) } if len(tokens) == 0 { continue } - pkg, known := enumAxisPackage[string(val.Desc.FullName())] - if !known { - return fmt.Errorf("enum value %s carries (ramp.v1.vocab_enum) but is not mapped to a package; add %q to enumAxisPackage in protoc-gen-rampvocab", val.Desc.FullName(), val.Desc.FullName()) + pkg, err := readVocabString(pkgExt, val.Desc.Options()) + if err != nil { + return fmt.Errorf("read (ramp.v1.vocab_enum_package) on %s: %w", val.Desc.FullName(), err) + } + if pkg == "" { + return fmt.Errorf("enum value %s carries (ramp.v1.vocab_enum) but no (ramp.v1.vocab_enum_package); add the package name on the value", val.Desc.FullName()) } if err := emit(gen, pkg, string(val.Desc.FullName()), tokens); err != nil { return err @@ -211,34 +214,37 @@ func genEnum(gen *protogen.Plugin, ext protoreflect.ExtensionTypeDescriptor, enu return nil } -// readVocab reads the repeated-string vocab values off a descriptor's options, -// structurally, via the request-derived extension resolver. It re-parses the -// raw options bytes so the dynamic extension is recognized. opts is the -// descriptor's Options() (a *descriptorpb.FieldOptions or *EnumValueOptions); -// the extension determines which is expected. -// Returns (nil, nil) when the descriptor legitimately carries no vocab option, -// and (nil, err) when decoding fails — the two are kept distinct so a real -// resolver/marshal failure surfaces instead of masquerading as "no vocab". -func readVocab(ext protoreflect.ExtensionTypeDescriptor, opts proto.Message) ([]string, error) { - if opts == nil { - return nil, nil - } - // Re-marshal/unmarshal the options through a resolver that knows the dynamic - // vocab extension, so its bytes are decoded into the dynamic extension - // rather than left in UnknownFields. +// decodeThroughExt re-parses a descriptor's options through a resolver that knows +// the dynamic vocab extension, so its bytes are decoded into the extension rather +// than left in UnknownFields. opts is the descriptor's Options() (a +// *descriptorpb.FieldOptions or *EnumValueOptions). A generic plugin binary has +// no ramp.v1 extensions registered globally, hence this request-derived resolver. +func decodeThroughExt(ext protoreflect.ExtensionTypeDescriptor, opts proto.Message) (protoreflect.Message, error) { raw, err := proto.Marshal(opts) if err != nil { return nil, fmt.Errorf("marshal options: %w", err) } decoded := opts.ProtoReflect().New().Interface() - if err := (proto.UnmarshalOptions{ - Resolver: resolverWith(ext), - }).Unmarshal(raw, decoded); err != nil { + if err := (proto.UnmarshalOptions{Resolver: resolverWith(ext)}).Unmarshal(raw, decoded); err != nil { return nil, fmt.Errorf("unmarshal options through vocab resolver: %w", err) } + return decoded.ProtoReflect(), nil +} - val := decoded.ProtoReflect().Get(ext) - list := val.List() +// readVocabList reads the repeated-string token values for ext off opts. Returns +// (nil, nil) when the descriptor legitimately carries no such option — including +// when ext is nil (the request did not carry the extension descriptor) — and +// (nil, err) only on a real decode failure, so a resolver/marshal fault surfaces +// instead of masquerading as "no vocab". +func readVocabList(ext protoreflect.ExtensionTypeDescriptor, opts proto.Message) ([]string, error) { + if ext == nil || opts == nil { + return nil, nil + } + m, err := decodeThroughExt(ext, opts) + if err != nil { + return nil, err + } + list := m.Get(ext).List() if list.Len() == 0 { return nil, nil } @@ -249,6 +255,22 @@ func readVocab(ext protoreflect.ExtensionTypeDescriptor, opts proto.Message) ([] return tokens, nil } +// readVocabString reads the scalar string value for ext off opts — the axis's +// generated package name. Returns "" when unset or when ext is nil. +func readVocabString(ext protoreflect.ExtensionTypeDescriptor, opts proto.Message) (string, error) { + if ext == nil || opts == nil { + return "", nil + } + m, err := decodeThroughExt(ext, opts) + if err != nil { + return "", err + } + if !m.Has(ext) { + return "", nil + } + return m.Get(ext).String(), nil +} + // extResolver adapts a single ExtensionTypeDescriptor to the // protoregistry.ExtensionTypeResolver interface used by Unmarshal. type extResolver struct { @@ -273,15 +295,48 @@ func (r extResolver) FindExtensionByNumber(message protoreflect.FullName, field return nil, protoregistry.NotFound } +// langSpec describes how one target language renders a vocab axis. The shared +// core — resolver, option reading, axis maps, token extraction, collision +// detection — is language-agnostic; only these per-language bits differ. +// Emitting every language from ONE plugin pass over ONE option set is what makes +// the three SDKs unable to drift from each other (no cross-language parity check +// is needed: all are produced from the same tokens in the same pass). +type langSpec struct { + name string + filename func(pkg string) string // path relative to the plugin out dir (../gen) + identName func(token string) (string, error) + render func(g *protogen.GeneratedFile, pkg, source string, entries []constEntry) +} + +var langs = []langSpec{ + {name: "go", filename: func(p string) string { return "go/vocab/" + p + "/" + p + ".go" }, identName: constName, render: renderGo}, + {name: "ts", filename: func(p string) string { return "ts/vocab/" + p + ".ts" }, identName: constName, render: renderTS}, + {name: "python", filename: func(p string) string { return "python/vocab/" + p + ".py" }, identName: pyConstName, render: renderPy}, +} + +// emit writes the vocab package for one axis in every target language. Paths are +// relative to the plugin's out dir (../gen), so files land in gen/go/vocab, +// gen/ts/vocab, and gen/python/vocab respectively. func emit(gen *protogen.Plugin, pkg, source string, tokens []string) error { - entries, err := constEntries(tokens) - if err != nil { - return fmt.Errorf("package %s (%s): %w", pkg, source, err) + for _, l := range langs { + entries, err := constEntriesFor(tokens, l.identName) + if err != nil { + return fmt.Errorf("%s vocab package %s (%s): %w", l.name, pkg, source, err) + } + g := gen.NewGeneratedFile(l.filename(pkg), protogen.GoImportPath("")) + l.render(g, pkg, source, entries) } + return nil +} - filename := fmt.Sprintf("%s/%s.go", pkg, pkg) - g := gen.NewGeneratedFile(filename, protogen.GoImportPath("")) +// sortedByToken returns entries token-sorted, for stable membership-set output. +func sortedByToken(entries []constEntry) []constEntry { + sorted := append([]constEntry(nil), entries...) + sort.Slice(sorted, func(i, j int) bool { return sorted[i].token < sorted[j].token }) + return sorted +} +func renderGo(g *protogen.GeneratedFile, pkg, source string, entries []constEntry) { g.P("// Code generated by protoc-gen-rampvocab. DO NOT EDIT.") g.P("//") g.P("// Source vocabulary: on ", source, ".") @@ -290,16 +345,12 @@ func emit(gen *protogen.Plugin, pkg, source string, tokens []string) error { g.P() g.P("package ", pkg) g.P() - - // const block — PascalCase constant name → token string. g.P("const (") for _, e := range entries { g.P("\t", e.ident, " = ", fmt.Sprintf("%q", e.token)) } g.P(")") g.P() - - // All slice — registered tokens in declaration order. g.P("// All lists every registered token in registration order.") g.P("var All = []string{") for _, e := range entries { @@ -307,26 +358,71 @@ func emit(gen *protogen.Plugin, pkg, source string, tokens []string) error { } g.P("}") g.P() - - // registered set for O(1) membership (token-sorted for stable output). g.P("var registered = map[string]struct{}{") - sorted := append([]constEntry(nil), entries...) - sort.Slice(sorted, func(i, j int) bool { return sorted[i].token < sorted[j].token }) - for _, e := range sorted { + for _, e := range sortedByToken(entries) { g.P("\t", e.ident, ": {},") } g.P("}") g.P() - - // IsRegistered membership check. g.P("// IsRegistered reports whether s is a registered bare token. Namespaced") g.P("// (vendor:token) values are NOT registered tokens and return false.") g.P("func IsRegistered(s string) bool {") g.P("\t_, ok := registered[s]") g.P("\treturn ok") g.P("}") +} - return nil +func renderTS(g *protogen.GeneratedFile, pkg, source string, entries []constEntry) { + g.P("// Code generated by protoc-gen-rampvocab. DO NOT EDIT.") + g.P("//") + g.P("// Source vocabulary: on ", source, ".") + g.P("// The token list is authored solely in that option; these constants and") + g.P("// isRegistered derive from it and cannot drift.") + g.P() + for _, e := range entries { + g.P("export const ", e.ident, " = ", fmt.Sprintf("%q", e.token), ";") + } + g.P() + g.P("// All lists every registered token in registration order.") + g.P("export const All = [") + for _, e := range entries { + g.P(" ", e.ident, ",") + } + g.P("] as const;") + g.P() + g.P("const registered: ReadonlySet = new Set(All);") + g.P() + g.P("// isRegistered reports whether s is a registered bare token. Namespaced") + g.P("// (vendor:token) values are NOT registered tokens and return false.") + g.P("export function isRegistered(s: string): boolean {") + g.P(" return registered.has(s);") + g.P("}") +} + +func renderPy(g *protogen.GeneratedFile, pkg, source string, entries []constEntry) { + g.P("# Code generated by protoc-gen-rampvocab. DO NOT EDIT.") + g.P("#") + g.P("# Source vocabulary: on ", source, ".") + g.P("# The token list is authored solely in that option; these constants and") + g.P("# is_registered derive from it and cannot drift.") + g.P() + for _, e := range entries { + g.P(e.ident, " = ", fmt.Sprintf("%q", e.token)) + } + g.P() + g.P("# ALL lists every registered token in registration order.") + g.P("ALL = (") + for _, e := range entries { + g.P(" ", e.ident, ",") + } + g.P(")") + g.P() + g.P("_REGISTERED = frozenset(ALL)") + g.P() + g.P() + g.P("def is_registered(s: str) -> bool:") + g.P(` """Return True if s is a registered bare token (namespaced vendor:token values return False)."""`) + g.P(" return s in _REGISTERED") } // constEntry pairs a generated identifier with its source token. @@ -335,15 +431,16 @@ type constEntry struct { token string } -// constEntries converts tokens to (ident, token) pairs in declaration order, -// erroring if any token does not yield a valid identifier (constName) or if two -// tokens map to the same identifier. Codegen fails loudly rather than emitting a -// broken or silently-shadowed constant. -func constEntries(tokens []string) ([]constEntry, error) { +// constEntriesFor converts tokens to (ident, token) pairs in declaration order +// using identName, erroring if any token does not yield a valid identifier or if +// two tokens map to the same one. Codegen fails loudly rather than emitting a +// broken or silently-shadowed constant. Collision detection is per-language: two +// tokens may collide in one language's identifier scheme but not another's. +func constEntriesFor(tokens []string, identName func(string) (string, error)) ([]constEntry, error) { entries := make([]constEntry, 0, len(tokens)) byIdent := make(map[string]string, len(tokens)) for _, tok := range tokens { - id, err := constName(tok) + id, err := identName(tok) if err != nil { return nil, err } @@ -356,6 +453,12 @@ func constEntries(tokens []string) ([]constEntry, error) { return entries, nil } +// constEntries builds Go-identifier entries (used by the Go emitter and tests); +// other languages call constEntriesFor with their own identName. +func constEntries(tokens []string) ([]constEntry, error) { + return constEntriesFor(tokens, constName) +} + // reservedIdents are identifiers the emitted package already defines; a token // must not map onto one of them. var reservedIdents = map[string]bool{ @@ -411,7 +514,8 @@ func constName(token string) (string, error) { } // isExportedIdent reports whether s is a legal exported Go identifier over the -// ASCII token alphabet: an uppercase first letter, then letters/digits. +// ASCII token alphabet: an uppercase first letter, then letters/digits. The same +// rule applies to the TypeScript const names (TS uses the same PascalCase). func isExportedIdent(s string) bool { if s == "" { return false @@ -429,3 +533,65 @@ func isExportedIdent(s string) bool { } return true } + +// pyConstNameSpecial / pyReservedIdents are the Python (UPPER_SNAKE) twins of +// constNameSpecial / reservedIdents above. ALL is the emitted tuple; the +// is_registered function is lowercase, so it never collides with an UPPER_SNAKE +// constant and need not be reserved. +var pyConstNameSpecial = map[string]string{ + "*": "WORLDWIDE", + "all": "ALL_USES", // bare "ALL" would shadow the emitted ALL tuple +} +var pyReservedIdents = map[string]bool{"ALL": true} + +// pyConstName converts a vocabulary token to an UPPER_SNAKE_CASE Python constant: +// "accesses" → "ACCESSES", "units-manufactured" → "UNITS_MANUFACTURED", +// "sq-km" → "SQ_KM", "*" → "WORLDWIDE". It errors (rather than emitting broken +// Python) on a token that cannot form a valid constant — e.g. a leading digit — +// or that collides with a reserved name; the fix is a pyConstNameSpecial mapping. +func pyConstName(token string) (string, error) { + if s, ok := pyConstNameSpecial[token]; ok { + if !isUpperSnakeIdent(s) { + return "", fmt.Errorf("pyConstNameSpecial[%q] = %q is not a valid UPPER_SNAKE identifier", token, s) + } + if pyReservedIdents[s] { + return "", fmt.Errorf("pyConstNameSpecial[%q] = %q collides with a reserved identifier", token, s) + } + return s, nil + } + parts := strings.FieldsFunc(token, func(r rune) bool { + return r == '-' || r == '_' || r == '.' + }) + for i, p := range parts { + parts[i] = strings.ToUpper(p) + } + id := strings.Join(parts, "_") + if !isUpperSnakeIdent(id) { + return "", fmt.Errorf("token %q does not yield a valid Python constant (got %q); add a pyConstNameSpecial mapping", token, id) + } + if pyReservedIdents[id] { + return "", fmt.Errorf("token %q maps to reserved identifier %q; add a pyConstNameSpecial mapping", token, id) + } + return id, nil +} + +// isUpperSnakeIdent reports whether s is a legal UPPER_SNAKE Python identifier +// over the ASCII token alphabet: an uppercase first letter, then uppercase +// letters / digits / underscores. +func isUpperSnakeIdent(s string) bool { + if s == "" { + return false + } + if s[0] < 'A' || s[0] > 'Z' { + return false + } + for i := 1; i < len(s); i++ { + c := s[i] + switch { + case c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '_': + default: + return false + } + } + return true +} diff --git a/cmd/protoc-gen-rampvocab/main_test.go b/cmd/protoc-gen-rampvocab/main_test.go index 58c6d77b..f908284f 100644 --- a/cmd/protoc-gen-rampvocab/main_test.go +++ b/cmd/protoc-gen-rampvocab/main_test.go @@ -60,11 +60,51 @@ func TestConstNameSpecialValidation(t *testing.T) { } } +func TestPyConstName(t *testing.T) { + cases := []struct { + token string + want string + wantErr bool + }{ + {token: "accesses", want: "ACCESSES"}, + {token: "units-manufactured", want: "UNITS_MANUFACTURED"}, + {token: "sq-km", want: "SQ_KM"}, + {token: "news_publisher", want: "NEWS_PUBLISHER"}, + // special-cased tokens + {token: "*", want: "WORLDWIDE"}, + {token: "all", want: "ALL_USES"}, // bare ALL would shadow the emitted ALL tuple + // invalid → error + {token: "", wantErr: true}, // empty + {token: "3d-model", wantErr: true}, // leading digit + } + for _, c := range cases { + got, err := pyConstName(c.token) + if c.wantErr { + if err == nil { + t.Errorf("pyConstName(%q) = %q, want error", c.token, got) + } + continue + } + if err != nil { + t.Errorf("pyConstName(%q) unexpected error: %v", c.token, err) + continue + } + if got != c.want { + t.Errorf("pyConstName(%q) = %q, want %q", c.token, got, c.want) + } + } +} + func TestConstEntriesCollision(t *testing.T) { // "ai-train" and "ai_train" both PascalCase to "AiTrain". if _, err := constEntries([]string{"ai-train", "ai_train"}); err == nil { t.Fatal("expected collision error for ai-train / ai_train") } + // The same tokens UPPER_SNAKE to "AI_TRAIN" — the per-language collision + // check must catch it under the Python identifier scheme too. + if _, err := constEntriesFor([]string{"ai-train", "ai_train"}, pyConstName); err == nil { + t.Fatal("expected collision error for ai-train / ai_train under pyConstName") + } } func TestConstEntriesOK(t *testing.T) { diff --git a/conformance/canonical_test.go b/conformance/canonical_test.go new file mode 100644 index 00000000..05afb3a5 --- /dev/null +++ b/conformance/canonical_test.go @@ -0,0 +1,93 @@ +// Package conformance — canonical_test.go is the canonical proto-JSON interop +// gate for the generated clients. +// +// PR10's clients (Pydantic/Zod) consume and produce proto-JSON, not the binary +// wire. This asserts that a client's *output* is canonical enough for the Go +// server: each client parses a valid corpus instance, re-serializes it, and the +// re-emitted JSON — read back through Go protojson — must decode to the SAME proto +// message as the original Go-canonical JSON (proto.Equal). This is the round-trip- +// against-Go obligation, NOT a self-round-trip: it tolerates the benign encoding +// differences proto-JSON permits (int64 as number vs the canonical string, omitted +// vs explicit zero fields) because both decode identically, while catching any +// emission Go cannot ingest (e.g. money as a number into a string field, or a +// malformed timestamp). +// +// The client emissions are produced by scripts/check-canonical.sh (Pydantic + +// Zod) and handed in via the CANONICAL_PY / CANONICAL_TS env vars; the test skips +// when they are absent so a plain `go test ./...` stays self-contained. +package conformance + +import ( + "encoding/json" + "os" + "testing" + + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protoreflect" + "google.golang.org/protobuf/reflect/protoregistry" +) + +type emittedCase struct { + ID string `json:"id"` + Message string `json:"message"` + JSON json.RawMessage `json:"json"` +} + +func TestCanonicalRoundTrip(t *testing.T) { + corpus := map[string]json.RawMessage{} + for _, c := range loadCorpus(t) { + corpus[c.ID] = c.JSON + } + for _, lang := range []struct{ env, label string }{ + {"CANONICAL_PY", "Pydantic"}, + {"CANONICAL_TS", "Zod"}, + } { + t.Run(lang.label, func(t *testing.T) { + path := os.Getenv(lang.env) + if path == "" { + t.Skipf("set %s to the %s round-trip emission (scripts/check-canonical.sh)", lang.env, lang.label) + } + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + var emitted []emittedCase + if err := json.Unmarshal(b, &emitted); err != nil { + t.Fatalf("parse %s: %v", path, err) + } + if len(emitted) == 0 { + t.Fatalf("%s emission is empty", lang.label) + } + for _, e := range emitted { + t.Run(e.ID, func(t *testing.T) { + orig, ok := corpus[e.ID] + if !ok { + t.Fatalf("emission references unknown corpus id %q", e.ID) + } + mOrig := newMessage(t, e.Message) + if err := protojson.Unmarshal(orig, mOrig); err != nil { + t.Fatalf("unmarshal original: %v", err) + } + mClient := newMessage(t, e.Message) + if err := protojson.Unmarshal(e.JSON, mClient); err != nil { + t.Errorf("%s emission is not ingestible by Go protojson: %v\n emitted=%s", e.Message, err, e.JSON) + return + } + if !proto.Equal(mOrig, mClient) { + t.Errorf("round-trip changed the message.\n original=%s\n emitted =%s", orig, e.JSON) + } + }) + } + }) + } +} + +func newMessage(t *testing.T, short string) proto.Message { + t.Helper() + mt, err := protoregistry.GlobalTypes.FindMessageByName(protoreflect.FullName("ramp.v1." + short)) + if err != nil { + t.Fatalf("unknown message ramp.v1.%s: %v", short, err) + } + return mt.New().Interface() +} diff --git a/conformance/corpus/cases.json b/conformance/corpus/cases.json new file mode 100644 index 00000000..e62b2328 --- /dev/null +++ b/conformance/corpus/cases.json @@ -0,0 +1,2829 @@ +[ + { + "id": "AcceptableRestriction/valid", + "message": "AcceptableRestriction", + "valid": true, + "json": { + "axis": "RESTRICTION_KIND_FUNCTION", + "values": [ + "ai-train" + ] + } + }, + { + "id": "AcceptableRestriction/values/item_pattern", + "message": "AcceptableRestriction", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "axis": "RESTRICTION_KIND_FUNCTION", + "values": [ + "ai-train", + "two words" + ] + } + }, + { + "id": "AcceptableRestriction/values/item_too_long", + "message": "AcceptableRestriction", + "valid": false, + "rules": [ + "string.max_len" + ], + "json": { + "axis": "RESTRICTION_KIND_FUNCTION", + "values": [ + "ai-train", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ] + } + }, + { + "id": "AcceptableRestriction/values/item_too_short", + "message": "AcceptableRestriction", + "valid": false, + "rules": [ + "string.min_len", + "string.pattern" + ], + "json": { + "axis": "RESTRICTION_KIND_FUNCTION", + "values": [ + "ai-train", + "" + ] + } + }, + { + "id": "AcceptableRestriction/values/too_many", + "message": "AcceptableRestriction", + "valid": false, + "rules": [ + "repeated.max_items" + ], + "json": { + "axis": "RESTRICTION_KIND_FUNCTION", + "values": [ + "ai-train", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x" + ] + } + }, + { + "id": "AuthorizedExchange/relationship/not_in", + "message": "AuthorizedExchange", + "valid": false, + "rules": [ + "enum.not_in" + ], + "json": {} + }, + { + "id": "AuthorizedExchange/valid", + "message": "AuthorizedExchange", + "valid": true, + "json": { + "relationship": "PROVIDER_RELATIONSHIP_DIRECT" + } + }, + { + "id": "CatalogRejection/reason/not_in", + "message": "CatalogRejection", + "valid": false, + "rules": [ + "enum.not_in" + ], + "json": {} + }, + { + "id": "CatalogRejection/reason/undefined", + "message": "CatalogRejection", + "valid": false, + "rules": [ + "enum.defined_only" + ], + "json": { + "reason": 8 + } + }, + { + "id": "CatalogRejection/valid", + "message": "CatalogRejection", + "valid": true, + "json": { + "reason": "CATALOG_REJECTION_REASON_NOT_CATALOG_CONTRIBUTOR" + } + }, + { + "id": "Cost/amount/empty_ok", + "message": "Cost", + "valid": true, + "json": { + "unit_cost": "0" + } + }, + { + "id": "Cost/amount/pattern#0", + "message": "Cost", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "amount": "two words", + "unit_cost": "0" + } + }, + { + "id": "Cost/amount/pattern#1", + "message": "Cost", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "amount": "1.2.3", + "unit_cost": "0" + } + }, + { + "id": "Cost/amount/pattern#2", + "message": "Cost", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "amount": "!!bad!!", + "unit_cost": "0" + } + }, + { + "id": "Cost/amount/pattern#3", + "message": "Cost", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "amount": "\u0000ctl\u0000", + "unit_cost": "0" + } + }, + { + "id": "Cost/amount/pattern#4", + "message": "Cost", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "amount": " ", + "unit_cost": "0" + } + }, + { + "id": "Cost/amount/pattern#5", + "message": "Cost", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "amount": "-5", + "unit_cost": "0" + } + }, + { + "id": "Cost/amount/pattern#6", + "message": "Cost", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "amount": "NaN", + "unit_cost": "0" + } + }, + { + "id": "Cost/amount/pattern#7", + "message": "Cost", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "amount": "Infinity", + "unit_cost": "0" + } + }, + { + "id": "Cost/amount/pattern#8", + "message": "Cost", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "amount": "1E3", + "unit_cost": "0" + } + }, + { + "id": "Cost/amount/too_long", + "message": "Cost", + "valid": false, + "rules": [ + "string.max_len", + "string.pattern" + ], + "json": { + "amount": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "unit_cost": "0" + } + }, + { + "id": "Cost/unit_cost/empty_ok", + "message": "Cost", + "valid": true, + "json": { + "amount": "0", + "unit_cost": "" + } + }, + { + "id": "Cost/unit_cost/pattern#0", + "message": "Cost", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "amount": "0", + "unit_cost": "two words" + } + }, + { + "id": "Cost/unit_cost/pattern#1", + "message": "Cost", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "amount": "0", + "unit_cost": "1.2.3" + } + }, + { + "id": "Cost/unit_cost/pattern#2", + "message": "Cost", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "amount": "0", + "unit_cost": "!!bad!!" + } + }, + { + "id": "Cost/unit_cost/pattern#3", + "message": "Cost", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "amount": "0", + "unit_cost": "\u0000ctl\u0000" + } + }, + { + "id": "Cost/unit_cost/pattern#4", + "message": "Cost", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "amount": "0", + "unit_cost": " " + } + }, + { + "id": "Cost/unit_cost/pattern#5", + "message": "Cost", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "amount": "0", + "unit_cost": "-5" + } + }, + { + "id": "Cost/unit_cost/pattern#6", + "message": "Cost", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "amount": "0", + "unit_cost": "NaN" + } + }, + { + "id": "Cost/unit_cost/pattern#7", + "message": "Cost", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "amount": "0", + "unit_cost": "Infinity" + } + }, + { + "id": "Cost/unit_cost/pattern#8", + "message": "Cost", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "amount": "0", + "unit_cost": "1E3" + } + }, + { + "id": "Cost/unit_cost/too_long", + "message": "Cost", + "valid": false, + "rules": [ + "string.max_len", + "string.pattern" + ], + "json": { + "amount": "0", + "unit_cost": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + }, + { + "id": "Cost/valid", + "message": "Cost", + "valid": true, + "json": { + "amount": "0", + "unit_cost": "0" + } + }, + { + "id": "DiscoveryRequest/uris/too_many", + "message": "DiscoveryRequest", + "valid": false, + "rules": [ + "repeated.max_items" + ], + "json": { + "uris": [ + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x" + ] + } + }, + { + "id": "DiscoveryRequest/valid", + "message": "DiscoveryRequest", + "valid": true, + "json": { + "uris": [ + "x" + ] + } + }, + { + "id": "DisputeFailure/reason/not_in", + "message": "DisputeFailure", + "valid": false, + "rules": [ + "enum.not_in" + ], + "json": {} + }, + { + "id": "DisputeFailure/reason/undefined", + "message": "DisputeFailure", + "valid": false, + "rules": [ + "enum.defined_only" + ], + "json": { + "reason": 6 + } + }, + { + "id": "DisputeFailure/valid", + "message": "DisputeFailure", + "valid": true, + "json": { + "reason": "DISPUTE_FAILURE_REASON_TRANSACTION_NOT_FOUND" + } + }, + { + "id": "DisputeRequest/idempotency_key/too_long", + "message": "DisputeRequest", + "valid": false, + "rules": [ + "string.max_len" + ], + "json": { + "idempotency_key": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "reason": "DISPUTE_REASON_CONTENT_MISMATCH" + } + }, + { + "id": "DisputeRequest/idempotency_key/too_short", + "message": "DisputeRequest", + "valid": false, + "rules": [ + "string.min_len" + ], + "json": { + "reason": "DISPUTE_REASON_CONTENT_MISMATCH" + } + }, + { + "id": "DisputeRequest/reason/not_in", + "message": "DisputeRequest", + "valid": false, + "rules": [ + "enum.not_in" + ], + "json": { + "idempotency_key": "idem-dr" + } + }, + { + "id": "DisputeRequest/valid", + "message": "DisputeRequest", + "valid": true, + "json": { + "idempotency_key": "idem-dr", + "reason": "DISPUTE_REASON_CONTENT_MISMATCH" + } + }, + { + "id": "DomainVerificationFailure/reason/not_in", + "message": "DomainVerificationFailure", + "valid": false, + "rules": [ + "enum.not_in" + ], + "json": {} + }, + { + "id": "DomainVerificationFailure/reason/undefined", + "message": "DomainVerificationFailure", + "valid": false, + "rules": [ + "enum.defined_only" + ], + "json": { + "reason": 7 + } + }, + { + "id": "DomainVerificationFailure/valid", + "message": "DomainVerificationFailure", + "valid": true, + "json": { + "reason": "DOMAIN_VERIFICATION_FAILURE_REASON_CHALLENGE_NOT_FOUND" + } + }, + { + "id": "License/uri_digest/empty_ok", + "message": "License", + "valid": true, + "json": { + "id": "CC-BY-4.0", + "uri_digest": "" + } + }, + { + "id": "License/uri_digest/pattern#0", + "message": "License", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "id": "CC-BY-4.0", + "uri_digest": "two words" + } + }, + { + "id": "License/uri_digest/pattern#1", + "message": "License", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "id": "CC-BY-4.0", + "uri_digest": "1.2.3" + } + }, + { + "id": "License/uri_digest/pattern#2", + "message": "License", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "id": "CC-BY-4.0", + "uri_digest": "!!bad!!" + } + }, + { + "id": "License/uri_digest/pattern#3", + "message": "License", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "id": "CC-BY-4.0", + "uri_digest": "\u0000ctl\u0000" + } + }, + { + "id": "License/uri_digest/pattern#4", + "message": "License", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "id": "CC-BY-4.0", + "uri_digest": " " + } + }, + { + "id": "License/uri_digest/pattern#5", + "message": "License", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "id": "CC-BY-4.0", + "uri_digest": "-5" + } + }, + { + "id": "License/uri_digest/pattern#6", + "message": "License", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "id": "CC-BY-4.0", + "uri_digest": "NaN" + } + }, + { + "id": "License/uri_digest/pattern#7", + "message": "License", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "id": "CC-BY-4.0", + "uri_digest": "Infinity" + } + }, + { + "id": "License/uri_digest/pattern#8", + "message": "License", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "id": "CC-BY-4.0", + "uri_digest": "1E3" + } + }, + { + "id": "License/valid", + "message": "License", + "valid": true, + "json": { + "id": "CC-BY-4.0" + } + }, + { + "id": "LicenseTerm/pricing/missing", + "message": "LicenseTerm", + "valid": false, + "rules": [ + "required" + ], + "json": { + "semantics": "TERM_SEMANTICS_ENUMERATED" + } + }, + { + "id": "LicenseTerm/scopes/too_many", + "message": "LicenseTerm", + "valid": false, + "rules": [ + "repeated.max_items" + ], + "json": { + "pricing": { + "model": "PRICING_MODEL_FREE", + "rate": "0" + }, + "scopes": [ + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x" + ], + "semantics": "TERM_SEMANTICS_ENUMERATED" + } + }, + { + "id": "LicenseTerm/semantics/not_in", + "message": "LicenseTerm", + "valid": false, + "rules": [ + "enum.not_in" + ], + "json": { + "pricing": { + "model": "PRICING_MODEL_FREE", + "rate": "0" + } + } + }, + { + "id": "LicenseTerm/valid", + "message": "LicenseTerm", + "valid": true, + "json": { + "pricing": { + "model": "PRICING_MODEL_FREE", + "rate": "0" + }, + "semantics": "TERM_SEMANTICS_ENUMERATED" + } + }, + { + "id": "Obligation/kind/not_in", + "message": "Obligation", + "valid": false, + "rules": [ + "enum.not_in" + ], + "json": { + "trigger": "OBLIGATION_TRIGGER_ON_USE" + } + }, + { + "id": "Obligation/trigger/not_in", + "message": "Obligation", + "valid": false, + "rules": [ + "enum.not_in" + ], + "json": { + "kind": "OBLIGATION_KIND_ATTRIBUTION" + } + }, + { + "id": "Obligation/valid", + "message": "Obligation", + "valid": true, + "json": { + "kind": "OBLIGATION_KIND_ATTRIBUTION", + "trigger": "OBLIGATION_TRIGGER_ON_USE" + } + }, + { + "id": "Pricing/model/not_in", + "message": "Pricing", + "valid": false, + "rules": [ + "enum.not_in" + ], + "json": { + "rate": "0" + } + }, + { + "id": "Pricing/rate/empty_ok", + "message": "Pricing", + "valid": true, + "json": { + "model": "PRICING_MODEL_FREE" + } + }, + { + "id": "Pricing/rate/pattern#0", + "message": "Pricing", + "valid": false, + "rules": [ + "pricing.free.zero_rate", + "string.pattern" + ], + "json": { + "model": "PRICING_MODEL_FREE", + "rate": "two words" + } + }, + { + "id": "Pricing/rate/pattern#1", + "message": "Pricing", + "valid": false, + "rules": [ + "pricing.free.zero_rate", + "string.pattern" + ], + "json": { + "model": "PRICING_MODEL_FREE", + "rate": "1.2.3" + } + }, + { + "id": "Pricing/rate/pattern#2", + "message": "Pricing", + "valid": false, + "rules": [ + "pricing.free.zero_rate", + "string.pattern" + ], + "json": { + "model": "PRICING_MODEL_FREE", + "rate": "!!bad!!" + } + }, + { + "id": "Pricing/rate/pattern#3", + "message": "Pricing", + "valid": false, + "rules": [ + "pricing.free.zero_rate", + "string.pattern" + ], + "json": { + "model": "PRICING_MODEL_FREE", + "rate": "\u0000ctl\u0000" + } + }, + { + "id": "Pricing/rate/pattern#4", + "message": "Pricing", + "valid": false, + "rules": [ + "pricing.free.zero_rate", + "string.pattern" + ], + "json": { + "model": "PRICING_MODEL_FREE", + "rate": " " + } + }, + { + "id": "Pricing/rate/pattern#5", + "message": "Pricing", + "valid": false, + "rules": [ + "pricing.free.zero_rate", + "string.pattern" + ], + "json": { + "model": "PRICING_MODEL_FREE", + "rate": "-5" + } + }, + { + "id": "Pricing/rate/pattern#6", + "message": "Pricing", + "valid": false, + "rules": [ + "pricing.free.zero_rate", + "string.pattern" + ], + "json": { + "model": "PRICING_MODEL_FREE", + "rate": "NaN" + } + }, + { + "id": "Pricing/rate/pattern#7", + "message": "Pricing", + "valid": false, + "rules": [ + "pricing.free.zero_rate", + "string.pattern" + ], + "json": { + "model": "PRICING_MODEL_FREE", + "rate": "Infinity" + } + }, + { + "id": "Pricing/rate/pattern#8", + "message": "Pricing", + "valid": false, + "rules": [ + "pricing.free.zero_rate", + "string.pattern" + ], + "json": { + "model": "PRICING_MODEL_FREE", + "rate": "1E3" + } + }, + { + "id": "Pricing/rate/too_long", + "message": "Pricing", + "valid": false, + "rules": [ + "pricing.free.zero_rate", + "string.max_len", + "string.pattern" + ], + "json": { + "model": "PRICING_MODEL_FREE", + "rate": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + }, + { + "id": "Pricing/unit/empty_ok", + "message": "Pricing", + "valid": true, + "json": { + "model": "PRICING_MODEL_FREE", + "rate": "0", + "unit": "" + } + }, + { + "id": "Pricing/unit/pattern#0", + "message": "Pricing", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "model": "PRICING_MODEL_FREE", + "rate": "0", + "unit": "two words" + } + }, + { + "id": "Pricing/unit/pattern#1", + "message": "Pricing", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "model": "PRICING_MODEL_FREE", + "rate": "0", + "unit": "1.2.3" + } + }, + { + "id": "Pricing/unit/pattern#2", + "message": "Pricing", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "model": "PRICING_MODEL_FREE", + "rate": "0", + "unit": "!!bad!!" + } + }, + { + "id": "Pricing/unit/pattern#3", + "message": "Pricing", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "model": "PRICING_MODEL_FREE", + "rate": "0", + "unit": "\u0000ctl\u0000" + } + }, + { + "id": "Pricing/unit/pattern#4", + "message": "Pricing", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "model": "PRICING_MODEL_FREE", + "rate": "0", + "unit": " " + } + }, + { + "id": "Pricing/unit/pattern#6", + "message": "Pricing", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "model": "PRICING_MODEL_FREE", + "rate": "0", + "unit": "NaN" + } + }, + { + "id": "Pricing/unit/pattern#7", + "message": "Pricing", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "model": "PRICING_MODEL_FREE", + "rate": "0", + "unit": "Infinity" + } + }, + { + "id": "Pricing/unit/pattern#8", + "message": "Pricing", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "model": "PRICING_MODEL_FREE", + "rate": "0", + "unit": "1E3" + } + }, + { + "id": "Pricing/unit/too_long", + "message": "Pricing", + "valid": false, + "rules": [ + "string.max_len" + ], + "json": { + "model": "PRICING_MODEL_FREE", + "rate": "0", + "unit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + }, + { + "id": "Pricing/unit_cost/empty_ok", + "message": "Pricing", + "valid": true, + "json": { + "model": "PRICING_MODEL_FREE", + "rate": "0", + "unit_cost": "" + } + }, + { + "id": "Pricing/unit_cost/pattern#0", + "message": "Pricing", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "model": "PRICING_MODEL_FREE", + "rate": "0", + "unit_cost": "two words" + } + }, + { + "id": "Pricing/unit_cost/pattern#1", + "message": "Pricing", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "model": "PRICING_MODEL_FREE", + "rate": "0", + "unit_cost": "1.2.3" + } + }, + { + "id": "Pricing/unit_cost/pattern#2", + "message": "Pricing", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "model": "PRICING_MODEL_FREE", + "rate": "0", + "unit_cost": "!!bad!!" + } + }, + { + "id": "Pricing/unit_cost/pattern#3", + "message": "Pricing", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "model": "PRICING_MODEL_FREE", + "rate": "0", + "unit_cost": "\u0000ctl\u0000" + } + }, + { + "id": "Pricing/unit_cost/pattern#4", + "message": "Pricing", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "model": "PRICING_MODEL_FREE", + "rate": "0", + "unit_cost": " " + } + }, + { + "id": "Pricing/unit_cost/pattern#5", + "message": "Pricing", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "model": "PRICING_MODEL_FREE", + "rate": "0", + "unit_cost": "-5" + } + }, + { + "id": "Pricing/unit_cost/pattern#6", + "message": "Pricing", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "model": "PRICING_MODEL_FREE", + "rate": "0", + "unit_cost": "NaN" + } + }, + { + "id": "Pricing/unit_cost/pattern#7", + "message": "Pricing", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "model": "PRICING_MODEL_FREE", + "rate": "0", + "unit_cost": "Infinity" + } + }, + { + "id": "Pricing/unit_cost/pattern#8", + "message": "Pricing", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "model": "PRICING_MODEL_FREE", + "rate": "0", + "unit_cost": "1E3" + } + }, + { + "id": "Pricing/unit_cost/too_long", + "message": "Pricing", + "valid": false, + "rules": [ + "string.max_len", + "string.pattern" + ], + "json": { + "model": "PRICING_MODEL_FREE", + "rate": "0", + "unit_cost": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + }, + { + "id": "Pricing/valid", + "message": "Pricing", + "valid": true, + "json": { + "model": "PRICING_MODEL_FREE", + "rate": "0" + } + }, + { + "id": "Quota/limit/below_min", + "message": "Quota", + "valid": false, + "rules": [ + "int64.gte" + ], + "json": { + "metric": "accesses", + "window": "QUOTA_WINDOW_DAILY" + } + }, + { + "id": "Quota/metric/missing_empty", + "message": "Quota", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "limit": "1", + "window": "QUOTA_WINDOW_DAILY" + } + }, + { + "id": "Quota/metric/pattern#0", + "message": "Quota", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "limit": "1", + "metric": "two words", + "window": "QUOTA_WINDOW_DAILY" + } + }, + { + "id": "Quota/metric/pattern#1", + "message": "Quota", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "limit": "1", + "metric": "1.2.3", + "window": "QUOTA_WINDOW_DAILY" + } + }, + { + "id": "Quota/metric/pattern#2", + "message": "Quota", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "limit": "1", + "metric": "!!bad!!", + "window": "QUOTA_WINDOW_DAILY" + } + }, + { + "id": "Quota/metric/pattern#3", + "message": "Quota", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "limit": "1", + "metric": "\u0000ctl\u0000", + "window": "QUOTA_WINDOW_DAILY" + } + }, + { + "id": "Quota/metric/pattern#4", + "message": "Quota", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "limit": "1", + "metric": " ", + "window": "QUOTA_WINDOW_DAILY" + } + }, + { + "id": "Quota/metric/pattern#6", + "message": "Quota", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "limit": "1", + "metric": "NaN", + "window": "QUOTA_WINDOW_DAILY" + } + }, + { + "id": "Quota/metric/pattern#7", + "message": "Quota", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "limit": "1", + "metric": "Infinity", + "window": "QUOTA_WINDOW_DAILY" + } + }, + { + "id": "Quota/metric/pattern#8", + "message": "Quota", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "limit": "1", + "metric": "1E3", + "window": "QUOTA_WINDOW_DAILY" + } + }, + { + "id": "Quota/metric/too_long", + "message": "Quota", + "valid": false, + "rules": [ + "string.max_len" + ], + "json": { + "limit": "1", + "metric": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "window": "QUOTA_WINDOW_DAILY" + } + }, + { + "id": "Quota/valid", + "message": "Quota", + "valid": true, + "json": { + "limit": "1", + "metric": "accesses", + "window": "QUOTA_WINDOW_DAILY" + } + }, + { + "id": "Quota/window/not_in", + "message": "Quota", + "valid": false, + "rules": [ + "enum.not_in" + ], + "json": { + "limit": "1", + "metric": "accesses" + } + }, + { + "id": "RegistrationFailure/reason/not_in", + "message": "RegistrationFailure", + "valid": false, + "rules": [ + "enum.not_in" + ], + "json": {} + }, + { + "id": "RegistrationFailure/reason/undefined", + "message": "RegistrationFailure", + "valid": false, + "rules": [ + "enum.defined_only" + ], + "json": { + "reason": 6 + } + }, + { + "id": "RegistrationFailure/valid", + "message": "RegistrationFailure", + "valid": true, + "json": { + "reason": "REGISTRATION_FAILURE_REASON_DOMAIN_NOT_VERIFIED" + } + }, + { + "id": "RequestConstraints/max_unit_cost/empty_ok", + "message": "RequestConstraints", + "valid": true, + "json": { + "budget_period": "90s", + "max_data_age": "90s", + "max_unit_cost": "" + } + }, + { + "id": "RequestConstraints/max_unit_cost/pattern#0", + "message": "RequestConstraints", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "budget_period": "90s", + "max_data_age": "90s", + "max_unit_cost": "two words" + } + }, + { + "id": "RequestConstraints/max_unit_cost/pattern#1", + "message": "RequestConstraints", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "budget_period": "90s", + "max_data_age": "90s", + "max_unit_cost": "1.2.3" + } + }, + { + "id": "RequestConstraints/max_unit_cost/pattern#2", + "message": "RequestConstraints", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "budget_period": "90s", + "max_data_age": "90s", + "max_unit_cost": "!!bad!!" + } + }, + { + "id": "RequestConstraints/max_unit_cost/pattern#3", + "message": "RequestConstraints", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "budget_period": "90s", + "max_data_age": "90s", + "max_unit_cost": "\u0000ctl\u0000" + } + }, + { + "id": "RequestConstraints/max_unit_cost/pattern#4", + "message": "RequestConstraints", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "budget_period": "90s", + "max_data_age": "90s", + "max_unit_cost": " " + } + }, + { + "id": "RequestConstraints/max_unit_cost/pattern#5", + "message": "RequestConstraints", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "budget_period": "90s", + "max_data_age": "90s", + "max_unit_cost": "-5" + } + }, + { + "id": "RequestConstraints/max_unit_cost/pattern#6", + "message": "RequestConstraints", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "budget_period": "90s", + "max_data_age": "90s", + "max_unit_cost": "NaN" + } + }, + { + "id": "RequestConstraints/max_unit_cost/pattern#7", + "message": "RequestConstraints", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "budget_period": "90s", + "max_data_age": "90s", + "max_unit_cost": "Infinity" + } + }, + { + "id": "RequestConstraints/max_unit_cost/pattern#8", + "message": "RequestConstraints", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "budget_period": "90s", + "max_data_age": "90s", + "max_unit_cost": "1E3" + } + }, + { + "id": "RequestConstraints/max_unit_cost/too_long", + "message": "RequestConstraints", + "valid": false, + "rules": [ + "string.max_len", + "string.pattern" + ], + "json": { + "budget_period": "90s", + "max_data_age": "90s", + "max_unit_cost": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + }, + { + "id": "RequestConstraints/valid", + "message": "RequestConstraints", + "valid": true, + "json": { + "budget_period": "90s", + "max_data_age": "90s", + "max_unit_cost": "0" + } + }, + { + "id": "Requester/scopes/too_many", + "message": "Requester", + "valid": false, + "rules": [ + "repeated.max_items" + ], + "json": { + "scopes": [ + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x" + ], + "type": "REQUESTER_TYPE_AGENT" + } + }, + { + "id": "Requester/type/not_in", + "message": "Requester", + "valid": false, + "rules": [ + "enum.not_in" + ], + "json": { + "scopes": [ + "x" + ] + } + }, + { + "id": "Requester/valid", + "message": "Requester", + "valid": true, + "json": { + "scopes": [ + "x" + ], + "type": "REQUESTER_TYPE_AGENT" + } + }, + { + "id": "ResourceIdentity/resource_mutability/not_in", + "message": "ResourceIdentity", + "valid": false, + "rules": [ + "enum.not_in" + ], + "json": {} + }, + { + "id": "ResourceIdentity/valid", + "message": "ResourceIdentity", + "valid": true, + "json": { + "resource_mutability": "RESOURCE_MUTABILITY_STATIC" + } + }, + { + "id": "ResourceQuery/uris/too_many", + "message": "ResourceQuery", + "valid": false, + "rules": [ + "repeated.max_items" + ], + "json": { + "deadline": "90s", + "uris": [ + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x" + ] + } + }, + { + "id": "ResourceQuery/valid", + "message": "ResourceQuery", + "valid": true, + "json": { + "deadline": "90s", + "uris": [ + "x" + ] + } + }, + { + "id": "Restriction/kind/not_in", + "message": "Restriction", + "valid": false, + "rules": [ + "enum.not_in" + ], + "json": { + "permitted": [ + "ai-input" + ] + } + }, + { + "id": "Restriction/permitted/item_pattern", + "message": "Restriction", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "kind": "RESTRICTION_KIND_FUNCTION", + "permitted": [ + "ai-input", + "two words" + ] + } + }, + { + "id": "Restriction/permitted/item_too_long", + "message": "Restriction", + "valid": false, + "rules": [ + "string.max_len" + ], + "json": { + "kind": "RESTRICTION_KIND_FUNCTION", + "permitted": [ + "ai-input", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ] + } + }, + { + "id": "Restriction/permitted/item_too_short", + "message": "Restriction", + "valid": false, + "rules": [ + "string.min_len", + "string.pattern" + ], + "json": { + "kind": "RESTRICTION_KIND_FUNCTION", + "permitted": [ + "ai-input", + "" + ] + } + }, + { + "id": "Restriction/permitted/too_many", + "message": "Restriction", + "valid": false, + "rules": [ + "repeated.max_items" + ], + "json": { + "kind": "RESTRICTION_KIND_FUNCTION", + "permitted": [ + "ai-input", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x" + ] + } + }, + { + "id": "Restriction/prohibited/item_pattern", + "message": "Restriction", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "kind": "RESTRICTION_KIND_FUNCTION", + "permitted": [ + "ai-input" + ], + "prohibited": [ + "two words" + ] + } + }, + { + "id": "Restriction/prohibited/item_too_long", + "message": "Restriction", + "valid": false, + "rules": [ + "string.max_len" + ], + "json": { + "kind": "RESTRICTION_KIND_FUNCTION", + "permitted": [ + "ai-input" + ], + "prohibited": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ] + } + }, + { + "id": "Restriction/prohibited/item_too_short", + "message": "Restriction", + "valid": false, + "rules": [ + "string.min_len", + "string.pattern" + ], + "json": { + "kind": "RESTRICTION_KIND_FUNCTION", + "permitted": [ + "ai-input" + ], + "prohibited": [ + "" + ] + } + }, + { + "id": "Restriction/prohibited/too_many", + "message": "Restriction", + "valid": false, + "rules": [ + "repeated.max_items" + ], + "json": { + "kind": "RESTRICTION_KIND_FUNCTION", + "permitted": [ + "ai-input" + ], + "prohibited": [ + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x", + "x" + ] + } + }, + { + "id": "Restriction/valid", + "message": "Restriction", + "valid": true, + "json": { + "kind": "RESTRICTION_KIND_FUNCTION", + "permitted": [ + "ai-input" + ] + } + }, + { + "id": "RetrievalAuthFailure/reason/not_in", + "message": "RetrievalAuthFailure", + "valid": false, + "rules": [ + "enum.not_in" + ], + "json": {} + }, + { + "id": "RetrievalAuthFailure/reason/undefined", + "message": "RetrievalAuthFailure", + "valid": false, + "rules": [ + "enum.defined_only" + ], + "json": { + "reason": 13 + } + }, + { + "id": "RetrievalAuthFailure/valid", + "message": "RetrievalAuthFailure", + "valid": true, + "json": { + "reason": "RETRIEVAL_AUTH_FAILURE_REASON_URL_EXPIRED" + } + }, + { + "id": "TransactionDenial/reason/not_in", + "message": "TransactionDenial", + "valid": false, + "rules": [ + "enum.not_in" + ], + "json": {} + }, + { + "id": "TransactionDenial/reason/undefined", + "message": "TransactionDenial", + "valid": false, + "rules": [ + "enum.defined_only" + ], + "json": { + "reason": 19 + } + }, + { + "id": "TransactionDenial/valid", + "message": "TransactionDenial", + "valid": true, + "json": { + "reason": "DENIAL_REASON_BILLING_REF_INACTIVE" + } + }, + { + "id": "TransactionRequest/idempotency_key/too_long", + "message": "TransactionRequest", + "valid": false, + "rules": [ + "string.max_len" + ], + "json": { + "idempotency_key": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + }, + { + "id": "TransactionRequest/idempotency_key/too_short", + "message": "TransactionRequest", + "valid": false, + "rules": [ + "string.min_len" + ], + "json": {} + }, + { + "id": "TransactionRequest/valid", + "message": "TransactionRequest", + "valid": true, + "json": { + "idempotency_key": "x" + } + }, + { + "id": "Usage/consumed_unit/empty_ok", + "message": "Usage", + "valid": true, + "json": { + "consumed_unit": "" + } + }, + { + "id": "Usage/consumed_unit/pattern#0", + "message": "Usage", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "consumed_unit": "two words" + } + }, + { + "id": "Usage/consumed_unit/pattern#1", + "message": "Usage", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "consumed_unit": "1.2.3" + } + }, + { + "id": "Usage/consumed_unit/pattern#2", + "message": "Usage", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "consumed_unit": "!!bad!!" + } + }, + { + "id": "Usage/consumed_unit/pattern#3", + "message": "Usage", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "consumed_unit": "\u0000ctl\u0000" + } + }, + { + "id": "Usage/consumed_unit/pattern#4", + "message": "Usage", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "consumed_unit": " " + } + }, + { + "id": "Usage/consumed_unit/pattern#6", + "message": "Usage", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "consumed_unit": "NaN" + } + }, + { + "id": "Usage/consumed_unit/pattern#7", + "message": "Usage", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "consumed_unit": "Infinity" + } + }, + { + "id": "Usage/consumed_unit/pattern#8", + "message": "Usage", + "valid": false, + "rules": [ + "string.pattern" + ], + "json": { + "consumed_unit": "1E3" + } + }, + { + "id": "Usage/consumed_unit/too_long", + "message": "Usage", + "valid": false, + "rules": [ + "string.max_len" + ], + "json": { + "consumed_unit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + }, + { + "id": "Usage/valid", + "message": "Usage", + "valid": true, + "json": { + "consumed_unit": "x" + } + }, + { + "id": "UsageReport/idempotency_key/too_long", + "message": "UsageReport", + "valid": false, + "rules": [ + "string.max_len" + ], + "json": { + "idempotency_key": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "timestamp": "2026-01-02T03:04:05Z" + } + }, + { + "id": "UsageReport/idempotency_key/too_short", + "message": "UsageReport", + "valid": false, + "rules": [ + "string.min_len" + ], + "json": { + "timestamp": "2026-01-02T03:04:05Z" + } + }, + { + "id": "UsageReport/valid", + "message": "UsageReport", + "valid": true, + "json": { + "idempotency_key": "x", + "timestamp": "2026-01-02T03:04:05Z" + } + }, + { + "id": "UsageReportRejection/reason/not_in", + "message": "UsageReportRejection", + "valid": false, + "rules": [ + "enum.not_in" + ], + "json": {} + }, + { + "id": "UsageReportRejection/reason/undefined", + "message": "UsageReportRejection", + "valid": false, + "rules": [ + "enum.defined_only" + ], + "json": { + "reason": 6 + } + }, + { + "id": "UsageReportRejection/valid", + "message": "UsageReportRejection", + "valid": true, + "json": { + "reason": "USAGE_REPORT_REJECTION_REASON_TRANSACTION_NOT_FOUND" + } + }, + { + "id": "WellKnownManifest/role/not_in", + "message": "WellKnownManifest", + "valid": false, + "rules": [ + "enum.not_in" + ], + "json": {} + }, + { + "id": "WellKnownManifest/valid", + "message": "WellKnownManifest", + "valid": true, + "json": { + "role": "ROLE_AGENT" + } + } +] diff --git a/conformance/corpus_coverage_test.go b/conformance/corpus_coverage_test.go new file mode 100644 index 00000000..97d493bb --- /dev/null +++ b/conformance/corpus_coverage_test.go @@ -0,0 +1,228 @@ +// Package conformance — corpus_coverage_test.go is a coverage guard over the +// GENERATED validation corpus (conformance/corpus/cases.json). It does NOT +// re-validate cases (that is corpus_test.go's job); it asserts the corpus +// EXERCISES specific field-level rule classes that the parity harness must cover. +// +// These four classes were the blind spots that let H1/M3-class bugs ship green +// (see agentic-content-access-kb1s0.9): money's divergent value space, the H1 +// positive ” accept, repeated-item length bounds, and pattern-derived +// required-presence. Each is asserted over the corpus JSON — the behavioral +// artifact the clients consume — not over corpusgen source. When corpusgen is +// updated to emit these mutants, each assertion flips to green. +package conformance + +import ( + "encoding/json" + "strings" + "testing" +) + +// moneyJSONKeys are the proto-JSON (snake_case) field names of every money-typed +// string field in the schema: Cost.amount/unit_cost, Pricing.rate/unit_cost, +// RequestConstraints.max_unit_cost. Money carries a value space (negatives, NaN, +// Infinity, scientific notation, and the accepted-empty edge) distinct from the +// generic token/hash patterns, so its coverage must be checked on these keys. +var moneyJSONKeys = map[string]bool{ + "amount": true, + "unit_cost": true, + "rate": true, + "max_unit_cost": true, +} + +// moneyKillers are values that fail ONLY the money pattern ^([0-9]+([.][0-9]+)?)?$ +// (they MATCH the token/hash patterns, so they are money-selective). A parity +// corpus that never feeds these to a money field cannot prove the clients reject +// what Go rejects for money specifically. +var moneyKillers = map[string]bool{ + "-5": true, + "NaN": true, + "Infinity": true, + "1E3": true, +} + +// moneyFieldValues walks a decoded proto-JSON value and returns every string +// value found under a money-typed key, at any nesting depth. +func moneyFieldValues(v any) []string { + var out []string + var walk func(any) + walk = func(x any) { + switch t := x.(type) { + case map[string]any: + for k, val := range t { + if s, ok := val.(string); ok && moneyJSONKeys[k] { + out = append(out, s) + } + walk(val) + } + case []any: + for _, e := range t { + walk(e) + } + } + } + walk(v) + return out +} + +// containsArrayOfStrings reports whether the decoded JSON has any array holding a +// string element (i.e. a repeated string field is materialized) at any depth. +func containsArrayOfStrings(v any) bool { + found := false + var walk func(any) + walk = func(x any) { + switch t := x.(type) { + case map[string]any: + for _, val := range t { + walk(val) + } + case []any: + for _, e := range t { + if _, ok := e.(string); ok { + found = true + } + walk(e) + } + } + } + walk(v) + return found +} + +func decodeCaseJSON(t *testing.T, raw json.RawMessage) any { + t.Helper() + var v any + if err := json.Unmarshal(raw, &v); err != nil { + t.Fatalf("decode case json: %v", err) + } + return v +} + +func ruleMatches(rules []string, substr string) bool { + for _, r := range rules { + if strings.Contains(r, substr) { + return true + } + } + return false +} + +// TestCorpusCoverage guards that the generated corpus exercises the four +// field-level rule classes M5 identified as missing. Each subtest is an +// independent coverage assertion whose failure names exactly which mutant class +// the corpus lacks. It fails NOW (corpus has 86 cases, none of these classes) and +// passes once corpusgen emits them. +func TestCorpusCoverage(t *testing.T) { + cases := loadCorpus(t) + + // Class 1 — money-specific killer values must be exercised as INVALID. + t.Run("invalid_money_killer_values", func(t *testing.T) { + for _, c := range cases { + if c.Valid { + continue + } + for _, val := range moneyFieldValues(decodeCaseJSON(t, c.JSON)) { + if moneyKillers[val] { + return // found a killer money case + } + } + } + t.Errorf("MISSING CLASS 1 (money killers): no INVALID case feeds a money field "+ + "(amount/unit_cost/rate/max_unit_cost) one of -5/NaN/Infinity/1E3; the corpus "+ + "proves the clients reject 'two words' but never the money-specific value space (%d cases scanned)", len(cases)) + }) + + // Class 2 — the H1 positive blind spot: '' must be VALID on a money field. + t.Run("valid_empty_money_value", func(t *testing.T) { + for _, c := range cases { + if !c.Valid { + continue + } + for _, val := range moneyFieldValues(decodeCaseJSON(t, c.JSON)) { + if val == "" { + return // found a valid empty-money case + } + } + } + t.Errorf("MISSING CLASS 2 (positive empty money): no VALID case has a money field "+ + "equal to \"\"; the H1 blind spot (clients wrongly rejecting accepted-empty money) "+ + "is unguarded — baselines use \"0\", never \"\" (%d cases scanned)", len(cases)) + }) + + // Class 3 — repeated string items length bounds (min_len/max_len). + t.Run("invalid_repeated_item_length", func(t *testing.T) { + for _, c := range cases { + if c.Valid { + continue + } + if !ruleMatches(c.Rules, "min_len") && !ruleMatches(c.Rules, "max_len") { + continue + } + if containsArrayOfStrings(decodeCaseJSON(t, c.JSON)) { + return // found a too_short/too_long list case + } + } + t.Errorf("MISSING CLASS 3 (repeated item length): no INVALID case pairs a "+ + "string.min_len/max_len rule with an array-of-strings value; repeated.items "+ + "min_len=1/max_len=64 (values/permitted/prohibited) are enforced in clients but "+ + "never exercised as too_short/too_long list mutants (%d cases scanned)", len(cases)) + }) + + // Class 4 — pattern-derived required-presence: Quota.metric omitted must be + // INVALID (its pattern rejects '', so omission is a violation) — beyond the + // single explicit `required` field the current 'missing' edge covers. + t.Run("invalid_pattern_required_presence", func(t *testing.T) { + for _, c := range cases { + if c.Message != "Quota" || c.Valid { + continue + } + obj, ok := decodeCaseJSON(t, c.JSON).(map[string]any) + if !ok { + continue + } + if _, present := obj["metric"]; present { + continue + } + if ruleMatches(c.Rules, "string.pattern") { + return // found Quota with metric omitted, rejected by its pattern + } + } + t.Errorf("MISSING CLASS 4 (pattern-derived required presence): no INVALID Quota case "+ + "OMITS 'metric' and trips string.pattern; the 'missing' edge fires only for the one "+ + "explicit required field, so pattern-required presence (Quota.metric) is untested (%d cases scanned)", len(cases)) + }) +} + +// TestWireIsSnakeCase locks the wire-naming decision: the RAMP wire is snake_case +// proto-JSON everywhere — the proto field names, the docs, this corpus (emitted with +// protojson UseProtoNames=true), and the generated Pydantic/Zod clients. protojson +// still ACCEPTS the camelCase json_name on input, so a stray UseProtoNames=false (in +// corpusgen or a client codec) would silently reintroduce camelCase and split the +// naming again. This fails if any object key in the corpus carries a camelCase hump. +func TestWireIsSnakeCase(t *testing.T) { + var walk func(t *testing.T, id string, v any) + walk = func(t *testing.T, id string, v any) { + switch x := v.(type) { + case map[string]any: + for k, vv := range x { + for i := 0; i+1 < len(k); i++ { + if k[i] >= 'a' && k[i] <= 'z' && k[i+1] >= 'A' && k[i+1] <= 'Z' { + t.Errorf("case %s: camelCase wire key %q — the wire is snake_case proto-JSON (check corpusgen protojson UseProtoNames=true)", id, k) + break + } + } + walk(t, id, vv) + } + case []any: + for _, e := range x { + walk(t, id, e) + } + } + } + for _, c := range loadCorpus(t) { + var v any + if err := json.Unmarshal(c.JSON, &v); err != nil { + t.Fatalf("case %s: %v", c.ID, err) + } + walk(t, c.ID, v) + } +} diff --git a/conformance/corpus_test.go b/conformance/corpus_test.go new file mode 100644 index 00000000..a9c61353 --- /dev/null +++ b/conformance/corpus_test.go @@ -0,0 +1,84 @@ +// Package conformance — corpus_test.go pins the cross-language validation corpus +// (conformance/corpus/cases.json) to Go protovalidate. +// +// The corpus is the single, generated source the Python (Pydantic) and TS (Zod) +// parity tests consume: each case is a proto-JSON instance with the verdict Go +// protovalidate — the requirement's only executable form — assigns it. This test +// re-validates every committed case so the corpus can never silently drift from +// what Go actually does; the drift gate (scripts/ci-local.sh) keeps the file +// itself in sync with `go run ./conformance/corpusgen`. +package conformance + +import ( + "encoding/json" + "os" + "testing" + + protovalidate "buf.build/go/protovalidate" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/reflect/protoreflect" + "google.golang.org/protobuf/reflect/protoregistry" +) + +type corpusCase struct { + ID string `json:"id"` + Message string `json:"message"` + Valid bool `json:"valid"` + Rules []string `json:"rules"` + JSON json.RawMessage `json:"json"` +} + +func loadCorpus(t *testing.T) []corpusCase { + t.Helper() + b, err := os.ReadFile("corpus/cases.json") + if err != nil { + t.Fatalf("read corpus (run 'go run ./conformance/corpusgen'): %v", err) + } + var cs []corpusCase + if err := json.Unmarshal(b, &cs); err != nil { + t.Fatalf("parse corpus: %v", err) + } + if len(cs) == 0 { + t.Fatal("empty corpus — corpusgen produced nothing") + } + return cs +} + +// TestCorpusMatchesProtovalidate re-validates every committed corpus case and +// asserts the recorded verdict still holds — valid cases pass, invalid cases fail +// and include the recorded rule ids. If this drifts, the cross-language parity +// tests would be measuring the clients against a stale oracle. +func TestCorpusMatchesProtovalidate(t *testing.T) { + v, err := protovalidate.New() + if err != nil { + t.Fatalf("protovalidate.New: %v", err) + } + for _, c := range loadCorpus(t) { + t.Run(c.ID, func(t *testing.T) { + mt, err := protoregistry.GlobalTypes.FindMessageByName(protoreflect.FullName("ramp.v1." + c.Message)) + if err != nil { + t.Fatalf("unknown message ramp.v1.%s: %v", c.Message, err) + } + m := mt.New().Interface() + if err := protojson.Unmarshal(c.JSON, m); err != nil { + t.Fatalf("unmarshal proto-JSON: %v", err) + } + verr := v.Validate(m) + if c.Valid { + if verr != nil { + t.Errorf("corpus marks VALID but protovalidate rejects: %v", verr) + } + return + } + ve, ok := verr.(*protovalidate.ValidationError) + if !ok { + t.Fatalf("corpus marks INVALID but protovalidate accepts (err=%v)", verr) + } + for _, r := range c.Rules { + if !violationsContain(ve, r) { + t.Errorf("corpus records rule %q but actual violations are %v", r, violationIDs(ve)) + } + } + }) + } +} diff --git a/conformance/corpusgen/main.go b/conformance/corpusgen/main.go new file mode 100644 index 00000000..7bd54c52 --- /dev/null +++ b/conformance/corpusgen/main.go @@ -0,0 +1,573 @@ +// Command corpusgen emits the cross-language validation corpus: for every +// message field that carries a protovalidate field rule, a valid baseline plus +// boundary-violating mutants (one per constraint), each rendered as proto-JSON +// with Go protovalidate's verdict recorded as the oracle. +// +// The corpus is the single, generated source the Python (Pydantic) and TS (Zod) +// parity tests consume — no rule is restated by hand. Go protovalidate is the +// requirement's only executable form, so its verdict defines each case. Scope is +// FIELD-level rules only: cross-field message CEL is server-authoritative and not +// represented here (a field mutant may incidentally also trip a message CEL — that +// is fine; the field-level violation is what the clients are expected to catch). +// +// Determinism: cases are emitted in a stable order so the committed corpus is a +// byte-exact, drift-gated artifact (regenerate: go run ./conformance/corpusgen). +package main + +import ( + "encoding/json" + "fmt" + "os" + "regexp" + "sort" + "strings" + "time" + + "buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate" + protovalidate "buf.build/go/protovalidate" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protoreflect" + "google.golang.org/protobuf/reflect/protoregistry" + "google.golang.org/protobuf/types/known/durationpb" + "google.golang.org/protobuf/types/known/timestamppb" + + rampv1 "github.com/RAMP-Protocol/protocol/gen/go/ramp/v1" +) + +// Fixed well-known-type values so the canonical proto-JSON round-trip test +// (TestCanonicalRoundTrip) actually exercises Timestamp (RFC 3339) and Duration +// encodings — the proto-JSON forms most likely to diverge across languages. Fixed, +// not time.Now(), so the corpus stays deterministic for the drift gate. +var ( + fixedTime = time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) + fixedDur = 90 * time.Second +) + +// Case is one corpus entry. Valid==true is a baseline; Valid==false is a mutant +// whose Rules are the protovalidate rule ids Go reported (the oracle verdict). +type Case struct { + ID string `json:"id"` + Message string `json:"message"` // short proto message name (== generated class/schema name) + Valid bool `json:"valid"` + Rules []string `json:"rules,omitempty"` + JSON json.RawMessage `json:"json"` +} + +// seeds are valid baseline instances for messages whose cross-field CEL (or +// required sub-message) auto-fill cannot satisfy. A seed is a valid EXAMPLE, not +// a restatement of any rule. Auto-fill handles everything else; a message that +// auto-fill cannot make valid AND has no seed fails the run loudly. +func seeds() map[string]proto.Message { + pricing := func() *rampv1.Pricing { + return &rampv1.Pricing{Model: rampv1.PricingModel_PRICING_MODEL_FREE, Rate: "0"} + } + return map[string]proto.Message{ + "Pricing": pricing(), + "License": &rampv1.License{Id: proto.String("CC-BY-4.0")}, + "Restriction": &rampv1.Restriction{Kind: rampv1.RestrictionKind_RESTRICTION_KIND_FUNCTION, Permitted: []string{"ai-input"}}, + "Obligation": &rampv1.Obligation{ + Kind: rampv1.ObligationKind_OBLIGATION_KIND_ATTRIBUTION, + Trigger: rampv1.ObligationTrigger_OBLIGATION_TRIGGER_ON_USE, + }, + "Quota": &rampv1.Quota{Metric: "accesses", Limit: 1, Window: rampv1.QuotaWindow_QUOTA_WINDOW_DAILY}, + "LicenseTerm": &rampv1.LicenseTerm{Semantics: rampv1.TermSemantics_TERM_SEMANTICS_ENUMERATED, Pricing: pricing()}, + "AcceptableRestriction": &rampv1.AcceptableRestriction{Axis: rampv1.RestrictionKind_RESTRICTION_KIND_FUNCTION, Values: []string{"ai-train"}}, + "DisputeRequest": &rampv1.DisputeRequest{IdempotencyKey: "idem-dr", Reason: rampv1.DisputeReason_DISPUTE_REASON_CONTENT_MISMATCH}, + } +} + +// stringSamples are candidate valid strings; the first that matches a field's +// pattern and length bounds becomes the auto-filled value (generic — no per-field +// table). badStrings are candidates that should FAIL a typical token/number/hash +// pattern; the first that the pattern rejects becomes the violating value. +var stringSamples = []string{"x", "ai-train", "tokens", "accesses", "0", "sha256:" + strings.Repeat("ab", 32), ""} + +// APPEND-ONLY: a badStrings entry's INDEX is baked into the emitted case IDs (see +// stringEdges' pattern# mutants), so appending keeps existing case IDs stable and +// the byte drift-gate diff purely additive. The trailing four are money-specific +// killers — numbers a naive Decimal would accept (negative / NaN / Infinity / exponent) +// but the decimal-string money pattern rejects; they are the H1 blind spot. +var badStrings = []string{"two words", "1.2.3", "!!bad!!", "\x00ctl\x00", " ", "-5", "NaN", "Infinity", "1E3"} + +func main() { + v, err := protovalidate.New() + must(err) + sd := seeds() + + var cases []Case + eachMessage(func(md protoreflect.MessageDescriptor) { + var constrained []protoreflect.FieldDescriptor + for i := 0; i < md.Fields().Len(); i++ { + fd := md.Fields().Get(i) + if fr := rules(fd); fr != nil && hasConstraint(fr) { + constrained = append(constrained, fd) + } + } + if len(constrained) == 0 { + return + } + short := string(md.Name()) + base, err := baseline(md, sd) + if err != nil { + die("baseline %s: %v", short, err) + } + if verr := v.Validate(base.Interface()); verr != nil { + die("baseline %s is not valid (add/fix a seed): %v", short, verr) + } + cases = append(cases, mkCase(short+"/valid", short, base.Interface(), true, nil, v)) + + for _, fd := range constrained { + for _, e := range edges(fd, rules(fd)) { + m := proto.Clone(base.Interface()).ProtoReflect() + e.apply(m) + verr := v.Validate(m.Interface()) + ids := ruleIDs(verr) + id := fmt.Sprintf("%s/%s/%s", short, fd.Name(), e.label) + if e.valid { + // Positive edge: Go must accept it. Proves the ACCEPT boundary + // (e.g. money "") the negative-only mutants never exercise. + if verr != nil { + die("positive edge %s expected valid, got %v", id, ids) + } + cases = append(cases, mkCase(id, short, m.Interface(), true, nil, v)) + continue + } + if verr == nil { + die("mutant %s.%s/%s did not violate any rule — edge is wrong", short, fd.Name(), e.label) + } + if !contains(ids, e.want) { + die("mutant %s.%s/%s expected rule %q, got %v", short, fd.Name(), e.label, e.want, ids) + } + cases = append(cases, mkCase(id, short, m.Interface(), false, ids, v)) + } + } + }) + + sort.Slice(cases, func(i, j int) bool { return cases[i].ID < cases[j].ID }) + out, err := json.MarshalIndent(cases, "", " ") + must(err) + must(os.WriteFile("conformance/corpus/cases.json", append(out, '\n'), 0o644)) + fmt.Printf("wrote %d cases -> conformance/corpus/cases.json\n", len(cases)) +} + +// ── baseline construction ──────────────────────────────────────────────────── + +func baseline(md protoreflect.MessageDescriptor, sd map[string]proto.Message) (protoreflect.Message, error) { + var m protoreflect.Message + if s, ok := sd[string(md.Name())]; ok { + m = proto.Clone(s).ProtoReflect() + } else { + mt, err := protoregistry.GlobalTypes.FindMessageByName(md.FullName()) + if err != nil { + return nil, err + } + m = mt.New() + for i := 0; i < md.Fields().Len(); i++ { + fd := md.Fields().Get(i) + fr := rules(fd) + if fr == nil || !hasConstraint(fr) { + continue + } + if err := setValid(m, fd, fr, sd); err != nil { + return nil, fmt.Errorf("field %s: %w", fd.Name(), err) + } + } + } + enrichWKT(m) + return m, nil +} + +// enrichWKT populates any direct Timestamp/Duration field on the baseline so the +// canonical round-trip test exercises those proto-JSON encodings. Singular, +// non-repeated, top-level fields only — enough to cover the corpus messages that +// carry them; deeper nesting is not currently exercised. +func enrichWKT(m protoreflect.Message) { + fds := m.Descriptor().Fields() + for i := 0; i < fds.Len(); i++ { + fd := fds.Get(i) + if fd.Kind() != protoreflect.MessageKind || fd.IsList() || fd.IsMap() { + continue + } + switch fd.Message().FullName() { + case "google.protobuf.Timestamp": + m.Set(fd, protoreflect.ValueOfMessage(timestamppb.New(fixedTime).ProtoReflect())) + case "google.protobuf.Duration": + m.Set(fd, protoreflect.ValueOfMessage(durationpb.New(fixedDur).ProtoReflect())) + } + } +} + +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)) + if err != nil { + return err + } + l.Append(v) + return nil + } + v, err := validScalar(fd, fr) + if err != nil { + return err + } + if fd.Kind() == protoreflect.MessageKind { + s, ok := sd[string(fd.Message().Name())] + if !ok { + return fmt.Errorf("required message field needs a seed for %s", fd.Message().Name()) + } + m.Set(fd, protoreflect.ValueOfMessage(proto.Clone(s).ProtoReflect())) + return nil + } + m.Set(fd, v) + return nil +} + +// validScalar returns a valid value for fd given its rules (enum→first allowed, +// string→first sample matching pattern+length, int64→its gte bound). +func validScalar(fd protoreflect.FieldDescriptor, fr *validate.FieldRules) (protoreflect.Value, error) { + switch fd.Kind() { + case protoreflect.EnumKind: + return protoreflect.ValueOfEnum(firstAllowedEnum(fd.Enum(), fr.GetEnum())), nil + case protoreflect.StringKind: + s, ok := validString(fr.GetString()) + if !ok { + return protoreflect.Value{}, fmt.Errorf("no sample string matches the pattern/length") + } + return protoreflect.ValueOfString(s), nil + case protoreflect.Int64Kind: + return protoreflect.ValueOfInt64(int64(gte(fr.GetInt64()))), nil + case protoreflect.MessageKind: + return protoreflect.Value{}, nil // handled by caller via seed + } + return protoreflect.Value{}, fmt.Errorf("unhandled kind %s", fd.Kind()) +} + +// ── edges (boundary-violating mutants) ─────────────────────────────────────── + +type edge struct { + label string + want string // the protovalidate rule id this edge must trip (integrity check) + apply func(m protoreflect.Message) + 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 { + 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)...) + } + switch fd.Kind() { + case protoreflect.EnumKind: + es = append(es, enumEdges(fd, fr.GetEnum())...) + case protoreflect.StringKind: + es = append(es, stringEdges(fd, fr.GetString())...) + case protoreflect.Int64Kind: + if r := fr.GetInt64(); r != nil { + if _, ok := r.GetGreaterThan().(*validate.Int64Rules_Gte); ok { + n := r.GetGte() - 1 + es = append(es, edge{label: "below_min", want: "int64.gte", apply: func(m protoreflect.Message) { + m.Set(fd, protoreflect.ValueOfInt64(n)) + }}) + } + } + } + return es +} + +func enumEdges(fd protoreflect.FieldDescriptor, r *validate.EnumRules) []edge { + var es []edge + for _, n := range r.GetNotIn() { + nn := protoreflect.EnumNumber(n) + es = append(es, edge{label: "not_in", want: "enum.not_in", apply: func(m protoreflect.Message) { + m.Set(fd, protoreflect.ValueOfEnum(nn)) + }}) + } + // Only when defined_only is set does Go reject an undefined number. With + // not_in:[0] alone, proto's open-enum forward-compat lets an unknown value + // through server-side (a newer peer's value), so no edge here. + if r.GetDefinedOnly() { + undef := undefinedEnum(fd.Enum()) + es = append(es, edge{label: "undefined", want: "enum.defined_only", apply: func(m protoreflect.Message) { + m.Set(fd, protoreflect.ValueOfEnum(undef)) + }}) + } + return es +} + +func stringEdges(fd protoreflect.FieldDescriptor, r *validate.StringRules) []edge { + var es []edge + if p := r.GetPattern(); p != "" { + // One INVALID mutant per badStrings entry the pattern rejects (option A), each + // keyed by the stable badStrings index so IDs don't shift when the list grows. + // This is where the money killers reach money fields. + for _, i := range failingBadStringIdxs(p) { + bad := badStrings[i] + es = append(es, edge{label: fmt.Sprintf("pattern#%d", i), want: "string.pattern", + apply: func(m protoreflect.Message) { m.Set(fd, protoreflect.ValueOfString(bad)) }}) + } + // The empty-string boundary: if the pattern ACCEPTS "" it is a positive case + // (money — the H1 blind spot; proves clients accept ""); if it REJECTS "" then + // the zero value is invalid, so omission must be rejected (pattern-derived + // required-presence, e.g. Quota.metric) — only meaningful for a non-optional + // field whose cleared value really is "". + if regexp.MustCompile(p).MatchString("") { + es = append(es, edge{label: "empty_ok", valid: true, + apply: func(m protoreflect.Message) { m.Set(fd, protoreflect.ValueOfString("")) }}) + } else if !fd.HasPresence() { + es = append(es, edge{label: "missing_empty", want: "string.pattern", + apply: func(m protoreflect.Message) { m.Clear(fd) }}) + } + } + if n := r.GetMinLen(); n > 0 { + s := strings.Repeat("a", int(n)-1) + es = append(es, edge{label: "too_short", want: "string.min_len", + apply: func(m protoreflect.Message) { m.Set(fd, protoreflect.ValueOfString(s)) }}) + } + if n := r.GetMaxLen(); n > 0 { + s := strings.Repeat("a", int(n)+1) + es = append(es, edge{label: "too_long", want: "string.max_len", + apply: func(m protoreflect.Message) { m.Set(fd, protoreflect.ValueOfString(s)) }}) + } + return es +} + +// failingBadStringIdxs returns the indices of badStrings the pattern rejects, in order. +// Shared by stringEdges (multi-emit) and listEdges item_pattern (first only), so the two +// call sites stay in lockstep on what "a bad string for this pattern" means. +func failingBadStringIdxs(pattern string) []int { + re := regexp.MustCompile(pattern) + var idxs []int + for i, s := range badStrings { + if !re.MatchString(s) { + idxs = append(idxs, i) + } + } + return idxs +} + +func listEdges(fd protoreflect.FieldDescriptor, fr *validate.FieldRules) []edge { + var es []edge + r := fr.GetRepeated() + item := itemRules(fr) + good, _ := validScalar(fd, item) // a valid item value + 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) { + l := m.Mutable(fd).List() + for l.Len() < n { + l.Append(good) + } + }}) + } + if item != nil { + if s := item.GetString(); s != nil { + if p := s.GetPattern(); p != "" { + if bad, ok := badString(p); ok { + es = append(es, edge{label: "item_pattern", want: "string.pattern", + apply: func(m protoreflect.Message) { m.Mutable(fd).List().Append(protoreflect.ValueOfString(bad)) }}) + } + } + if n := s.GetMinLen(); n > 0 { + bad := strings.Repeat("a", int(n)-1) + es = append(es, edge{label: "item_too_short", want: "string.min_len", + apply: func(m protoreflect.Message) { m.Mutable(fd).List().Append(protoreflect.ValueOfString(bad)) }}) + } + if n := s.GetMaxLen(); n > 0 { + bad := strings.Repeat("a", int(n)+1) + es = append(es, edge{label: "item_too_long", want: "string.max_len", + apply: func(m protoreflect.Message) { m.Mutable(fd).List().Append(protoreflect.ValueOfString(bad)) }}) + } + } + } + return es +} + +// ── rule helpers ───────────────────────────────────────────────────────────── + +func rules(fd protoreflect.FieldDescriptor) *validate.FieldRules { + fr, err := protovalidate.ResolveFieldRules(fd) + if err != nil { + return nil + } + return fr +} + +// itemRules is the per-item FieldRules of a repeated field (repeated.items). +func itemRules(fr *validate.FieldRules) *validate.FieldRules { return fr.GetRepeated().GetItems() } + +// hasConstraint reports whether fr carries a FIELD-level rule we generate edges +// for. CEL-only (cross-field) field rules are excluded — they are not client- +// enforceable and belong to the server. +func hasConstraint(fr *validate.FieldRules) bool { + if fr.GetRequired() { + return true + } + if e := fr.GetEnum(); e != nil && (e.GetDefinedOnly() || len(e.GetNotIn()) > 0) { + return true + } + if s := fr.GetString(); s != nil && (s.GetPattern() != "" || s.GetMinLen() > 0 || s.GetMaxLen() > 0) { + return true + } + if i := fr.GetInt64(); i != nil && i.GetGreaterThan() != nil { + return true + } + if r := fr.GetRepeated(); r != nil { + if r.GetMaxItems() > 0 || r.GetMinItems() > 0 { + return true + } + if it := r.GetItems(); it != nil && it.GetString().GetPattern() != "" { + return true + } + } + return false +} + +func firstAllowedEnum(ed protoreflect.EnumDescriptor, r *validate.EnumRules) protoreflect.EnumNumber { + notIn := map[int32]bool{} + for _, n := range r.GetNotIn() { + notIn[n] = true + } + vs := ed.Values() + for i := 0; i < vs.Len(); i++ { + num := vs.Get(i).Number() + name := string(vs.Get(i).Name()) + if notIn[int32(num)] || strings.HasSuffix(name, "_UNSPECIFIED") { + continue + } + return num + } + return 0 +} + +func undefinedEnum(ed protoreflect.EnumDescriptor) protoreflect.EnumNumber { + max := int32(0) + vs := ed.Values() + for i := 0; i < vs.Len(); i++ { + if int32(vs.Get(i).Number()) > max { + max = int32(vs.Get(i).Number()) + } + } + return protoreflect.EnumNumber(max + 1) +} + +func validString(r *validate.StringRules) (string, bool) { + var re *regexp.Regexp + if r != nil && r.GetPattern() != "" { + re = regexp.MustCompile(r.GetPattern()) + } + min, max := uint64(0), uint64(0) + if r != nil { + min, max = r.GetMinLen(), r.GetMaxLen() + } + for _, s := range stringSamples { + if re != nil && !re.MatchString(s) { + continue + } + if min > 0 && uint64(len(s)) < min { + continue + } + if max > 0 && uint64(len(s)) > max { + continue + } + return s, true + } + return "", false +} + +// badString returns the FIRST badStrings entry the pattern rejects — the single-emit +// form used for list items (multi-emit belongs to scalar stringEdges only, so list +// edges don't amplify). Shares failingBadStringIdxs so both sites agree on "bad". +func badString(pattern string) (string, bool) { + if idxs := failingBadStringIdxs(pattern); len(idxs) > 0 { + return badStrings[idxs[0]], true + } + return "", false +} + +func gte(r *validate.Int64Rules) int64 { + if r == nil { + return 0 + } + if x, ok := r.GetGreaterThan().(*validate.Int64Rules_Gte); ok { + return x.Gte + } + return 0 +} + +// ── output / misc ──────────────────────────────────────────────────────────── + +func mkCase(id, short string, m proto.Message, valid bool, ids []string, _ protovalidate.Validator) Case { + b, err := protojson.MarshalOptions{UseProtoNames: true}.Marshal(m) + must(err) + // re-indent to canonical form so the committed corpus is stable + var v any + must(json.Unmarshal(b, &v)) + canon, err := json.Marshal(v) + must(err) + sort.Strings(ids) + return Case{ID: id, Message: short, Valid: valid, Rules: dedupe(ids), JSON: canon} +} + +func ruleIDs(err error) []string { + verr, ok := err.(*protovalidate.ValidationError) + if !ok { + return nil + } + var ids []string + for _, vi := range verr.Violations { + ids = append(ids, vi.Proto.GetRuleId()) + } + return ids +} + +func contains(xs []string, x string) bool { + for _, s := range xs { + if s == x { + return true + } + } + return false +} + +func dedupe(xs []string) []string { + seen := map[string]bool{} + var out []string + for _, x := range xs { + if !seen[x] { + seen[x] = true + out = append(out, x) + } + } + sort.Strings(out) + return out +} + +func eachMessage(fn func(protoreflect.MessageDescriptor)) { + var walk func(protoreflect.MessageDescriptors) + walk = func(ms protoreflect.MessageDescriptors) { + for i := 0; i < ms.Len(); i++ { + md := ms.Get(i) + if !md.IsMapEntry() { + fn(md) + } + walk(md.Messages()) + } + } + walk(rampv1.File_ramp_v1_ramp_proto.Messages()) +} + +func must(err error) { + if err != nil { + die("%v", err) + } +} + +func die(f string, a ...any) { + fmt.Fprintf(os.Stderr, "corpusgen: "+f+"\n", a...) + os.Exit(1) +} diff --git a/conformance/descriptor_invariants_test.go b/conformance/descriptor_invariants_test.go index 5145d1a3..4c48c753 100644 --- a/conformance/descriptor_invariants_test.go +++ b/conformance/descriptor_invariants_test.go @@ -239,76 +239,16 @@ func messageSnake(name string) string { return b.String() } -// ─── INV-3: the namespaced-token format is written identically everywhere ──── +// ─── INV-3 removed ─────────────────────────────────────────────────────────── // -// The "bare token or vendor:namespaced token" charset is hand-copied across -// several field CELs (Pricing.unit, Quota.metric, Usage.consumed_unit). -// protovalidate has no shared-macro mechanism, so the substitute is to assert -// (3a) every such CEL uses EXACTLY the two canonical regex fragments — so the -// charset cannot drift between copies — and (3b) its human message does not -// claim registry/registration membership, which the CEL never checks. The -// selector is structural (a CEL whose regex set includes the bare-token -// fragment), so the digest format and the repeated-charset formats are excluded -// automatically and a new token field is covered the moment it is added. -const ( - tokenBareRe = "^[a-z0-9-]+$" - tokenNsRe = "^[A-Za-z0-9._-]+:[A-Za-z0-9._-]+$" -) - -var matchesArgRe = regexp.MustCompile(`matches\('([^']*)'\)`) - -func TestTokenFormatCELsAreCanonical(t *testing.T) { - seen := 0 - eachMessage(func(md protoreflect.MessageDescriptor) { - for j := 0; j < md.Fields().Len(); j++ { - fd := md.Fields().Get(j) - fr, err := protovalidate.ResolveFieldRules(fd) - if err != nil || fr == nil { - continue - } - for _, r := range fr.GetCel() { - args := matchArgs(r.GetExpression()) - if !contains(args, tokenBareRe) { - continue // not a namespaced-token format (digest / repeated-charset formats) - } - seen++ - // (3a) charset cannot drift — exactly the two canonical fragments. - if !(len(args) == 2 && contains(args, tokenNsRe)) { - t.Errorf("%s: token-format CEL must use exactly the canonical fragments {%q, %q}, got %v. "+ - "Charset drift across the token-format copies is the disease; keep them identical.", - r.GetId(), tokenBareRe, tokenNsRe, args) - } - // (3b) the message must not claim registry/registration — the CEL - // is structure-only and never checks membership. - if strings.Contains(strings.ToLower(r.GetMessage()), "register") { - t.Errorf("%s: token-format CEL message %q claims registration/registry, but the CEL only checks structure. "+ - "Drop the 'registered' wording — it misleads and diverges from the sibling messages.", - r.GetId(), r.GetMessage()) - } - } - } - }) - if seen == 0 { - t.Fatal("no token-format CELs found — the structural selector (bare-token fragment) drifted; INV-3 would be vacuous.") - } -} - -func matchArgs(expr string) []string { - var out []string - for _, m := range matchesArgRe.FindAllStringSubmatch(expr, -1) { - out = append(out, m[1]) - } - return out -} - -func contains(ss []string, s string) bool { - for _, x := range ss { - if x == s { - return true - } - } - return false -} +// The namespaced-token format is now expressed as STANDARD protovalidate +// string.pattern / repeated.items.string.pattern constraints on the fields +// (Pricing.unit, Quota.metric, Usage.consumed_unit, Restriction.permitted/ +// prohibited, AcceptableRestriction.values), not custom CEL. There is no +// token-format CEL left to keep canonical, so the old INV-3 (which asserted the +// CEL regex fragments were identical across copies) is obsolete. The standard +// patterns survive into the generated JSON Schema / Pydantic / Zod — which was +// the point of the conversion. // ─── Positive controls ─────────────────────────────────────────────────────── // @@ -354,11 +294,7 @@ func TestInvariantHelpers(t *testing.T) { } pr := (&rampv1.Pricing{}).ProtoReflect().Descriptor() if !fieldRejectsZero(pr, pr.Fields().ByName("model")) { - t.Error("fieldRejectsZero(Pricing.model) = false, want true (message-level CEL rejects zero)") - } - - if args := matchArgs("a.matches('^x$') || b.matches('y:z')"); !(len(args) == 2 && args[0] == "^x$" && args[1] == "y:z") { - t.Errorf("matchArgs returned %v, want [^x$ y:z]", args) + t.Error("fieldRejectsZero(Pricing.model) = false, want true (field-level enum not_in:[0])") } } diff --git a/conformance/docexamples_snakecase_test.go b/conformance/docexamples_snakecase_test.go new file mode 100644 index 00000000..ef367145 --- /dev/null +++ b/conformance/docexamples_snakecase_test.go @@ -0,0 +1,67 @@ +package conformance + +import ( + "regexp" + "strings" + "testing" + + "google.golang.org/protobuf/reflect/protoreflect" + "google.golang.org/protobuf/reflect/protoregistry" +) + +// camelProtoFieldNames returns the set of camelCase json_names of every RAMP proto +// field whose json_name differs from its snake_case proto name (i.e. the multiword +// fields). These are the ONLY camelCase keys that are RAMP wire fields — a doc example +// keying one of them in camelCase is a wire↔client mismatch (dropped by the snake-only +// clients; if the field is required, a hard reject). Single-word fields (amount, rate) +// and non-proto content payloads (mcpServers, companyInfo, …) are intentionally absent. +func camelProtoFieldNames(t *testing.T) map[string]bool { + t.Helper() + out := map[string]bool{} + protoregistry.GlobalFiles.RangeFiles(func(fd protoreflect.FileDescriptor) bool { + if !strings.HasPrefix(string(fd.Package()), "ramp.v1") { + return true + } + var visit func(msgs protoreflect.MessageDescriptors) + visit = func(msgs protoreflect.MessageDescriptors) { + for i := 0; i < msgs.Len(); i++ { + md := msgs.Get(i) + fields := md.Fields() + for j := 0; j < fields.Len(); j++ { + f := fields.Get(j) + if jn := f.JSONName(); jn != string(f.Name()) { + out[jn] = true + } + } + visit(md.Messages()) // nested + } + } + visit(fd.Messages()) + return true + }) + if len(out) == 0 { + t.Fatal("no ramp.v1 multiword proto fields found — are the generated types imported/registered?") + } + return out +} + +var docJSONKeyRe = regexp.MustCompile(`"([a-zA-Z_][a-zA-Z0-9_]*)"\s*:`) + +// TestDocExamplesAreSnakeCase closes the harness blind spot behind RN1: the wire is +// snake_case proto-JSON, but nothing parsed doc code blocks through the client naming, +// so camelCase RAMP field keys (e.g. required `idempotencyKey`) survived in walkthrough +// examples and are hard-rejected by the generated clients. This fails if any doc code +// fence uses the camelCase json_name of a real proto field as a JSON key. +func TestDocExamplesAreSnakeCase(t *testing.T) { + camel := camelProtoFieldNames(t) + walkDocs(t, func(path, content string) { + for _, fence := range codeFences(content) { + for _, m := range docJSONKeyRe.FindAllStringSubmatch(fence, -1) { + key := m[1] + if camel[key] { + t.Errorf("%s: doc example uses camelCase RAMP field key %q — the wire is snake_case proto-JSON; the snake-only clients drop it (and hard-reject if the field is required)", path, key) + } + } + } + }) +} diff --git a/conformance/requiredgen/main.go b/conformance/requiredgen/main.go new file mode 100644 index 00000000..af8eff46 --- /dev/null +++ b/conformance/requiredgen/main.go @@ -0,0 +1,126 @@ +// Command requiredgen emits required_fields.json: per message, the JSON field +// names whose proto ZERO value is rejected by the field's own protovalidate rule +// (an enum not_in:[0], a string min_len≥1 or a pattern that rejects "", a numeric +// gte≥1, or an explicit required). Those fields are required on the wire but the +// JSON Schema protoschema emits leaves them optional; merge_schema reads this +// manifest to mark them `required` (and drop the zero default) so the generated +// Pydantic/Zod reject omission, matching the Go server. +// +// This is the single, authoritative source of "is the zero value invalid" (the same +// Go protovalidate the conformance corpus is labeled against), consumed by +// scripts/gen-sdk-types.sh so the Python bridge does not re-implement the rule +// semantics. +package main + +import ( + "encoding/json" + "fmt" + "os" + "regexp" + "sort" + + "buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate" + protovalidate "buf.build/go/protovalidate" + "google.golang.org/protobuf/reflect/protoreflect" + + rampv1 "github.com/RAMP-Protocol/protocol/gen/go/ramp/v1" +) + +func main() { + out := "required_fields.json" + if len(os.Args) > 1 { + out = os.Args[1] + } + req := map[string][]string{} + eachMessage(func(md protoreflect.MessageDescriptor) { + var names []string + for i := 0; i < md.Fields().Len(); i++ { + fd := md.Fields().Get(i) + if zeroRejected(fd) { + names = append(names, string(fd.Name())) + } + } + if len(names) > 0 { + sort.Strings(names) + req[string(md.Name())] = names + } + }) + b, err := json.MarshalIndent(req, "", " ") + if err != nil { + panic(err) + } + if err := os.WriteFile(out, append(b, '\n'), 0o644); err != nil { + panic(err) + } +} + +// zeroRejected reports whether fd's zero/absent value is rejected by its own +// field rule. Fields with explicit presence (optional, message, oneof member) +// are exempt unless they carry an explicit `required` — protovalidate skips an +// unset presence-tracking field, so its absence is valid. +func zeroRejected(fd protoreflect.FieldDescriptor) bool { + fr, err := protovalidate.ResolveFieldRules(fd) + if err != nil || fr == nil { + return false + } + if fr.GetRequired() { + return true + } + if fd.HasPresence() { + return false + } + if e := fr.GetEnum(); e != nil { + for _, n := range e.GetNotIn() { + if n == 0 { + return true + } + } + } + if s := fr.GetString(); s != nil { + if s.GetMinLen() >= 1 { + return true + } + if p := s.GetPattern(); p != "" { + if re, err := regexp.Compile(p); err == nil && !re.MatchString("") { + return true + } + } + } + if i := fr.GetInt64(); i != nil { + switch x := i.GetGreaterThan().(type) { + case *validate.Int64Rules_Gte: + if x.Gte >= 1 { + return true + } + case *validate.Int64Rules_Gt: + if x.Gt >= 0 { + return true + } + } + } + // Loud guard: only int64 numeric rules are evaluated above (Quota.limit today). A + // rule of another numeric kind would fall through as "zero is valid" — the field + // would not be marked required and the generated clients would accept an omission + // the Go server rejects. Fail instead of silently under-marking. + if fr.GetInt32() != nil || fr.GetUint64() != nil || fr.GetUint32() != nil || + fr.GetSint64() != nil || fr.GetSint32() != nil || fr.GetFixed64() != nil || + fr.GetFixed32() != nil || fr.GetSfixed64() != nil || fr.GetSfixed32() != nil || + fr.GetFloat() != nil || fr.GetDouble() != nil { + panic(fmt.Sprintf("requiredgen: unhandled numeric rule on field %s (kind %s) — extend zeroRejected to evaluate it", fd.FullName(), fd.Kind())) + } + return false +} + +func eachMessage(fn func(protoreflect.MessageDescriptor)) { + var walk func(protoreflect.MessageDescriptors) + walk = func(ms protoreflect.MessageDescriptors) { + for i := 0; i < ms.Len(); i++ { + md := ms.Get(i) + if !md.IsMapEntry() { + fn(md) + } + walk(md.Messages()) + } + } + walk(rampv1.File_ramp_v1_ramp_proto.Messages()) +} diff --git a/conformance/validate_test.go b/conformance/validate_test.go index 26498688..e1f097c8 100644 --- a/conformance/validate_test.go +++ b/conformance/validate_test.go @@ -94,38 +94,38 @@ func licensingCases() []validationCase { // License.uri_digest — strong-hash structure only. {"uri_digest empty ok", &rampv1.License{UriDigest: proto.String("")}, true, ""}, {"uri_digest sha256 ok", &rampv1.License{UriDigest: proto.String("sha256:" + hex64)}, true, ""}, - {"uri_digest md5 rejected", &rampv1.License{UriDigest: proto.String("md5:" + hex64)}, false, "license.uri_digest.format"}, - {"uri_digest sha256 wrong length", &rampv1.License{UriDigest: proto.String("sha256:dead")}, false, "license.uri_digest.format"}, - {"uri_digest sha256 non-hex", &rampv1.License{UriDigest: proto.String("sha256:" + "g" + hex64[1:])}, false, "license.uri_digest.format"}, + {"uri_digest md5 rejected", &rampv1.License{UriDigest: proto.String("md5:" + hex64)}, false, "string.pattern"}, + {"uri_digest sha256 wrong length", &rampv1.License{UriDigest: proto.String("sha256:dead")}, false, "string.pattern"}, + {"uri_digest sha256 non-hex", &rampv1.License{UriDigest: proto.String("sha256:" + "g" + hex64[1:])}, false, "string.pattern"}, // Pricing message-level CEL: PER_UNIT⇒unit set; FREE⇒rate 0. - {"pricing per_unit with unit ok", &rampv1.Pricing{Model: rampv1.PricingModel_PRICING_MODEL_PER_UNIT, Unit: proto.String("tokens"), Currency: "USD", Rate: 0.05}, true, ""}, - {"pricing per_unit without unit rejected", &rampv1.Pricing{Model: rampv1.PricingModel_PRICING_MODEL_PER_UNIT, Currency: "USD", Rate: 0.05}, false, "pricing.per_unit.requires_unit"}, - {"pricing free zero rate ok", &rampv1.Pricing{Model: rampv1.PricingModel_PRICING_MODEL_FREE, Rate: 0}, true, ""}, - {"pricing free nonzero rate rejected", &rampv1.Pricing{Model: rampv1.PricingModel_PRICING_MODEL_FREE, Rate: 1.0}, false, "pricing.free.zero_rate"}, + {"pricing per_unit with unit ok", &rampv1.Pricing{Model: rampv1.PricingModel_PRICING_MODEL_PER_UNIT, Unit: proto.String("tokens"), Currency: "USD", Rate: "0.05"}, true, ""}, + {"pricing per_unit without unit rejected", &rampv1.Pricing{Model: rampv1.PricingModel_PRICING_MODEL_PER_UNIT, Currency: "USD", Rate: "0.05"}, false, "pricing.per_unit.requires_unit"}, + {"pricing free zero rate ok", &rampv1.Pricing{Model: rampv1.PricingModel_PRICING_MODEL_FREE, Rate: "0"}, true, ""}, + {"pricing free nonzero rate rejected", &rampv1.Pricing{Model: rampv1.PricingModel_PRICING_MODEL_FREE, Rate: "1.0"}, false, "pricing.free.zero_rate"}, // Pricing.unit format: empty / bare-dashed / vendor:namespaced. - {"pricing unit bare ok", &rampv1.Pricing{Model: rampv1.PricingModel_PRICING_MODEL_PER_UNIT, Unit: proto.String("sq-km"), Rate: 1}, true, ""}, - {"pricing unit vendor ok", &rampv1.Pricing{Model: rampv1.PricingModel_PRICING_MODEL_PER_UNIT, Unit: proto.String("acme:widgets"), Rate: 1}, true, ""}, - {"pricing unit with space rejected", &rampv1.Pricing{Model: rampv1.PricingModel_PRICING_MODEL_PER_UNIT, Unit: proto.String("two words"), Rate: 1}, false, "pricing.unit.format"}, + {"pricing unit bare ok", &rampv1.Pricing{Model: rampv1.PricingModel_PRICING_MODEL_PER_UNIT, Unit: proto.String("sq-km"), Rate: "1"}, true, ""}, + {"pricing unit vendor ok", &rampv1.Pricing{Model: rampv1.PricingModel_PRICING_MODEL_PER_UNIT, Unit: proto.String("acme:widgets"), Rate: "1"}, true, ""}, + {"pricing unit with space rejected", &rampv1.Pricing{Model: rampv1.PricingModel_PRICING_MODEL_PER_UNIT, Unit: proto.String("two words"), Rate: "1"}, false, "string.pattern"}, // AcceptableRestriction.values charset + max_items. {"acceptable values ok", &rampv1.AcceptableRestriction{Axis: rampv1.RestrictionKind_RESTRICTION_KIND_FUNCTION, Values: []string{"ai-train", "ai-input"}}, true, ""}, - {"acceptable values space rejected", &rampv1.AcceptableRestriction{Axis: rampv1.RestrictionKind_RESTRICTION_KIND_FUNCTION, Values: []string{"ai train"}}, false, "acceptable_restriction.values.format"}, + {"acceptable values space rejected", &rampv1.AcceptableRestriction{Axis: rampv1.RestrictionKind_RESTRICTION_KIND_FUNCTION, Values: []string{"ai train"}}, false, "string.pattern"}, {"acceptable values too many rejected", &rampv1.AcceptableRestriction{Axis: rampv1.RestrictionKind_RESTRICTION_KIND_FUNCTION, Values: gen65()}, false, "repeated.max_items"}, // Restriction.permitted/prohibited charset. {"restriction permitted ok", &rampv1.Restriction{Kind: rampv1.RestrictionKind_RESTRICTION_KIND_FUNCTION, Permitted: []string{"ai-input"}}, true, ""}, - {"restriction permitted control-char rejected", &rampv1.Restriction{Kind: rampv1.RestrictionKind_RESTRICTION_KIND_FUNCTION, Permitted: []string{"bad\ttoken"}}, false, "restriction.permitted.format"}, + {"restriction permitted control-char rejected", &rampv1.Restriction{Kind: rampv1.RestrictionKind_RESTRICTION_KIND_FUNCTION, Permitted: []string{"bad\ttoken"}}, false, "string.pattern"}, {"restriction prohibited ok", &rampv1.Restriction{Kind: rampv1.RestrictionKind_RESTRICTION_KIND_FUNCTION, Prohibited: []string{"ai-train"}}, true, ""}, - {"restriction prohibited space rejected", &rampv1.Restriction{Kind: rampv1.RestrictionKind_RESTRICTION_KIND_FUNCTION, Prohibited: []string{"ai train"}}, false, "restriction.prohibited.format"}, + {"restriction prohibited space rejected", &rampv1.Restriction{Kind: rampv1.RestrictionKind_RESTRICTION_KIND_FUNCTION, Prohibited: []string{"ai train"}}, false, "string.pattern"}, // Quota.metric format — bare-dashed or vendor:namespaced; empty rejected. // window set so the only variable under test is metric. {"quota metric bare ok", &rampv1.Quota{Metric: "display-words", Limit: 1, Window: rampv1.QuotaWindow_QUOTA_WINDOW_DAILY}, true, ""}, {"quota metric vendor ok", &rampv1.Quota{Metric: "acme:frames", Limit: 1, Window: rampv1.QuotaWindow_QUOTA_WINDOW_DAILY}, true, ""}, - {"quota metric empty rejected", &rampv1.Quota{Metric: "", Limit: 1, Window: rampv1.QuotaWindow_QUOTA_WINDOW_DAILY}, false, "quota.metric.format"}, - {"quota metric space rejected", &rampv1.Quota{Metric: "two words", Limit: 1, Window: rampv1.QuotaWindow_QUOTA_WINDOW_DAILY}, false, "quota.metric.format"}, + {"quota metric empty rejected", &rampv1.Quota{Metric: "", Limit: 1, Window: rampv1.QuotaWindow_QUOTA_WINDOW_DAILY}, false, "string.pattern"}, + {"quota metric space rejected", &rampv1.Quota{Metric: "two words", Limit: 1, Window: rampv1.QuotaWindow_QUOTA_WINDOW_DAILY}, false, "string.pattern"}, // License.uri present requires uri_digest (any semantics). {"license no uri ok", &rampv1.License{Id: proto.String("CC-BY-4.0")}, true, ""}, @@ -153,30 +153,30 @@ func licensingCases() []validationCase { // Required-enum discriminators — UNSPECIFIED (zero) is never a valid value. // These guard the gap where the conditional coherence CELs above are // vacuously satisfied by an unset discriminator. - {"term semantics unspecified rejected", &rampv1.LicenseTerm{Pricing: freePricing()}, false, "license_term.semantics_specified"}, - {"pricing model unspecified rejected", &rampv1.Pricing{Rate: 0}, false, "pricing.model_specified"}, - {"restriction kind unspecified rejected", &rampv1.Restriction{Permitted: []string{"ai-input"}}, false, "restriction.kind_specified"}, - {"obligation kind unspecified rejected", &rampv1.Obligation{Trigger: rampv1.ObligationTrigger_OBLIGATION_TRIGGER_ON_USE}, false, "obligation.kind_specified"}, - {"quota window unspecified rejected", &rampv1.Quota{Metric: "accesses", Limit: 1}, false, "quota.window_specified"}, - {"obligation trigger unspecified rejected", &rampv1.Obligation{Kind: rampv1.ObligationKind_OBLIGATION_KIND_ATTRIBUTION}, false, "obligation.trigger_specified"}, + {"term semantics unspecified rejected", &rampv1.LicenseTerm{Pricing: freePricing()}, false, "enum.not_in"}, + {"pricing model unspecified rejected", &rampv1.Pricing{Rate: "0"}, false, "enum.not_in"}, + {"restriction kind unspecified rejected", &rampv1.Restriction{Permitted: []string{"ai-input"}}, false, "enum.not_in"}, + {"obligation kind unspecified rejected", &rampv1.Obligation{Trigger: rampv1.ObligationTrigger_OBLIGATION_TRIGGER_ON_USE}, false, "enum.not_in"}, + {"quota window unspecified rejected", &rampv1.Quota{Metric: "accesses", Limit: 1}, false, "enum.not_in"}, + {"obligation trigger unspecified rejected", &rampv1.Obligation{Kind: rampv1.ObligationKind_OBLIGATION_KIND_ATTRIBUTION}, false, "enum.not_in"}, // Discriminator + format CELs on messages OUTSIDE the licensing core. The // rules are identical in shape to the ones above; covering them here keeps // TestCELRuleCoverage's completeness assertion green for the whole proto, // not just the licensing subtree. {"authorized_exchange relationship set ok", &rampv1.AuthorizedExchange{Relationship: rampv1.ProviderRelationship_PROVIDER_RELATIONSHIP_DIRECT}, true, ""}, - {"authorized_exchange relationship unspecified rejected", &rampv1.AuthorizedExchange{}, false, "authorized_exchange.relationship_specified"}, + {"authorized_exchange relationship unspecified rejected", &rampv1.AuthorizedExchange{}, false, "enum.not_in"}, {"requester type set ok", &rampv1.Requester{Type: rampv1.RequesterType_REQUESTER_TYPE_AGENT}, true, ""}, - {"requester type unspecified rejected", &rampv1.Requester{}, false, "requester.type_specified"}, + {"requester type unspecified rejected", &rampv1.Requester{}, false, "enum.not_in"}, {"resource_identity mutability set ok", &rampv1.ResourceIdentity{ResourceMutability: rampv1.ResourceMutability_RESOURCE_MUTABILITY_STATIC}, true, ""}, - {"resource_identity mutability unspecified rejected", &rampv1.ResourceIdentity{}, false, "resource_identity.resource_mutability_specified"}, + {"resource_identity mutability unspecified rejected", &rampv1.ResourceIdentity{}, false, "enum.not_in"}, {"well_known_manifest role set ok", &rampv1.WellKnownManifest{Role: rampv1.Role_ROLE_AGENT}, true, ""}, - {"well_known_manifest role unspecified rejected", &rampv1.WellKnownManifest{}, false, "well_known_manifest.role_specified"}, + {"well_known_manifest role unspecified rejected", &rampv1.WellKnownManifest{}, false, "enum.not_in"}, {"dispute_request reason set ok", &rampv1.DisputeRequest{IdempotencyKey: "idem-dr-x", Reason: rampv1.DisputeReason_DISPUTE_REASON_CONTENT_MISMATCH}, true, ""}, - {"dispute_request reason unspecified rejected", &rampv1.DisputeRequest{IdempotencyKey: "idem-dr-x"}, false, "dispute_request.reason_specified"}, + {"dispute_request reason unspecified rejected", &rampv1.DisputeRequest{IdempotencyKey: "idem-dr-x"}, false, "enum.not_in"}, {"usage consumed_unit empty ok", &rampv1.Usage{}, true, ""}, {"usage consumed_unit bare ok", &rampv1.Usage{ConsumedUnit: proto.String("tokens")}, true, ""}, - {"usage consumed_unit space rejected", &rampv1.Usage{ConsumedUnit: proto.String("two words")}, false, "usage.consumed_unit.format"}, + {"usage consumed_unit space rejected", &rampv1.Usage{ConsumedUnit: proto.String("two words")}, false, "string.pattern"}, } } @@ -200,7 +200,7 @@ func idempotencyCases() []validationCase { } func freePricing() *rampv1.Pricing { - return &rampv1.Pricing{Model: rampv1.PricingModel_PRICING_MODEL_FREE, Rate: 0} + return &rampv1.Pricing{Model: rampv1.PricingModel_PRICING_MODEL_FREE, Rate: "0"} } func gen65() []string { @@ -256,6 +256,7 @@ func errorDetailCases() []validationCase { // listing them lets the integrity check reject a mistyped wantRule that is // neither a real custom CEL id nor a known standard rule. var standardRuleIDs = map[string]bool{ + "string.pattern": true, "required": true, "repeated.max_items": true, "int64.gte": true, diff --git a/conformance/vocab_parity_test.go b/conformance/vocab_parity_test.go new file mode 100644 index 00000000..5e987e8b --- /dev/null +++ b/conformance/vocab_parity_test.go @@ -0,0 +1,80 @@ +// Package conformance — vocab_parity_test.go asserts the generated vocabulary +// CONSTANTS agree across languages. +// +// The per-file drift gate proves each generated vocab file matches its own +// generator's output; it never proves the THREE generators (Go, Python, TS +// rampvocab emitters) agree with each other. A bug in one emitter — a dropped or +// mistyped token in only the Zod or Pydantic output — passes the drift gate but +// would hand consumers a different registry per language. This test reads the +// token sets straight from the three generated files and requires them identical, +// per axis. Axes are discovered from gen/go/vocab (opt-out: a new axis is covered +// the moment its package is generated). +package conformance + +import ( + "os" + "path/filepath" + "regexp" + "sort" + "testing" +) + +// Token VALUES are the quoted string literals in each language's const block. +// The `All`/`registered` collections reference those consts by name (no quotes), +// so matching the ` = ""` definition lines captures each token once. +var ( + goToken = regexp.MustCompile(`(?m)^\s*[A-Za-z0-9_]+\s*=\s*"([^"]+)"`) + pyToken = regexp.MustCompile(`(?m)^[A-Z0-9_]+ = "([^"]+)"`) + tsToken = regexp.MustCompile(`(?m)^export const \w+ = "([^"]+)";`) +) + +func extractTokens(t *testing.T, path string, re *regexp.Regexp) []string { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + var out []string + for _, m := range re.FindAllStringSubmatch(string(b), -1) { + out = append(out, m[1]) + } + sort.Strings(out) + return out +} + +func TestVocabConstantsParity(t *testing.T) { + const root = ".." // conformance/ -> repo root + axisDirs, err := filepath.Glob(filepath.Join(root, "gen", "go", "vocab", "*")) + if err != nil || len(axisDirs) == 0 { + t.Fatalf("no vocab axes under gen/go/vocab (glob err=%v) — discovery drifted", err) + } + for _, dir := range axisDirs { + axis := filepath.Base(dir) + t.Run(axis, func(t *testing.T) { + goTokens := extractTokens(t, filepath.Join(root, "gen", "go", "vocab", axis, axis+".go"), goToken) + pyTokens := extractTokens(t, filepath.Join(root, "gen", "python", "vocab", axis+".py"), pyToken) + tsTokens := extractTokens(t, filepath.Join(root, "gen", "ts", "vocab", axis+".ts"), tsToken) + if len(goTokens) == 0 { + t.Fatalf("%s: no tokens extracted from the Go package — extraction drifted", axis) + } + if !equalStrings(goTokens, pyTokens) { + t.Errorf("%s: Python tokens differ from Go.\n go=%v\n py=%v", axis, goTokens, pyTokens) + } + if !equalStrings(goTokens, tsTokens) { + t.Errorf("%s: TS tokens differ from Go.\n go=%v\n ts=%v", axis, goTokens, tsTokens) + } + }) + } +} + +func equalStrings(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/gen/descriptor.binpb b/gen/descriptor.binpb index fca2ed1d..8664a602 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 12b7d414..5d4f0e95 100644 --- a/gen/go/ramp/v1/ramp.pb.go +++ b/gen/go/ramp/v1/ramp.pb.go @@ -6,6 +6,13 @@ // // The ExchangeService is the core protocol. Both AI agents and // Brokers are valid clients — the Exchange doesn't distinguish. +// +// Wire format: the canonical wire is snake_case proto-JSON — the field names as +// declared here (idempotency_key, unit_cost), used by the generated Pydantic/Zod +// clients and the conformance corpus. The camelCase json_name alias is out of +// contract for those clients. This is load-bearing for signatures: signed messages +// are Ed25519 over JCS-canonicalized (RFC 8785) proto-JSON, and JCS sorts keys, so +// field-name casing determines the signed bytes. // Code generated by protoc-gen-go. DO NOT EDIT. // versions: @@ -2517,7 +2524,7 @@ type Offer struct { SignatureAlgorithm string `protobuf:"bytes,10,opt,name=signature_algorithm,json=signatureAlgorithm,proto3" json:"signature_algorithm,omitempty"` // If set, this offer is available under an existing subscription/deal. // No per-request billing — usage tracked against subscription quota. - // Pricing.rate = 0 for subscription offers (zero marginal cost). + // Pricing.rate = "0" for subscription offers (zero marginal cost). // The Broker SHOULD prefer subscription offers when available. SubscriptionId *string `protobuf:"bytes,11,opt,name=subscription_id,json=subscriptionId,proto3,oneof" json:"subscription_id,omitempty"` // IAB Content Taxonomy category codes. @@ -3831,15 +3838,17 @@ type Pricing struct { state protoimpl.MessageState `protogen:"open.v1"` // Provider's pricing model. Model PricingModel `protobuf:"varint,1,opt,name=model,proto3,enum=ramp.v1.PricingModel" json:"model,omitempty"` - // Price in the provider's model (e.g. 0.05 = $0.05 per article). - Rate float64 `protobuf:"fixed64,2,opt,name=rate,proto3" json:"rate,omitempty"` + // Price in the provider's model, as an exact decimal string — e.g. "0.05" = + // $0.05 per article. NOT a float: money is decimal to avoid binary rounding and + // to allow arbitrary sub-cent precision (e.g. "0.0001234"). Denominated in `currency`. + Rate string `protobuf:"bytes,2,opt,name=rate,proto3" json:"rate,omitempty"` // ISO 4217 currency code (e.g. "USD", "EUR"). Currency string `protobuf:"bytes,3,opt,name=currency,proto3" json:"currency,omitempty"` - // Normalized cost per unit — the universal comparison metric. + // Normalized cost per unit — the universal comparison metric, exact decimal string. // For text: cost per token. For video: cost per second. // For data: cost per record. For APIs: cost per call. // Denominated in the Exchange's base_currency (from its WellKnownManifest). - UnitCost *float64 `protobuf:"fixed64,4,opt,name=unit_cost,json=unitCost,proto3,oneof" json:"unit_cost,omitempty"` + UnitCost *string `protobuf:"bytes,4,opt,name=unit_cost,json=unitCost,proto3,oneof" json:"unit_cost,omitempty"` // Estimated quantity in the metering unit. // For text: token count. For video: duration in seconds. // For documents: page count. For data: record count. @@ -3902,11 +3911,11 @@ func (x *Pricing) GetModel() PricingModel { return PricingModel_PRICING_MODEL_UNSPECIFIED } -func (x *Pricing) GetRate() float64 { +func (x *Pricing) GetRate() string { if x != nil { return x.Rate } - return 0 + return "" } func (x *Pricing) GetCurrency() string { @@ -3916,11 +3925,11 @@ func (x *Pricing) GetCurrency() string { return "" } -func (x *Pricing) GetUnitCost() float64 { +func (x *Pricing) GetUnitCost() string { if x != nil && x.UnitCost != nil { return *x.UnitCost } - return 0 + return "" } func (x *Pricing) GetEstimatedQuantity() int32 { @@ -4533,7 +4542,7 @@ type TransactionResponse struct { // No per-request charge — usage tracked against subscription quota. SubscriptionId *string `protobuf:"bytes,12,opt,name=subscription_id,json=subscriptionId,proto3,oneof" json:"subscription_id,omitempty"` // Computed per-unit cost for financial attribution on subscription transactions. - // Even when cost.amount=0 (subscription), this field carries the value + // Even when cost.amount="0" (subscription), this field carries the value // of the access for accounting purposes (e.g., ASC 606 prepaid drawdown). SubscriptionUnitValue *Cost `protobuf:"bytes,16,opt,name=subscription_unit_value,json=subscriptionUnitValue,proto3,oneof" json:"subscription_unit_value,omitempty"` // Batch mode: per-offer results. @@ -4720,7 +4729,7 @@ type TransactionResultItem struct { // If under subscription, no per-request charge. SubscriptionId *string `protobuf:"bytes,6,opt,name=subscription_id,json=subscriptionId,proto3,oneof" json:"subscription_id,omitempty"` // Computed per-unit cost for financial attribution on subscription transactions. - // Even when cost.amount=0 (subscription), this field carries the value + // Even when cost.amount="0" (subscription), this field carries the value // of the access for accounting purposes (e.g., ASC 606 prepaid drawdown). SubscriptionUnitValue *Cost `protobuf:"bytes,11,opt,name=subscription_unit_value,json=subscriptionUnitValue,proto3,oneof" json:"subscription_unit_value,omitempty"` // Set if this specific item was denied (others may succeed). @@ -4867,10 +4876,10 @@ func (x *TransactionResultItem) GetReportingObligation() *ReportingObligation { // Cost — Actual transaction cost. type Cost struct { state protoimpl.MessageState `protogen:"open.v1"` - // Cost amount - Amount float64 `protobuf:"fixed64,1,opt,name=amount,proto3" json:"amount,omitempty"` - Currency string `protobuf:"bytes,2,opt,name=currency,proto3" json:"currency,omitempty"` // ISO 4217 - UnitCost *float64 `protobuf:"fixed64,3,opt,name=unit_cost,json=unitCost,proto3,oneof" json:"unit_cost,omitempty"` // Effective cost per unit + // Exact decimal string (not a float), e.g. "19.99". Denominated in `currency`. + Amount string `protobuf:"bytes,1,opt,name=amount,proto3" json:"amount,omitempty"` + Currency string `protobuf:"bytes,2,opt,name=currency,proto3" json:"currency,omitempty"` // ISO 4217 + UnitCost *string `protobuf:"bytes,3,opt,name=unit_cost,json=unitCost,proto3,oneof" json:"unit_cost,omitempty"` // Effective cost per unit (decimal string) unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -4905,11 +4914,11 @@ func (*Cost) Descriptor() ([]byte, []int) { return file_ramp_v1_ramp_proto_rawDescGZIP(), []int{22} } -func (x *Cost) GetAmount() float64 { +func (x *Cost) GetAmount() string { if x != nil { return x.Amount } - return 0 + return "" } func (x *Cost) GetCurrency() string { @@ -4919,11 +4928,11 @@ func (x *Cost) GetCurrency() string { return "" } -func (x *Cost) GetUnitCost() float64 { +func (x *Cost) GetUnitCost() string { if x != nil && x.UnitCost != nil { return *x.UnitCost } - return 0 + return "" } type PushResourcesRequest struct { @@ -6239,8 +6248,8 @@ type RequestConstraints struct { Exchanges []string `protobuf:"bytes,1,rep,name=exchanges,proto3" json:"exchanges,omitempty"` // Maximum price the agent is willing to pay. MaxPrice *Cost `protobuf:"bytes,2,opt,name=max_price,json=maxPrice,proto3,oneof" json:"max_price,omitempty"` - // Maximum effective cost per unit. - MaxUnitCost *float64 `protobuf:"fixed64,3,opt,name=max_unit_cost,json=maxUnitCost,proto3,oneof" json:"max_unit_cost,omitempty"` + // Maximum effective cost per unit, as an exact decimal string (not a float). + MaxUnitCost *string `protobuf:"bytes,3,opt,name=max_unit_cost,json=maxUnitCost,proto3,oneof" json:"max_unit_cost,omitempty"` // Preferred delivery methods, in order of preference. DeliveryPreference []DeliveryMethod `protobuf:"varint,4,rep,packed,name=delivery_preference,json=deliveryPreference,proto3,enum=ramp.v1.DeliveryMethod" json:"delivery_preference,omitempty"` // Whether the agent supports post-usage reporting. @@ -6325,11 +6334,11 @@ func (x *RequestConstraints) GetMaxPrice() *Cost { return nil } -func (x *RequestConstraints) GetMaxUnitCost() float64 { +func (x *RequestConstraints) GetMaxUnitCost() string { if x != nil && x.MaxUnitCost != nil { return *x.MaxUnitCost } - return 0 + return "" } func (x *RequestConstraints) GetDeliveryPreference() []DeliveryMethod { @@ -8379,11 +8388,10 @@ var File_ramp_v1_ramp_proto protoreflect.FileDescriptor const file_ramp_v1_ramp_proto_rawDesc = "" + "\n" + - "\x12ramp/v1/ramp.proto\x12\aramp.v1\x1a\x1cgoogle/protobuf/struct.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1egoogle/protobuf/duration.proto\x1a\x1bbuf/validate/validate.proto\x1a\x13ramp/v1/vocab.proto\"\xb5\x02\n" + + "\x12ramp/v1/ramp.proto\x12\aramp.v1\x1a\x1cgoogle/protobuf/struct.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1egoogle/protobuf/duration.proto\x1a\x1bbuf/validate/validate.proto\x1a\x13ramp/v1/vocab.proto\"\x84\x01\n" + "\x15AcceptableRestriction\x12,\n" + - "\x04axis\x18\x01 \x01(\x0e2\x18.ramp.v1.RestrictionKindR\x04axis\x12\xed\x01\n" + - "\x06values\x18\x02 \x03(\tB\xd4\x01\xbaH\xd0\x01\xba\x01\xc7\x01\n" + - "$acceptable_restriction.values.format\x12Meach value must be 1-64 chars from [A-Za-z0-9._:*-] (no spaces/control chars)\x1aPthis.all(t, t.size() >= 1 && t.size() <= 64 && t.matches('^[A-Za-z0-9._:*-]+$'))\x92\x01\x02\x10@R\x06values\"\x91\x03\n" + + "\x04axis\x18\x01 \x01(\x0e2\x18.ramp.v1.RestrictionKindR\x04axis\x12=\n" + + "\x06values\x18\x02 \x03(\tB%\xbaH\"\x92\x01\x1f\x10@\"\x1br\x19\x10\x01\x18@2\x13^[A-Za-z0-9._:*-]+$R\x06values\"\x91\x03\n" + "\rResourceQuery\x12\x10\n" + "\x03ver\x18\x01 \x01(\tR\x03ver\x120\n" + "\trequester\x18\x03 \x01(\v2\x12.ramp.v1.RequesterR\trequester\x12\x1d\n" + @@ -8460,7 +8468,7 @@ const file_ramp_v1_ramp_proto_rawDesc = "" + "\v_expires_atB\v\n" + "\t_identityB\x12\n" + "\x10_subscription_idB\r\n" + - "\v_data_as_of\"\xab\a\n" + + "\v_data_as_of\"\xe1\x05\n" + "\x10ResourceIdentity\x12(\n" + "\rcanonical_url\x18\x01 \x01(\tH\x00R\fcanonicalUrl\x88\x01\x01\x12\x15\n" + "\x03doi\x18\x02 \x01(\tH\x01R\x03doi\x88\x01\x01\x12 \n" + @@ -8468,8 +8476,8 @@ const file_ramp_v1_ramp_proto_rawDesc = "" + "\x04isni\x18\x04 \x01(\tH\x03R\x04isni\x88\x01\x01\x12&\n" + "\fcontent_hash\x18\x05 \x01(\tH\x04R\vcontentHash\x88\x01\x01\x12$\n" + "\vhash_method\x18\x06 \x01(\tH\x05R\n" + - "hashMethod\x88\x01\x01\x12L\n" + - "\x13resource_mutability\x18\b \x01(\x0e2\x1b.ramp.v1.ResourceMutabilityR\x12resourceMutability\x12(\n" + + "hashMethod\x88\x01\x01\x12V\n" + + "\x13resource_mutability\x18\b \x01(\x0e2\x1b.ramp.v1.ResourceMutabilityB\b\xbaH\x05\x82\x01\x02 \x00R\x12resourceMutability\x12(\n" + "\rc2pa_manifest\x18\a \x01(\tH\x06R\fc2paManifest\x88\x01\x01\x129\n" + "\vc2pa_status\x18\t \x01(\x0e2\x13.ramp.v1.C2PAStatusH\aR\n" + "c2paStatus\x88\x01\x01\x12&\n" + @@ -8477,8 +8485,7 @@ const file_ramp_v1_ramp_proto_rawDesc = "" + " \x01(\tH\bR\vsoftBinding\x88\x01\x01\x123\n" + "\x13soft_binding_method\x18\v \x01(\tH\tR\x11softBindingMethod\x88\x01\x01\x12)\n" + "\x03ext\x18\x0f \x01(\v2\x17.google.protobuf.StructR\x03ext\x12!\n" + - "\fext_critical\x18Z \x03(\tR\vextCritical:\xd1\x01\xbaH\xcd\x01\x1a\xca\x01\n" + - "/resource_identity.resource_mutability_specified\x12?resource_mutability must not be RESOURCE_MUTABILITY_UNSPECIFIED\x1aVthis.resource_mutability != ramp.v1.ResourceMutability.RESOURCE_MUTABILITY_UNSPECIFIEDB\x10\n" + + "\fext_critical\x18Z \x03(\tR\vextCriticalB\x10\n" + "\x0e_canonical_urlB\x06\n" + "\x04_doiB\f\n" + "\n" + @@ -8497,63 +8504,54 @@ const file_ramp_v1_ramp_proto_rawDesc = "" + "attestedAt\x12\x10\n" + "\x03uri\x18\x04 \x01(\tR\x03uri\x12/\n" + "\x06claims\x18\x05 \x01(\v2\x17.google.protobuf.StructR\x06claims\x12\x1c\n" + - "\tsignature\x18\x06 \x01(\tR\tsignature\"\xd4\x04\n" + + "\tsignature\x18\x06 \x01(\tR\tsignature\"\x92\x03\n" + "\aLicense\x12\x15\n" + "\x03uri\x18\x01 \x01(\tH\x00R\x03uri\x88\x01\x01\x12\x13\n" + "\x02id\x18\x02 \x01(\tH\x01R\x02id\x88\x01\x01\x12\x17\n" + "\x04name\x18\x03 \x01(\tH\x02R\x04name\x88\x01\x01\x12!\n" + - "\timmutable\x18\x04 \x01(\bH\x03R\timmutable\x88\x01\x01\x12\xad\x02\n" + + "\timmutable\x18\x04 \x01(\bH\x03R\timmutable\x88\x01\x01\x12l\n" + "\n" + - "uri_digest\x18\x05 \x01(\tB\x88\x02\xbaH\x84\x02\xba\x01\x80\x02\n" + - "\x19license.uri_digest.format\x12Zuri_digest must be sha256:/sha384:/sha512: followed by a hex digest of the matching length\x1a\x86\x01this == '' || this.matches('^sha256:[0-9a-f]{64}$') || this.matches('^sha384:[0-9a-f]{96}$') || this.matches('^sha512:[0-9a-f]{128}$')H\x04R\turiDigest\x88\x01\x01:|\xbaHy\x1aw\n" + + "uri_digest\x18\x05 \x01(\tBH\xbaHErC2A^(sha256:[0-9a-f]{64}|sha384:[0-9a-f]{96}|sha512:[0-9a-f]{128})?$H\x04R\turiDigest\x88\x01\x01:|\xbaHy\x1aw\n" + " license.digest_required_with_uri\x12*uri_digest is required whenever uri is set\x1a'this.uri == '' || this.uri_digest != ''B\x06\n" + "\x04_uriB\x05\n" + "\x03_idB\a\n" + "\x05_nameB\f\n" + "\n" + "_immutableB\r\n" + - "\v_uri_digest\"\xe4\x06\n" + - "\vRestriction\x12,\n" + - "\x04kind\x18\x01 \x01(\x0e2\x18.ramp.v1.RestrictionKindR\x04kind\x12\xeb\x01\n" + - "\tpermitted\x18\x02 \x03(\tB\xcc\x01\xbaH\xc8\x01\xba\x01\xbf\x01\n" + - "\x1crestriction.permitted.format\x12Meach token must be 1-64 chars from [A-Za-z0-9._:*-] (no spaces/control chars)\x1aPthis.all(t, t.size() >= 1 && t.size() <= 64 && t.matches('^[A-Za-z0-9._:*-]+$'))\x92\x01\x02\x10@R\tpermitted\x12\xee\x01\n" + + "\v_uri_digest\"\x8a\x03\n" + + "\vRestriction\x126\n" + + "\x04kind\x18\x01 \x01(\x0e2\x18.ramp.v1.RestrictionKindB\b\xbaH\x05\x82\x01\x02 \x00R\x04kind\x12C\n" + + "\tpermitted\x18\x02 \x03(\tB%\xbaH\"\x92\x01\x1f\x10@\"\x1br\x19\x10\x01\x18@2\x13^[A-Za-z0-9._:*-]+$R\tpermitted\x12E\n" + "\n" + - "prohibited\x18\x03 \x03(\tB\xcd\x01\xbaH\xc9\x01\xba\x01\xc0\x01\n" + - "\x1drestriction.prohibited.format\x12Meach token must be 1-64 chars from [A-Za-z0-9._:*-] (no spaces/control chars)\x1aPthis.all(t, t.size() >= 1 && t.size() <= 64 && t.matches('^[A-Za-z0-9._:*-]+$'))\x92\x01\x02\x10@R\n" + + "prohibited\x18\x03 \x03(\tB%\xbaH\"\x92\x01\x1f\x10@\"\x1br\x19\x10\x01\x18@2\x13^[A-Za-z0-9._:*-]+$R\n" + "prohibited\x12\x1a\n" + - "\badvisory\x18\x04 \x01(\bR\badvisory:\xab\x02\xbaH\xa7\x02\x1a\x93\x01\n" + - ")restriction.permitted_prohibited_disjoint\x126a token cannot appear in both permitted and prohibited\x1a.this.permitted.all(p, !(p in this.prohibited))\x1a\x8e\x01\n" + - "\x1arestriction.kind_specified\x12-kind must not be RESTRICTION_KIND_UNSPECIFIED\x1aAthis.kind != ramp.v1.RestrictionKind.RESTRICTION_KIND_UNSPECIFIED\"\xd4\x04\n" + - "\x05Quota\x12\xf1\x02\n" + - "\x06metric\x18\x01 \x01(\tB\xd8\x02\xbaH\xe5\x01\xba\x01\xdd\x01\n" + - "\x13quota.metric.format\x12cmetric must be a lowercase-dashed token or vendor:namespaced (no spaces/control chars, ≤64 chars)\x1aathis != '' && (this.matches('^[a-z0-9-]+$') || this.matches('^[A-Za-z0-9._-]+:[A-Za-z0-9._-]+$'))r\x02\x18@\x8a\xb5\x18\rdisplay-words\x8a\xb5\x18\vimpressions\x8a\xb5\x18\x06tokens\x8a\xb5\x18\finput-tokens\x8a\xb5\x18\x12units-manufactured\x8a\xb5\x18\baccesses\x8a\xb5\x18\x06copies\x8a\xb5\x18\x05seatsR\x06metric\x12\x1d\n" + - "\x05limit\x18\x02 \x01(\x03B\a\xbaH\x04\"\x02(\x01R\x05limit\x12,\n" + - "\x06window\x18\x03 \x01(\x0e2\x14.ramp.v1.QuotaWindowR\x06window:\x89\x01\xbaH\x85\x01\x1a\x82\x01\n" + - "\x16quota.window_specified\x12+window must not be QUOTA_WINDOW_UNSPECIFIED\x1a;this.window != ramp.v1.QuotaWindow.QUOTA_WINDOW_UNSPECIFIED\"\xb0\x06\n" + + "\badvisory\x18\x04 \x01(\bR\badvisory:\x9a\x01\xbaH\x96\x01\x1a\x93\x01\n" + + ")restriction.permitted_prohibited_disjoint\x126a token cannot appear in both permitted and prohibited\x1a.this.permitted.all(p, !(p in this.prohibited))\"\xb0\x02\n" + + "\x05Quota\x12\xcf\x01\n" + + "\x06metric\x18\x01 \x01(\tB\xb6\x01\xbaH4r2\x18@2.^([a-z0-9-]+|[A-Za-z0-9._-]+:[A-Za-z0-9._-]+)$\x8a\xb5\x18\rdisplay-words\x8a\xb5\x18\vimpressions\x8a\xb5\x18\x06tokens\x8a\xb5\x18\finput-tokens\x8a\xb5\x18\x12units-manufactured\x8a\xb5\x18\baccesses\x8a\xb5\x18\x06copies\x8a\xb5\x18\x05seats\x9a\xb5\x18\fquotametricsR\x06metric\x12\x1d\n" + + "\x05limit\x18\x02 \x01(\x03B\a\xbaH\x04\"\x02(\x01R\x05limit\x126\n" + + "\x06window\x18\x03 \x01(\x0e2\x14.ramp.v1.QuotaWindowB\b\xbaH\x05\x82\x01\x02 \x00R\x06window\"\x98\x04\n" + "\n" + - "Obligation\x12+\n" + - "\x04kind\x18\x01 \x01(\x0e2\x17.ramp.v1.ObligationKindR\x04kind\x124\n" + - "\atrigger\x18\x02 \x01(\x0e2\x1a.ramp.v1.ObligationTriggerR\atrigger\x12:\n" + + "Obligation\x125\n" + + "\x04kind\x18\x01 \x01(\x0e2\x17.ramp.v1.ObligationKindB\b\xbaH\x05\x82\x01\x02 \x00R\x04kind\x12>\n" + + "\atrigger\x18\x02 \x01(\x0e2\x1a.ramp.v1.ObligationTriggerB\b\xbaH\x05\x82\x01\x02 \x00R\atrigger\x12:\n" + "\rscope_license\x18\x03 \x01(\v2\x10.ramp.v1.LicenseH\x00R\fscopeLicense\x88\x01\x01\x12\x1b\n" + - "\x06detail\x18\x04 \x01(\tH\x01R\x06detail\x88\x01\x01:\xc8\x04\xbaH\xc4\x04\x1a\x95\x02\n" + - "-obligation.share_alike.requires_scope_license\x12DSHARE_ALIKE requires scope_license to identify a license (id or uri)\x1a\x9d\x01this.kind != ramp.v1.ObligationKind.OBLIGATION_KIND_SHARE_ALIKE || (has(this.scope_license) && (this.scope_license.id != '' || this.scope_license.uri != ''))\x1a\x8a\x01\n" + - "\x19obligation.kind_specified\x12,kind must not be OBLIGATION_KIND_UNSPECIFIED\x1a?this.kind != ramp.v1.ObligationKind.OBLIGATION_KIND_UNSPECIFIED\x1a\x9c\x01\n" + - "\x1cobligation.trigger_specified\x122trigger must not be OBLIGATION_TRIGGER_UNSPECIFIED\x1aHthis.trigger != ramp.v1.ObligationTrigger.OBLIGATION_TRIGGER_UNSPECIFIEDB\x10\n" + + "\x06detail\x18\x04 \x01(\tH\x01R\x06detail\x88\x01\x01:\x9c\x02\xbaH\x98\x02\x1a\x95\x02\n" + + "-obligation.share_alike.requires_scope_license\x12DSHARE_ALIKE requires scope_license to identify a license (id or uri)\x1a\x9d\x01this.kind != ramp.v1.ObligationKind.OBLIGATION_KIND_SHARE_ALIKE || (has(this.scope_license) && (this.scope_license.id != '' || this.scope_license.uri != ''))B\x10\n" + "\x0e_scope_licenseB\t\n" + - "\a_detail\"\xe6\a\n" + + "\a_detail\"\xd5\x06\n" + "\vLicenseTerm\x12/\n" + - "\alicense\x18\x01 \x01(\v2\x10.ramp.v1.LicenseH\x00R\alicense\x88\x01\x01\x124\n" + - "\tsemantics\x18\x02 \x01(\x0e2\x16.ramp.v1.TermSemanticsR\tsemantics\x128\n" + + "\alicense\x18\x01 \x01(\v2\x10.ramp.v1.LicenseH\x00R\alicense\x88\x01\x01\x12>\n" + + "\tsemantics\x18\x02 \x01(\x0e2\x16.ramp.v1.TermSemanticsB\b\xbaH\x05\x82\x01\x02 \x00R\tsemantics\x128\n" + "\frestrictions\x18\x03 \x03(\v2\x14.ramp.v1.RestrictionR\frestrictions\x12&\n" + "\x06quotas\x18\x04 \x03(\v2\x0e.ramp.v1.QuotaR\x06quotas\x125\n" + "\vobligations\x18\x05 \x03(\v2\x13.ramp.v1.ObligationR\vobligations\x127\n" + "\apricing\x18\x06 \x01(\v2\x10.ramp.v1.PricingB\x06\xbaH\x03\xc8\x01\x01H\x01R\apricing\x88\x01\x01\x12 \n" + "\x06scopes\x18\a \x03(\tB\b\xbaH\x05\x92\x01\x02\x10@R\x06scopes\x12\"\n" + "\n" + - "part_label\x18\b \x01(\tH\x02R\tpartLabel\x88\x01\x01:\xb0\x04\xbaH\xac\x04\x1a\xe2\x01\n" + + "part_label\x18\b \x01(\tH\x02R\tpartLabel\x88\x01\x01:\x95\x03\xbaH\x91\x03\x1a\xe2\x01\n" + "(license_term.reference_only.requires_uri\x12>REFERENCE_ONLY terms must carry a license with a non-empty uri\x1avthis.semantics != ramp.v1.TermSemantics.TERM_SEMANTICS_REFERENCE_ONLY || (has(this.license) && this.license.uri != '')\x1a\xa9\x01\n" + - "%license_term.one_restriction_per_kind\x12+at most one restriction is allowed per kind\x1aSthis.restrictions.all(r, this.restrictions.filter(o, o.kind == r.kind).size() <= 1)\x1a\x98\x01\n" + - " license_term.semantics_specified\x120semantics must not be TERM_SEMANTICS_UNSPECIFIED\x1aBthis.semantics != ramp.v1.TermSemantics.TERM_SEMANTICS_UNSPECIFIEDB\n" + + "%license_term.one_restriction_per_kind\x12+at most one restriction is allowed per kind\x1aSthis.restrictions.all(r, this.restrictions.filter(o, o.kind == r.kind).size() <= 1)B\n" + "\n" + "\b_licenseB\n" + "\n" + @@ -8570,31 +8568,29 @@ const file_ramp_v1_ramp_proto_rawDesc = "" + "\x06_widthB\t\n" + "\a_heightB\v\n" + "\t_durationB\a\n" + - "\x05_size\"\xf7\t\n" + - "\aPricing\x12+\n" + - "\x05model\x18\x01 \x01(\x0e2\x15.ramp.v1.PricingModelR\x05model\x12\x12\n" + - "\x04rate\x18\x02 \x01(\x01R\x04rate\x12\x1a\n" + - "\bcurrency\x18\x03 \x01(\tR\bcurrency\x12 \n" + - "\tunit_cost\x18\x04 \x01(\x01H\x00R\bunitCost\x88\x01\x01\x122\n" + + "\x05_size\"\xc8\b\n" + + "\aPricing\x125\n" + + "\x05model\x18\x01 \x01(\x0e2\x15.ramp.v1.PricingModelB\b\xbaH\x05\x82\x01\x02 \x00R\x05model\x124\n" + + "\x04rate\x18\x02 \x01(\tB \xbaH\x1dr\x1b\x18 2\x17^([0-9]+([.][0-9]+)?)?$R\x04rate\x12\x1a\n" + + "\bcurrency\x18\x03 \x01(\tR\bcurrency\x12B\n" + + "\tunit_cost\x18\x04 \x01(\tB \xbaH\x1dr\x1b\x18 2\x17^([0-9]+([.][0-9]+)?)?$H\x00R\bunitCost\x88\x01\x01\x122\n" + "\x12estimated_quantity\x18\x05 \x01(\x05H\x01R\x11estimatedQuantity\x88\x01\x01\x12;\n" + - "\x17license_duration_months\x18\a \x01(\x05H\x02R\x15licenseDurationMonths\x88\x01\x01\x12\xb8\x03\n" + - "\x04unit\x18\b \x01(\tB\x9e\x03\xbaH\xe9\x01\xba\x01\xe1\x01\n" + - "\x13pricing.unit.format\x12iunit must be empty, a lowercase-dashed token, or vendor:namespaced (no spaces/control chars, ≤64 chars)\x1a_this == '' || this.matches('^[a-z0-9-]+$') || this.matches('^[A-Za-z0-9._-]+:[A-Za-z0-9._-]+$')r\x02\x18@\x8a\xb5\x18\afetches\x8a\xb5\x18\baccesses\x8a\xb5\x18\x06tokens\x8a\xb5\x18\x05calls\x8a\xb5\x18\x05pages\x8a\xb5\x18\aseconds\x8a\xb5\x18\aminutes\x8a\xb5\x18\arecords\x8a\xb5\x18\astreams\x8a\xb5\x18\x06images\x8a\xb5\x18\x05seats\x8a\xb5\x18\x12units-manufactured\x8a\xb5\x18\n" + - "characters\x8a\xb5\x18\x05bytes\x8a\xb5\x18\x05items\x8a\xb5\x18\x05sq-kmH\x03R\x04unit\x88\x01\x01\x129\n" + - "\bmetering\x18\t \x01(\x0e2\x18.ramp.v1.PricingMeteringH\x04R\bmetering\x88\x01\x01:\xae\x03\xbaH\xaa\x03\x1a\x97\x01\n" + - "\x1epricing.per_unit.requires_unit\x12'unit is required when model is PER_UNIT\x1aLthis.model != ramp.v1.PricingModel.PRICING_MODEL_PER_UNIT || this.unit != ''\x1a\x86\x01\n" + - "\x16pricing.free.zero_rate\x12!rate must be 0 when model is FREE\x1aIthis.model != ramp.v1.PricingModel.PRICING_MODEL_FREE || this.rate == 0.0\x1a\x84\x01\n" + - "\x17pricing.model_specified\x12+model must not be PRICING_MODEL_UNSPECIFIED\x1a google.protobuf.FieldOptions - 1, // 1: ramp.v1.vocab_enum:extendee -> google.protobuf.EnumValueOptions - 2, // [2:2] is the sub-list for method output_type - 2, // [2:2] is the sub-list for method input_type - 2, // [2:2] is the sub-list for extension type_name - 0, // [0:2] is the sub-list for extension extendee + 0, // 1: ramp.v1.vocab_package:extendee -> google.protobuf.FieldOptions + 1, // 2: ramp.v1.vocab_enum:extendee -> google.protobuf.EnumValueOptions + 1, // 3: ramp.v1.vocab_enum_package:extendee -> google.protobuf.EnumValueOptions + 4, // [4:4] is the sub-list for method output_type + 4, // [4:4] is the sub-list for method input_type + 4, // [4:4] is the sub-list for extension type_name + 0, // [0:4] is the sub-list for extension extendee 0, // [0:0] is the sub-list for field type_name } @@ -119,7 +154,7 @@ func file_ramp_v1_vocab_proto_init() { RawDescriptor: unsafe.Slice(unsafe.StringData(file_ramp_v1_vocab_proto_rawDesc), len(file_ramp_v1_vocab_proto_rawDesc)), NumEnums: 0, NumMessages: 0, - NumExtensions: 2, + NumExtensions: 4, NumServices: 0, }, GoTypes: file_ramp_v1_vocab_proto_goTypes, diff --git a/gen/python/README.md b/gen/python/README.md new file mode 100644 index 00000000..4262b086 --- /dev/null +++ b/gen/python/README.md @@ -0,0 +1,31 @@ +# Generated Python types export + +Generated from [`proto/`](../../proto) via JSON Schema. **Do not edit by hand** — +regenerate with `scripts/gen-sdk-types.sh` and commit the result (CI drift-gates it). + +A **types export**, not a full SDK: Pydantic models + registered vocabulary constants. +Transport (Connect), request signing (RFC 9421), and key management are a separate, +hand-written SDK layer. + +Contents: +- `wire/base.py` — **`WireModel`**, the single base class every model extends (the one + seam: SDK-wide config + your override point; neutral name so a protocol rename never + touches consumer imports). **Hand-written, not regenerated.** +- `wire/models.py` — Pydantic v2 models for every message (`License`, `Pricing`, + `LicenseTerm`, `Offer`, …), all extending `WireModel`. They carry **shape + per-field + validation**: enums (named from the proto descriptor, `*_UNSPECIFIED` dropped), + string patterns, length/item bounds. Nested messages reference the same model + (`LicenseTerm.license` is a `License`), so the whole tree hydrates as typed models. + **Cross-field rules are NOT here** — enforced server-side by the Exchange/Broker. +- `vocab/` — registered vocabulary constants per axis (`pricingunits`, …) with + `is_registered()`. + +```python +from wire.models import LicenseTerm, License +from wire.base import WireModel # subclass this to customize ALL models at once + +term = LicenseTerm.model_validate(incoming_json) # raises on shape/per-field violations +assert isinstance(term.license, License) # full nested hierarchy, typed +``` + +Install (from this directory): `pip install .` diff --git a/gen/python/pyproject.toml b/gen/python/pyproject.toml new file mode 100644 index 00000000..4008cd06 --- /dev/null +++ b/gen/python/pyproject.toml @@ -0,0 +1,30 @@ +# Packaging for the generated RAMP Python types export (mirrors gen/ts/package.json). +# wire/models.py and vocab/* are generated from proto/ by scripts/gen-sdk-types.sh — do +# not edit them by hand. Only this file, README.md, and the hand-written wire/base.py +# seam are authored. +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "ramp-protocol" +version = "0.1.0" +description = "Generated types export for Python: Pydantic v2 models (wire.models, extending wire.base.WireModel) + registered vocabulary constants (vocab.*), generated from the proto via JSON Schema." +readme = "README.md" +requires-python = ">=3.9" +license = { text = "Apache-2.0" } +# Pydantic for the message models; vocabulary constants are pure string literals. +dependencies = [ + "pydantic>=2.0", +] + +[project.urls] +Homepage = "https://ramp-protocol.org" +Source = "https://github.com/RAMP-Protocol/protocol" + +[tool.setuptools.packages.find] +where = ["."] +# PEP 420 namespace packages. Pydantic models + base at wire.* (neutral name, no +# protocol coupling); vocabulary constants at vocab.pricingunits (etc.). +namespaces = true +include = ["wire*", "vocab*"] diff --git a/gen/python/tests/test_forward_compat.py b/gen/python/tests/test_forward_compat.py new file mode 100644 index 00000000..257fbe10 --- /dev/null +++ b/gen/python/tests/test_forward_compat.py @@ -0,0 +1,59 @@ +"""Direct behavioral regression for wire forward-compatibility (H2 / kb1s0.2). + +Core invariant: an UNKNOWN top-level field (a field a newer protocol version +adds that this older client has never seen) MUST be ACCEPTED and DROPPED — not +rejected, not retained. This is the whole point of WireModel's +`model_config = ConfigDict(extra="ignore")`: an older SDK keeps parsing a newer +message instead of failing closed. + +This exercises the REAL generated models (wire.models) through their public +parse surface (model_validate on proto-JSON dicts, routed through +wire.base.WireModel). No corpus, no source scanning — behavior only. + +Run: PYTHONPATH=gen/python python3 -m pytest gen/python/tests/test_forward_compat.py -q + +It FAILS today: every generated model carries its own +`model_config = ConfigDict(extra='forbid')`, which SHADOWS the WireModel +extra='ignore' seam in Pydantic v2, so the unknown field raises ValidationError +instead of being dropped. +""" +import pytest +from pydantic import ValidationError + +import wire.models as models + +# A key no protocol version defines — stands in for "a field a newer version added". +UNKNOWN_KEY = "__unknown_future_field__" + +# Representative models with a minimally-valid proto-JSON body (only what the +# model REQUIRES) so the sole variable under test is the extra unknown field. +# (case_id, ModelClass, base_instance) +DROP_CASES = [ + ("Cost", models.Cost, {"amount": "19.99"}), + ("Delegation", models.Delegation, {"principal_id": "user@acme.com"}), + ("Pricing", models.Pricing, {"model": models.PricingModel.PRICING_MODEL_FREE.value}), +] + + +@pytest.mark.parametrize( + "cls,base", + [(cls, base) for _, cls, base in DROP_CASES], + ids=[cid for cid, *_ in DROP_CASES], +) +def test_unknown_top_level_field_accepted_and_dropped(cls, base): + instance = {**base, UNKNOWN_KEY: "a value from the future"} + + # MUST NOT raise: an unknown top-level field is accepted (forward-compat). + parsed = cls.model_validate(instance) + + # MUST be dropped, not retained: it is absent from the model and its dump. + assert not hasattr(parsed, UNKNOWN_KEY) + assert UNKNOWN_KEY not in parsed.model_dump() + + +def test_closed_enum_discriminator_still_rejects_bogus_value(): + # Guard: opening up unknown *top-level* fields must not over-open a CLOSED + # enum. Pricing.model is a closed PricingModel enum; a bogus value must + # still raise (before AND after the fix). + with pytest.raises(ValidationError): + models.Pricing.model_validate({"model": "PRICING_MODEL_BOGUS"}) diff --git a/gen/python/tests/test_money_pattern.py b/gen/python/tests/test_money_pattern.py new file mode 100644 index 00000000..10c88b7b --- /dev/null +++ b/gen/python/tests/test_money_pattern.py @@ -0,0 +1,100 @@ +"""Direct behavioral regression for the money wire-pattern (H1 / kb1s0.1). + +Money on the wire is a pattern-constrained string: ^([0-9]+([.][0-9]+)?)?$ +(Go keeps string.pattern; Zod keeps z.string().regex(...)). The generated +Pydantic models MUST enforce the identical rule so the three verdicts converge. + +This exercises the REAL generated models (wire.models) through their public +parse surface (model_validate on proto-JSON dicts, which routes through +wire.base.WireModel) for all five money fields: + Cost.amount, Cost.unit_cost, RequestConstraints.max_unit_cost, + Pricing.rate, Pricing.unit_cost + +Run: PYTHONPATH=gen/python python3 -m pytest gen/python/tests/test_money_pattern.py -q + +It FAILS today: mark_money_decimal tags money `format: decimal`, so the +generator emits a bare `Decimal` that DROPS the sibling string.pattern. +Pydantic's Decimal wrongly ACCEPTS "-5"/"NaN"/"Infinity"/"1E3" and wrongly +REJECTS the valid empty string "". +""" +import pytest +from pydantic import ValidationError + +import wire.models as models + +# A minimal valid proto-JSON instance per money field, plus the field to inject +# into. Each tuple: (case_id, ModelClass, base_instance, money_field_name). +# Base instances carry only what the model REQUIRES so the money field is the +# sole rule under test. +MONEY_FIELDS = [ + ("Cost.amount", models.Cost, {}, "amount"), + ("Cost.unit_cost", models.Cost, {}, "unit_cost"), + ( + "RequestConstraints.max_unit_cost", + models.RequestConstraints, + {}, + "max_unit_cost", + ), + ( + "Pricing.rate", + models.Pricing, + {"model": models.PricingModel.PRICING_MODEL_PER_UNIT.value}, + "rate", + ), + ( + "Pricing.unit_cost", + models.Pricing, + {"model": models.PricingModel.PRICING_MODEL_PER_UNIT.value}, + "unit_cost", + ), +] + +# Values that VIOLATE ^([0-9]+([.][0-9]+)?)?$ and MUST be rejected. Each is a +# classic bare-Decimal footgun that a plain Decimal wrongly accepts: +# "-5" -> negative sign not in the pattern +# "NaN" -> Decimal special value; not a number on the wire +# "Infinity" -> Decimal special value; the "no float in the money path" goal +# "1E3" -> scientific notation; not in the pattern (and mutates on re-emit) +REJECT_VALUES = ["-5", "NaN", "Infinity", "1E3"] + +# Values that MATCH the pattern and MUST be accepted. "" is valid (the whole +# body is optional) and is the case bare-Decimal wrongly rejects. +ACCEPT_VALUES = ["", "19.99"] + + +def _reject_ids(): + return [f"{cid}::rejects::{v!r}" for cid, *_ in MONEY_FIELDS for v in REJECT_VALUES] + + +def _accept_ids(): + return [f"{cid}::accepts::{v!r}" for cid, *_ in MONEY_FIELDS for v in ACCEPT_VALUES] + + +@pytest.mark.parametrize( + "cls,base,field,value", + [ + (cls, base, field, v) + for _, cls, base, field in MONEY_FIELDS + for v in REJECT_VALUES + ], + ids=_reject_ids(), +) +def test_money_field_rejects_non_pattern_value(cls, base, field, value): + instance = {**base, field: value} + with pytest.raises(ValidationError): + cls.model_validate(instance) + + +@pytest.mark.parametrize( + "cls,base,field,value", + [ + (cls, base, field, v) + for _, cls, base, field in MONEY_FIELDS + for v in ACCEPT_VALUES + ], + ids=_accept_ids(), +) +def test_money_field_accepts_pattern_value(cls, base, field, value): + instance = {**base, field: value} + parsed = cls.model_validate(instance) + assert getattr(parsed, field) == value diff --git a/gen/python/tests/test_money_roundtrip.py b/gen/python/tests/test_money_roundtrip.py new file mode 100644 index 00000000..2fdc1c9d --- /dev/null +++ b/gen/python/tests/test_money_roundtrip.py @@ -0,0 +1,34 @@ +"""Money is a decimal STRING on the wire, so parse -> dump must be byte-exact: +a normalizing round-trip (e.g. Decimal coercion turning "007.50" into "7.50" or +"1E3" into "1E+3") would change the bytes of a JWS-signed offer or anything under +RFC 9421 Content-Digest coverage and break the signature. This pins that the +generated Pydantic money field preserves the exact wire string (regression guard +for M2 / kb1s0.6; the fix rides on money-as-str from kb1s0.1).""" + +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from wire.models import Cost, Pricing # noqa: E402 + +# Every value is valid per the money pattern ^([0-9]+([.][0-9]+)?)?$ and is a form +# a naive Decimal round-trip would have normalized (leading zeros, trailing zeros, +# high sub-cent precision, empty). +PRESERVED = ["007.50", "19.90", "0.0001234", "0", "", "100", "0.10"] + + +@pytest.mark.parametrize("value", PRESERVED) +def test_cost_amount_roundtrips_byte_exact(value): + model = Cost.model_validate({"amount": value, "currency": "USD"}) + assert model.model_dump()["amount"] == value + assert f'"amount":"{value}"' in model.model_dump_json() + + +@pytest.mark.parametrize("value", PRESERVED) +def test_pricing_rate_roundtrips_byte_exact(value): + model = Pricing.model_validate({"model": "PRICING_MODEL_PER_UNIT", "rate": value}) + assert model.model_dump()["rate"] == value + assert f'"rate":"{value}"' in model.model_dump_json() diff --git a/gen/python/tests/test_parity.py b/gen/python/tests/test_parity.py new file mode 100644 index 00000000..56492faf --- /dev/null +++ b/gen/python/tests/test_parity.py @@ -0,0 +1,44 @@ +"""Cross-language validation parity (Python / Pydantic side). + +Every case in conformance/corpus/cases.json is a proto-JSON instance with the +verdict Go protovalidate assigned it. This asserts the generated Pydantic models +reach the SAME verdict — i.e. the proto -> JSON Schema -> Pydantic pipeline carries +every field-level rule faithfully. The corpus is generated (no rule is restated +here); the Go conformance suite pins it to protovalidate, the drift gate keeps it +fresh, and the TS suite asserts the identical corpus against Zod. + +Run: PYTHONPATH=gen/python pytest gen/python/tests +""" +import json +import pathlib + +import pytest + +import wire.models as models + +CORPUS = pathlib.Path(__file__).resolve().parents[3] / "conformance" / "corpus" / "cases.json" +CASES = json.loads(CORPUS.read_text()) + + +def _accepts(message: str, instance) -> bool: + cls = getattr(models, message) + try: + cls.model_validate(instance) + return True + except Exception: + return False + + +@pytest.mark.parametrize("case", CASES, ids=[c["id"] for c in CASES]) +def test_pydantic_matches_go_verdict(case): + assert hasattr(models, case["message"]), f"no generated model wire.models.{case['message']}" + accepted = _accepts(case["message"], case["json"]) + assert accepted == case["valid"], ( + f"{case['id']}: Pydantic accepted={accepted} but Go verdict valid={case['valid']} " + f"(rules={case.get('rules')})" + ) + + +def test_corpus_is_nonempty(): + # Guard against a silently empty/!-found corpus making the suite vacuous. + assert len(CASES) > 0 diff --git a/gen/python/tests/test_snake_fields.py b/gen/python/tests/test_snake_fields.py new file mode 100644 index 00000000..3a6535f3 --- /dev/null +++ b/gen/python/tests/test_snake_fields.py @@ -0,0 +1,32 @@ +"""Every generated Pydantic model field name must be snake_case — the wire is +snake_case proto-JSON, no exceptions. The proto source is snake by buf lint +(FIELD_LOWER_SNAKE_CASE), the corpus by protojson UseProtoNames, and the docs by the +Go TestDocExamplesAreSnakeCase gate; this is the direct guard on the CLIENT layer, so a +pipeline regression that consumed the camelCase json-name schema variant fails loudly +here rather than as a cryptic parity mismatch.""" + +import inspect +import re +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from wire import models # noqa: E402 +from wire.base import WireModel # noqa: E402 + +SNAKE = re.compile(r"^[a-z][a-z0-9_]*$") + + +def test_all_generated_model_fields_are_snake_case(): + offenders = [] + for _, cls in inspect.getmembers(models, inspect.isclass): + if not (issubclass(cls, WireModel) and cls is not WireModel): + continue + for field_name in cls.model_fields: + if not SNAKE.match(field_name): + offenders.append(f"{cls.__name__}.{field_name}") + assert not offenders, ( + "camelCase (or otherwise non-snake_case) field names in generated Pydantic " + f"models — the wire is snake_case proto-JSON: {sorted(offenders)}" + ) diff --git a/gen/python/vocab/functiontokens.py b/gen/python/vocab/functiontokens.py new file mode 100644 index 00000000..2305ff6c --- /dev/null +++ b/gen/python/vocab/functiontokens.py @@ -0,0 +1,63 @@ +# Code generated by protoc-gen-rampvocab. DO NOT EDIT. +# +# Source vocabulary: on ramp.v1.RESTRICTION_KIND_FUNCTION. +# The token list is authored solely in that option; these constants and +# is_registered derive from it and cannot drift. + +ALL_USES = "all" +AI_ALL = "ai-all" +AI_TRAIN = "ai-train" +AI_INPUT = "ai-input" +AI_INDEX = "ai-index" +SEARCH = "search" +CRAWL = "crawl" +TEXT_AND_DATA_MINING = "text-and-data-mining" +TTS = "tts" +COMMERCIAL = "commercial" +ADVERTISING = "advertising" +EDITORIAL = "editorial" +RESEARCH = "research" +REPRODUCE = "reproduce" +DISTRIBUTE = "distribute" +MODIFY = "modify" +DISPLAY = "display" +SYNC = "sync" +BROADCAST = "broadcast" +STREAM = "stream" +PRINT = "print" +MANUFACTURE = "manufacture" +SELL = "sell" + +# ALL lists every registered token in registration order. +ALL = ( + ALL_USES, + AI_ALL, + AI_TRAIN, + AI_INPUT, + AI_INDEX, + SEARCH, + CRAWL, + TEXT_AND_DATA_MINING, + TTS, + COMMERCIAL, + ADVERTISING, + EDITORIAL, + RESEARCH, + REPRODUCE, + DISTRIBUTE, + MODIFY, + DISPLAY, + SYNC, + BROADCAST, + STREAM, + PRINT, + MANUFACTURE, + SELL, +) + +_REGISTERED = frozenset(ALL) + + +def is_registered(s: str) -> bool: + """Return True if s is a registered bare token (namespaced vendor:token values return False).""" + return s in _REGISTERED diff --git a/gen/python/vocab/geographytokens.py b/gen/python/vocab/geographytokens.py new file mode 100644 index 00000000..1399f63f --- /dev/null +++ b/gen/python/vocab/geographytokens.py @@ -0,0 +1,23 @@ +# Code generated by protoc-gen-rampvocab. DO NOT EDIT. +# +# Source vocabulary: on ramp.v1.RESTRICTION_KIND_GEOGRAPHY. +# The token list is authored solely in that option; these constants and +# is_registered derive from it and cannot drift. + +WORLDWIDE = "*" +EU = "EU" +EEA = "EEA" + +# ALL lists every registered token in registration order. +ALL = ( + WORLDWIDE, + EU, + EEA, +) + +_REGISTERED = frozenset(ALL) + + +def is_registered(s: str) -> bool: + """Return True if s is a registered bare token (namespaced vendor:token values return False).""" + return s in _REGISTERED diff --git a/gen/python/vocab/pricingunits.py b/gen/python/vocab/pricingunits.py new file mode 100644 index 00000000..87e63d13 --- /dev/null +++ b/gen/python/vocab/pricingunits.py @@ -0,0 +1,49 @@ +# Code generated by protoc-gen-rampvocab. DO NOT EDIT. +# +# Source vocabulary: on ramp.v1.Pricing.unit. +# The token list is authored solely in that option; these constants and +# is_registered derive from it and cannot drift. + +FETCHES = "fetches" +ACCESSES = "accesses" +TOKENS = "tokens" +CALLS = "calls" +PAGES = "pages" +SECONDS = "seconds" +MINUTES = "minutes" +RECORDS = "records" +STREAMS = "streams" +IMAGES = "images" +SEATS = "seats" +UNITS_MANUFACTURED = "units-manufactured" +CHARACTERS = "characters" +BYTES = "bytes" +ITEMS = "items" +SQ_KM = "sq-km" + +# ALL lists every registered token in registration order. +ALL = ( + FETCHES, + ACCESSES, + TOKENS, + CALLS, + PAGES, + SECONDS, + MINUTES, + RECORDS, + STREAMS, + IMAGES, + SEATS, + UNITS_MANUFACTURED, + CHARACTERS, + BYTES, + ITEMS, + SQ_KM, +) + +_REGISTERED = frozenset(ALL) + + +def is_registered(s: str) -> bool: + """Return True if s is a registered bare token (namespaced vendor:token values return False).""" + return s in _REGISTERED diff --git a/gen/python/vocab/quotametrics.py b/gen/python/vocab/quotametrics.py new file mode 100644 index 00000000..cfe77755 --- /dev/null +++ b/gen/python/vocab/quotametrics.py @@ -0,0 +1,33 @@ +# Code generated by protoc-gen-rampvocab. DO NOT EDIT. +# +# Source vocabulary: on ramp.v1.Quota.metric. +# The token list is authored solely in that option; these constants and +# is_registered derive from it and cannot drift. + +DISPLAY_WORDS = "display-words" +IMPRESSIONS = "impressions" +TOKENS = "tokens" +INPUT_TOKENS = "input-tokens" +UNITS_MANUFACTURED = "units-manufactured" +ACCESSES = "accesses" +COPIES = "copies" +SEATS = "seats" + +# ALL lists every registered token in registration order. +ALL = ( + DISPLAY_WORDS, + IMPRESSIONS, + TOKENS, + INPUT_TOKENS, + UNITS_MANUFACTURED, + ACCESSES, + COPIES, + SEATS, +) + +_REGISTERED = frozenset(ALL) + + +def is_registered(s: str) -> bool: + """Return True if s is a registered bare token (namespaced vendor:token values return False).""" + return s in _REGISTERED diff --git a/gen/python/vocab/usertypes.py b/gen/python/vocab/usertypes.py new file mode 100644 index 00000000..a819bf22 --- /dev/null +++ b/gen/python/vocab/usertypes.py @@ -0,0 +1,29 @@ +# Code generated by protoc-gen-rampvocab. DO NOT EDIT. +# +# Source vocabulary: on ramp.v1.RESTRICTION_KIND_USER_TYPE. +# The token list is authored solely in that option; these constants and +# is_registered derive from it and cannot drift. + +INDIVIDUAL = "individual" +ACADEMIC = "academic" +NON_PROFIT = "non_profit" +NEWS_PUBLISHER = "news_publisher" +BROADCASTER = "broadcaster" +COMMERCIAL_ENTITY = "commercial_entity" + +# ALL lists every registered token in registration order. +ALL = ( + INDIVIDUAL, + ACADEMIC, + NON_PROFIT, + NEWS_PUBLISHER, + BROADCASTER, + COMMERCIAL_ENTITY, +) + +_REGISTERED = frozenset(ALL) + + +def is_registered(s: str) -> bool: + """Return True if s is a registered bare token (namespaced vendor:token values return False).""" + return s in _REGISTERED diff --git a/gen/python/wire/base.py b/gen/python/wire/base.py new file mode 100644 index 00000000..a733d286 --- /dev/null +++ b/gen/python/wire/base.py @@ -0,0 +1,26 @@ +"""Base class for every generated model. + +All generated models inherit this, so it is the single place to configure model-wide +behavior and the one point an application extends to add or relax behavior across every +model at once. Hand-written; not regenerated. +""" +from typing import Any + +from pydantic import BaseModel, ConfigDict + + +class WireModel(BaseModel): + # Forward-compatible: fields from a newer protocol version are ignored, not + # rejected. A consumer that wants strictness sets extra="forbid" in its own + # subclass. + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + def model_dump(self, **kwargs: Any) -> dict[str, Any]: + # Proto-JSON omits unset optional fields; default to the same so parse → dump + # round-trips match the wire. Pass exclude_none=False to include nulls. + kwargs.setdefault("exclude_none", True) + return super().model_dump(**kwargs) + + def model_dump_json(self, **kwargs: Any) -> str: + kwargs.setdefault("exclude_none", True) + return super().model_dump_json(**kwargs) diff --git a/gen/python/wire/models.py b/gen/python/wire/models.py new file mode 100644 index 00000000..4c960c47 --- /dev/null +++ b/gen/python/wire/models.py @@ -0,0 +1,1685 @@ +# Code generated from the RAMP proto (via JSON Schema). DO NOT EDIT. +# Regenerate: scripts/gen-sdk-types.sh Base class / extension seam: wire/base.py + +from __future__ import annotations + +from typing import Any +from pydantic import AwareDatetime, Field, RootModel, conint, constr +from enum import Enum +from wire.base import WireModel + + + +class AuthMethod(Enum): + AUTH_METHOD_GNAP = 'AUTH_METHOD_GNAP' + AUTH_METHOD_OAUTH_DPOP = 'AUTH_METHOD_OAUTH_DPOP' + AUTH_METHOD_OAUTH_BEARER = 'AUTH_METHOD_OAUTH_BEARER' + AUTH_METHOD_OAUTH_MTLS = 'AUTH_METHOD_OAUTH_MTLS' + + +class C2PAStatus(Enum): + C2PA_STATUS_TRUSTED = 'C2PA_STATUS_TRUSTED' + C2PA_STATUS_VALID = 'C2PA_STATUS_VALID' + C2PA_STATUS_INVALID = 'C2PA_STATUS_INVALID' + C2PA_STATUS_ABSENT = 'C2PA_STATUS_ABSENT' + + +class CatalogContributor(WireModel): + domain: str | None = Field( + '', + description='Canonical domain of the authorized contributor (e.g., "doubleverify.com").', + ) + relationship: str | None = Field( + '', + description='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).', + ) + + +class CatalogRejectionReason(Enum): + CATALOG_REJECTION_REASON_NOT_CATALOG_CONTRIBUTOR = ( + 'CATALOG_REJECTION_REASON_NOT_CATALOG_CONTRIBUTOR' + ) + CATALOG_REJECTION_REASON_TENANT_MISMATCH = ( + 'CATALOG_REJECTION_REASON_TENANT_MISMATCH' + ) + CATALOG_REJECTION_REASON_DOMAIN_NOT_VERIFIED = ( + 'CATALOG_REJECTION_REASON_DOMAIN_NOT_VERIFIED' + ) + CATALOG_REJECTION_REASON_SIGNATURE_INVALID = ( + 'CATALOG_REJECTION_REASON_SIGNATURE_INVALID' + ) + CATALOG_REJECTION_REASON_MALFORMED_ENTRY = ( + 'CATALOG_REJECTION_REASON_MALFORMED_ENTRY' + ) + CATALOG_REJECTION_REASON_UNKNOWN_VOCAB_TOKEN = ( + 'CATALOG_REJECTION_REASON_UNKNOWN_VOCAB_TOKEN' + ) + CATALOG_REJECTION_REASON_QUOTA_EXCEEDED = 'CATALOG_REJECTION_REASON_QUOTA_EXCEEDED' + + +class CitationFormat(Enum): + CITATION_FORMAT_LINK = 'CITATION_FORMAT_LINK' + CITATION_FORMAT_FOOTNOTE = 'CITATION_FORMAT_FOOTNOTE' + CITATION_FORMAT_INLINE = 'CITATION_FORMAT_INLINE' + + +class Cost(WireModel): + amount: constr(pattern=r'^([0-9]+([.][0-9]+)?)?$', max_length=32) | None = Field( + '', + description='Exact decimal string (not a float), e.g. "19.99". Denominated in `currency`.', + ) + currency: str | None = '' + unit_cost: constr(pattern=r'^([0-9]+([.][0-9]+)?)?$', max_length=32) | None = None + + +class Delegation(WireModel): + expires_at: AwareDatetime | None = Field( + None, + description='When this delegation expires. Exchange MUST reject expired tokens.', + ) + ext: dict[str, Any] | None = Field(None, description='Extension point') + ext_critical: list[str] | None = Field( + None, + description='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.', + ) + issuer: str | None = Field( + None, + description='Token issuer. OIDC issuer URL or GNAP grant server URL.\n Exchange uses this for JWT validation (OIDC discovery → JWKS)\n or GNAP token introspection.', + ) + max_accesses: conint(ge=-2147483648, le=2147483647) | None = Field( + None, + description='Maximum number of accesses allowed under this delegation.\n Exchange tracks cumulative access count against this cap.\n Deny with DENIAL_REASON_QUOTA_EXCEEDED when count >= limit.\n For subscriptions with "10,000 accesses/month", this carries the ceiling.', + ) + max_spend_cents: int | None = Field( + None, + description='Maximum spend in currency minor units (e.g., cents for USD).\n Exchange tracks cumulative spend against this cap.', + ) + principal_domain: str | None = Field( + '', description='Who granted this delegation (domain for public key lookup).' + ) + principal_id: str | None = Field( + '', + description='Principal\'s identifier (e.g., "user@acme.com", "marketdata.example.com").', + ) + quota_period: str | None = Field( + None, + description='Quota reset period. How often the access/spend counters reset.\n Example: 720h (30 days) for monthly subscriptions.\n When absent, the quota is lifetime (bounded only by expires_at).', + ) + revocation_uri: str | None = Field( + None, + description='Optional: URI for real-time revocation checking.\n Exchange MAY check this for high-value transactions.\n Not checked for routine low-value access (performance tradeoff).', + ) + scopes: list[str] | None = Field( + None, + description="Scopes granted by this delegation. MUST be a subset of the\n principal's own scopes (attenuation — can only narrow, not widen).", + ) + token: constr(pattern=r'^[A-Za-z0-9+/]*={0,2}$') | None = Field( + '', + description='Token bytes. A JWT (base64url-encoded JWS) by default, or a Biscuit (binary,\n base64-encoded) when token_format is "biscuit-v3".', + ) + token_format: str | None = Field( + '', + description='Token format: "jwt" (default) or "biscuit-v3" (optional, for deep\n multi-hop offline attenuation). Empty is treated as "jwt".', + ) + + +class DeliveryMethod(Enum): + DELIVERY_METHOD_DIRECT = 'DELIVERY_METHOD_DIRECT' + DELIVERY_METHOD_INSTRUCTIONS = 'DELIVERY_METHOD_INSTRUCTIONS' + DELIVERY_METHOD_STREAMING = 'DELIVERY_METHOD_STREAMING' + + +class DenialReason(Enum): + DENIAL_REASON_BILLING_REF_INACTIVE = 'DENIAL_REASON_BILLING_REF_INACTIVE' + DENIAL_REASON_INSUFFICIENT_BALANCE = 'DENIAL_REASON_INSUFFICIENT_BALANCE' + DENIAL_REASON_RATE_LIMITED = 'DENIAL_REASON_RATE_LIMITED' + DENIAL_REASON_CONTENT_UNAVAILABLE = 'DENIAL_REASON_CONTENT_UNAVAILABLE' + DENIAL_REASON_RESTRICTION_NOT_SATISFIED = 'DENIAL_REASON_RESTRICTION_NOT_SATISFIED' + DENIAL_REASON_REPORTING_OVERDUE = 'DENIAL_REASON_REPORTING_OVERDUE' + DENIAL_REASON_OFFER_EXPIRED = 'DENIAL_REASON_OFFER_EXPIRED' + DENIAL_REASON_SIGNATURE_INVALID = 'DENIAL_REASON_SIGNATURE_INVALID' + DENIAL_REASON_QUOTA_EXCEEDED = 'DENIAL_REASON_QUOTA_EXCEEDED' + DENIAL_REASON_DELEGATION_INVALID = 'DENIAL_REASON_DELEGATION_INVALID' + DENIAL_REASON_SCOPE_INSUFFICIENT = 'DENIAL_REASON_SCOPE_INSUFFICIENT' + DENIAL_REASON_ENTITLEMENT_MISSING = 'DENIAL_REASON_ENTITLEMENT_MISSING' + DENIAL_REASON_ENTITLEMENT_MALFORMED = 'DENIAL_REASON_ENTITLEMENT_MALFORMED' + DENIAL_REASON_ENTITLEMENT_EXPIRED = 'DENIAL_REASON_ENTITLEMENT_EXPIRED' + DENIAL_REASON_ENTITLEMENT_WRONG_BUYER = 'DENIAL_REASON_ENTITLEMENT_WRONG_BUYER' + DENIAL_REASON_SUBSCRIPTION_LAPSED = 'DENIAL_REASON_SUBSCRIPTION_LAPSED' + DENIAL_REASON_ENTITLEMENT_NOT_GRANTED = 'DENIAL_REASON_ENTITLEMENT_NOT_GRANTED' + DENIAL_REASON_ENTITLEMENT_STALE_ATTENUATION = ( + 'DENIAL_REASON_ENTITLEMENT_STALE_ATTENUATION' + ) + + +class DiscoveryMethod(Enum): + DISCOVERY_METHOD_EXCHANGE = 'DISCOVERY_METHOD_EXCHANGE' + DISCOVERY_METHOD_SEARCH = 'DISCOVERY_METHOD_SEARCH' + DISCOVERY_METHOD_RECOMMENDATION = 'DISCOVERY_METHOD_RECOMMENDATION' + DISCOVERY_METHOD_SYNDICATION = 'DISCOVERY_METHOD_SYNDICATION' + + +class DisputeFailureReason(Enum): + DISPUTE_FAILURE_REASON_TRANSACTION_NOT_FOUND = ( + 'DISPUTE_FAILURE_REASON_TRANSACTION_NOT_FOUND' + ) + DISPUTE_FAILURE_REASON_REPORT_NOT_FILED = 'DISPUTE_FAILURE_REASON_REPORT_NOT_FILED' + DISPUTE_FAILURE_REASON_WINDOW_EXPIRED = 'DISPUTE_FAILURE_REASON_WINDOW_EXPIRED' + DISPUTE_FAILURE_REASON_DUPLICATE = 'DISPUTE_FAILURE_REASON_DUPLICATE' + DISPUTE_FAILURE_REASON_INELIGIBLE = 'DISPUTE_FAILURE_REASON_INELIGIBLE' + + +class DisputeReason(Enum): + DISPUTE_REASON_CONTENT_MISMATCH = 'DISPUTE_REASON_CONTENT_MISMATCH' + DISPUTE_REASON_DELIVERY_FAILED = 'DISPUTE_REASON_DELIVERY_FAILED' + DISPUTE_REASON_WRONG_CONTENT = 'DISPUTE_REASON_WRONG_CONTENT' + DISPUTE_REASON_EXPIRED_BEFORE_FETCH = 'DISPUTE_REASON_EXPIRED_BEFORE_FETCH' + DISPUTE_REASON_INCOMPLETE_CONTENT = 'DISPUTE_REASON_INCOMPLETE_CONTENT' + + +class DisputeRequest(WireModel): + billing_id: str | None = Field( + '', description='Billing reference from the transaction.' + ) + description: str | None = Field( + None, description='Human-readable description of the issue.' + ) + ext: dict[str, Any] | None = Field(None, description='Extension point') + ext_critical: list[str] | None = Field( + None, + description='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.', + ) + idempotency_key: constr(min_length=1, max_length=255) = Field( + ..., + description="Idempotency key (REQUIRED). The server MUST dedupe on this so a replayed\n filing does not open a duplicate case. The dispute's durable identity is the\n Exchange-assigned dispute_id in DisputeResponse.\n Uniqueness is scoped to the verified RFC 9421 signer: the server dedupes per\n (authenticated caller, key), never globally, so a key chosen by one caller\n cannot collide with another's cached result.", + ) + reason: DisputeReason = Field(..., description='Reason for the dispute.') + received_content_hash: str | None = Field( + None, + description='Evidence: content hash of what was actually received.\n Exchange compares against the hash promised in ResourceIdentity.', + ) + received_hash_method: str | None = Field( + None, description='Hash algorithm the agent used' + ) + report_id: str | None = Field( + '', + description='Must reference a filed UsageReport. The agent MUST file a UsageReport\n (via ReportUsage RPC) and receive a report_id BEFORE filing a dispute.\n This prevents fire-and-forget disputes and ensures the Exchange has\n the complete evidence chain: what was offered, what was transacted,\n what the agent reported using, and what the agent disputes.\n The dispute chain: Transaction → UsageReport → Dispute.', + ) + transaction_id: str | None = Field('', description='Transaction being disputed.') + ver: str | None = Field('', description='Protocol version') + + +class DisputeStatus(Enum): + DISPUTE_STATUS_FILED = 'DISPUTE_STATUS_FILED' + DISPUTE_STATUS_AUTO_RESOLVED = 'DISPUTE_STATUS_AUTO_RESOLVED' + DISPUTE_STATUS_EVIDENCE_NEEDED = 'DISPUTE_STATUS_EVIDENCE_NEEDED' + DISPUTE_STATUS_UNDER_REVIEW = 'DISPUTE_STATUS_UNDER_REVIEW' + DISPUTE_STATUS_ESCALATED = 'DISPUTE_STATUS_ESCALATED' + DISPUTE_STATUS_RESOLVED = 'DISPUTE_STATUS_RESOLVED' + DISPUTE_STATUS_APPEALED = 'DISPUTE_STATUS_APPEALED' + DISPUTE_STATUS_SETTLED = 'DISPUTE_STATUS_SETTLED' + DISPUTE_STATUS_FINAL = 'DISPUTE_STATUS_FINAL' + + +class DomainVerificationChallenge(WireModel): + expires_at: AwareDatetime | None = Field( + None, + description='When this challenge expires. Provider must confirm before this time.', + ) + ext: dict[str, Any] | None = Field(None, description='Extension point') + ext_critical: list[str] | None = Field( + None, + description='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.', + ) + token: str | None = Field( + '', + description='Opaque challenge token. Provider must serve this at:\n https://{domain}/.well-known/ramp-verify/{token}', + ) + ver: str | None = Field('', description='Protocol version') + verification_url: str | None = Field( + '', description='The exact URL the Exchange will fetch to verify.' + ) + + +class DomainVerificationConfirmation(WireModel): + cdn_type: str | None = Field(None, description='CDN type this key is for.') + domain: str | None = Field('', description='The domain being verified.') + ext: dict[str, Any] | None = Field(None, description='Extension point') + ext_critical: list[str] | None = Field( + None, + description='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.', + ) + signing_key: str | None = Field( + None, + description='Optional: signing key to register upon successful verification.\n If present, the key is registered atomically with verification.\n Key format depends on CDN type (PEM for CloudFront, hex for HMAC).', + ) + token: str | None = Field( + '', description='The challenge token (echoed from DomainVerificationChallenge).' + ) + ver: str | None = Field('', description='Protocol version') + + +class DomainVerificationFailureReason(Enum): + DOMAIN_VERIFICATION_FAILURE_REASON_CHALLENGE_NOT_FOUND = ( + 'DOMAIN_VERIFICATION_FAILURE_REASON_CHALLENGE_NOT_FOUND' + ) + DOMAIN_VERIFICATION_FAILURE_REASON_CHALLENGE_MISMATCH = ( + 'DOMAIN_VERIFICATION_FAILURE_REASON_CHALLENGE_MISMATCH' + ) + DOMAIN_VERIFICATION_FAILURE_REASON_CHALLENGE_EXPIRED = ( + 'DOMAIN_VERIFICATION_FAILURE_REASON_CHALLENGE_EXPIRED' + ) + DOMAIN_VERIFICATION_FAILURE_REASON_FETCH_FAILED = ( + 'DOMAIN_VERIFICATION_FAILURE_REASON_FETCH_FAILED' + ) + DOMAIN_VERIFICATION_FAILURE_REASON_EXCHANGE_NOT_AUTHORIZED = ( + 'DOMAIN_VERIFICATION_FAILURE_REASON_EXCHANGE_NOT_AUTHORIZED' + ) + DOMAIN_VERIFICATION_FAILURE_REASON_KEY_REGISTRATION_FAILED = ( + 'DOMAIN_VERIFICATION_FAILURE_REASON_KEY_REGISTRATION_FAILED' + ) + + +class DomainVerificationRequest(WireModel): + caller_id: str | None = Field( + None, description='Caller identity (registered with the Exchange).' + ) + domain: str | None = Field( + '', description='The provider domain to verify (e.g., "techcrunch.com").' + ) + ext: dict[str, Any] | None = Field(None, description='Extension point') + ext_critical: list[str] | None = Field( + None, + description='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.', + ) + ver: str | None = Field('', description='Protocol version') + + +class DomainVerificationResult(WireModel): + ext: dict[str, Any] | None = Field(None, description='Extension point') + ext_critical: list[str] | None = Field( + None, + description='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.', + ) + key_id: str | None = Field( + None, + description='If signing_key was provided: confirmation of key registration.', + ) + valid_until: AwareDatetime | None = Field( + None, + description='Verification is valid until this time. Provider must re-verify periodically.', + ) + ver: str | None = Field('', description='Protocol version') + + +class IngestionSource(Enum): + INGESTION_SOURCE_RAMP_SITEMAP = 'INGESTION_SOURCE_RAMP_SITEMAP' + INGESTION_SOURCE_RSL = 'INGESTION_SOURCE_RSL' + INGESTION_SOURCE_SITEMAP = 'INGESTION_SOURCE_SITEMAP' + INGESTION_SOURCE_HTML_CRAWL = 'INGESTION_SOURCE_HTML_CRAWL' + INGESTION_SOURCE_CMS_API = 'INGESTION_SOURCE_CMS_API' + INGESTION_SOURCE_MANUAL = 'INGESTION_SOURCE_MANUAL' + INGESTION_SOURCE_CATALOG_API = 'INGESTION_SOURCE_CATALOG_API' + + +class JsonWebKey(WireModel): + alg: str | None = Field( + '', description='Signing algorithm. RAMP v1.0: MUST be "EdDSA".' + ) + crv: str | None = Field('', description='Curve. RAMP v1.0: MUST be "Ed25519".') + kty: str | None = Field('', description='Key type. RAMP v1.0: MUST be "OKP".') + not_after: str | None = Field( + '', + description='RFC3339 timestamp. Key is invalid at and after this instant\n (strict upper bound).', + ) + not_before: str | None = Field( + '', description='RFC3339 timestamp. Key is invalid before this instant.' + ) + use: str | None = Field( + '', description='Intended key use. RAMP v1.0: MUST be "sig".' + ) + x: str | None = Field( + '', description='base64url-encoded 32-byte Ed25519 public key.' + ) + + +class KeyRevocationList(WireModel): + as_of: AwareDatetime | None = Field( + None, + description="Server's response time (RFC3339, UTC). Consumers use this to detect\n clock skew.", + ) + revoked: list[str] | None = Field( + None, + description='Complete list of revoked key thumbprints (RFC 7638, base64url-no-pad) at\n `as_of`.', + ) + + +class License(WireModel): + id: str | None = Field( + None, + description='Stable short identifier: SPDX short-id ("GPL-3.0-only"), TollBit cuid,\n or catalog doc-id. Used by agents and the vocab linter for known-license\n lookup; SHARE_ALIKE derivatives default their scope_license to this.', + ) + immutable: bool | None = Field( + None, + description='Data-labels TDL: the document at uri is versioned and will not change.', + ) + name: str | None = Field( + None, description='Human-readable name (licenseType, schema.org node name).' + ) + uri: str | None = Field( + None, + description='"MUST NOT URL-validate" means do not REJECT non-URL schemes — it does NOT\n mean fetch blindly. A consumer that dereferences this URI MUST apply the\n SSRF countermeasures in the security threat model (T-LIC-1): scheme\n allowlist, block loopback/private/metadata addresses (resolve-then-check),\n fetch via an egress proxy, and treat the response as untrusted content.\n Verify the fetched bytes against `uri_digest` before use.', + ) + uri_digest: ( + constr( + pattern=r'^(sha256:[0-9a-f]{64}|sha384:[0-9a-f]{96}|sha512:[0-9a-f]{128})?$' + ) + | None + ) = Field( + None, + description='The method MUST be a collision-resistant hash — sha256, sha384, or sha512.\n Legacy md5/sha1 are rejected on the wire: a forgeable digest would defeat\n the swap-protection this field exists for. The CEL is STRUCTURE ONLY\n (allowlisted prefix + matching hex length); presence (digest-when-uri) is\n enforced at ingest.', + ) + + +class ObligationKind(Enum): + OBLIGATION_KIND_ATTRIBUTION = 'OBLIGATION_KIND_ATTRIBUTION' + OBLIGATION_KIND_CONTRIBUTION = 'OBLIGATION_KIND_CONTRIBUTION' + OBLIGATION_KIND_SHARE_ALIKE = 'OBLIGATION_KIND_SHARE_ALIKE' + OBLIGATION_KIND_NETWORK_COPYLEFT = 'OBLIGATION_KIND_NETWORK_COPYLEFT' + OBLIGATION_KIND_NOTICE = 'OBLIGATION_KIND_NOTICE' + OBLIGATION_KIND_OTHER = 'OBLIGATION_KIND_OTHER' + + +class ObligationTrigger(Enum): + OBLIGATION_TRIGGER_ON_USE = 'OBLIGATION_TRIGGER_ON_USE' + OBLIGATION_TRIGGER_ON_DISTRIBUTION = 'OBLIGATION_TRIGGER_ON_DISTRIBUTION' + OBLIGATION_TRIGGER_ON_NETWORK_SERVICE = 'OBLIGATION_TRIGGER_ON_NETWORK_SERVICE' + OBLIGATION_TRIGGER_ON_DERIVATIVE = 'OBLIGATION_TRIGGER_ON_DERIVATIVE' + + +class OfferAbsenceReason(Enum): + OFFER_ABSENCE_REASON_NOT_IN_CATALOG = 'OFFER_ABSENCE_REASON_NOT_IN_CATALOG' + OFFER_ABSENCE_REASON_CONTENT_BLOCKED = 'OFFER_ABSENCE_REASON_CONTENT_BLOCKED' + OFFER_ABSENCE_REASON_RESTRICTION_FILTERED = ( + 'OFFER_ABSENCE_REASON_RESTRICTION_FILTERED' + ) + OFFER_ABSENCE_REASON_TEMPORARILY_UNAVAILABLE = ( + 'OFFER_ABSENCE_REASON_TEMPORARILY_UNAVAILABLE' + ) + OFFER_ABSENCE_REASON_NOT_AUTHORIZED = 'OFFER_ABSENCE_REASON_NOT_AUTHORIZED' + OFFER_ABSENCE_REASON_SCOPE_INSUFFICIENT = 'OFFER_ABSENCE_REASON_SCOPE_INSUFFICIENT' + OFFER_ABSENCE_REASON_UNKNOWN_CRITICAL_EXTENSION = ( + 'OFFER_ABSENCE_REASON_UNKNOWN_CRITICAL_EXTENSION' + ) + OFFER_ABSENCE_REASON_BUDGET_EXCEEDED = 'OFFER_ABSENCE_REASON_BUDGET_EXCEEDED' + + +class Preview(WireModel): + duration: conint(ge=-2147483648, le=2147483647) | None = Field( + None, description='Duration in seconds (for audio and video clips).' + ) + height: conint(ge=-2147483648, le=2147483647) | None = Field( + None, description='Height in pixels (images and video)' + ) + media_type: str | None = Field( + '', + description='MIME type of the preview.\n Examples: "image/jpeg", "image/webp", "audio/mpeg", "video/mp4",\n "text/plain", "application/json"', + ) + size: str | None = Field( + None, + description='Size category hint. Agents use this to select the right preview\n without fetching all of them.\n Standard values:\n "thumbnail" — smallest useful preview (100–150px or 5–10s)\n "preview" — mid-size for evaluation (300–500px or 15–30s)\n "sample" — larger / more detailed (for data: 1–3 sample records)', + ) + url: str | None = Field( + '', + description="URL to a preview asset (thumbnail, clip, snippet, sample).\n Served by the provider's CDN, not by the Exchange.", + ) + width: conint(ge=-2147483648, le=2147483647) | None = Field( + None, description='Dimensions in pixels (for images and video).' + ) + + +class PricingMetering(Enum): + PRICING_METERING_ONLINE = 'PRICING_METERING_ONLINE' + PRICING_METERING_NONE = 'PRICING_METERING_NONE' + PRICING_METERING_OFFLINE_SELF_REPORTED = 'PRICING_METERING_OFFLINE_SELF_REPORTED' + + +class PricingModel(Enum): + PRICING_MODEL_FREE = 'PRICING_MODEL_FREE' + PRICING_MODEL_PER_UNIT = 'PRICING_MODEL_PER_UNIT' + PRICING_MODEL_FLAT = 'PRICING_MODEL_FLAT' + + +class ProviderRelationship(Enum): + PROVIDER_RELATIONSHIP_DIRECT = 'PROVIDER_RELATIONSHIP_DIRECT' + PROVIDER_RELATIONSHIP_RESELLER = 'PROVIDER_RELATIONSHIP_RESELLER' + + +class PushResourcesResponse(WireModel): + accepted: conint(ge=-2147483648, le=2147483647) | None = Field( + None, description='Number of entries accepted' + ) + ext: dict[str, Any] | None = Field(None, description='Extension point') + ext_critical: list[str] | None = Field( + None, + description='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.', + ) + rejected: conint(ge=-2147483648, le=2147483647) | None = Field( + None, description='Number of entries rejected' + ) + ver: str | None = Field('', description='Protocol version') + warnings: list[str] | None = Field( + None, + description='Non-fatal issues encountered during ingestion.\n Examples: unrecognized vocab token in a Restriction (term accepted but flagged),\n REFERENCE_ONLY term missing License.uri (informational).\n Warnings do not cause rejection — they are surfaced so publishers can fix\n their feeds without a hard failure.', + ) + + +class QuotaWindow(Enum): + QUOTA_WINDOW_HOURLY = 'QUOTA_WINDOW_HOURLY' + QUOTA_WINDOW_DAILY = 'QUOTA_WINDOW_DAILY' + QUOTA_WINDOW_MONTHLY = 'QUOTA_WINDOW_MONTHLY' + QUOTA_WINDOW_TOTAL = 'QUOTA_WINDOW_TOTAL' + + +class RateLimitInfo(WireModel): + limit: conint(ge=-2147483648, le=2147483647) | None = Field( + None, description='Maximum requests allowed in the current window.' + ) + remaining: conint(ge=-2147483648, le=2147483647) | None = Field( + None, description='Requests remaining in the current window.' + ) + reset_at: AwareDatetime | None = Field( + None, + description='When the current window resets (UTC). After this time, `remaining` resets to `limit`.', + ) + window: str | None = Field( + None, + description='Duration of the rate limit window (e.g. 60s = per-minute limit).', + ) + + +class RefreshCatalogRequest(WireModel): + tenant_id: str | None = Field('', description='Tenant identifier') + ver: str | None = Field('', description='Protocol version') + + +class RefreshCatalogResponse(WireModel): + started: bool | None = Field(False, description='Whether the refresh was started') + ver: str | None = Field('', description='Protocol version') + + +class RegistrationFailureReason(Enum): + REGISTRATION_FAILURE_REASON_DOMAIN_NOT_VERIFIED = ( + 'REGISTRATION_FAILURE_REASON_DOMAIN_NOT_VERIFIED' + ) + REGISTRATION_FAILURE_REASON_INVALID_KEY = 'REGISTRATION_FAILURE_REASON_INVALID_KEY' + REGISTRATION_FAILURE_REASON_SIGNATURE_INVALID = ( + 'REGISTRATION_FAILURE_REASON_SIGNATURE_INVALID' + ) + REGISTRATION_FAILURE_REASON_ALREADY_REGISTERED = ( + 'REGISTRATION_FAILURE_REASON_ALREADY_REGISTERED' + ) + REGISTRATION_FAILURE_REASON_QUOTA_EXCEEDED = ( + 'REGISTRATION_FAILURE_REASON_QUOTA_EXCEEDED' + ) + + +class RemoveResourcesRequest(WireModel): + paths: list[str] | None = Field(None, description='Paths to remove') + tenant_id: str | None = Field('', description='Tenant identifier') + ver: str | None = Field('', description='Protocol version') + + +class RemoveResourcesResponse(WireModel): + removed: conint(ge=-2147483648, le=2147483647) | None = Field( + None, description='Number of entries removed' + ) + ver: str | None = Field('', description='Protocol version') + + +class ReportingObligation(WireModel): + endpoint: str | None = Field( + None, + description='URL to submit the usage report to (if different from Exchange).', + ) + ext: dict[str, Any] | None = Field(None, description='Extension point') + ext_critical: list[str] | None = Field( + None, + description='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.', + ) + required: bool | None = Field( + False, description='Whether post-usage reporting is required.' + ) + required_fields: list[str] | None = Field( + None, description='Field names that must be present in the report.' + ) + window: str | None = Field( + None, + description='Duration within which the report must be submitted (e.g. 24h).', + ) + + +class RequestConstraints(WireModel): + budget_period: str | None = Field( + None, + description='Budget period (e.g. 720h = 30 days). Resets at period boundary.', + ) + budget_scope: str | None = Field( + None, + description='Budget scope identifier for per-period tracking.\n E.g. "user:u-12345" for per-user budgets, "team:eng" for per-team.\n The Broker tracks cumulative spend per scope across sessions.', + ) + delivery_preference: list[DeliveryMethod] | None = Field( + None, description='Preferred delivery methods, in order of preference.' + ) + exchanges: list[str] | None = Field( + None, description='Authorized Exchange domains. Broker queries only these.' + ) + max_data_age: str | None = Field( + None, + description='Only relevant for DYNAMIC resources. Ignored for STATIC (content is\n immutable) and LIVE (content doesn\'t exist yet).\n\n Examples:\n 7 days — "credit report updated within the last week"\n 1 hour — "stock snapshot from the last hour"\n 30 days — "drug interaction database updated this month"', + ) + max_hops: conint(ge=-2147483648, le=2147483647) | None = Field( + None, + description="Maximum forwarding hops the agent will allow (Agent → Broker → … →\n Exchange), counted as the number of RFC 9421 HTTP Message Signatures on the\n request. Caps chain depth so a request is not relayed through more brokers\n than the agent is willing to trust or pay. A Broker MUST NOT forward a\n request whose signature count would exceed this. Absent = agent imposes no\n cap (the Exchange's max_intermediary_hops still applies).", + ) + max_price: Cost | None = Field( + None, description='Maximum price the agent is willing to pay.' + ) + max_unit_cost: constr(pattern=r'^([0-9]+([.][0-9]+)?)?$', max_length=32) | None = ( + Field( + None, + description='Maximum effective cost per unit, as an exact decimal string (not a float).', + ) + ) + period_budget: Cost | None = Field( + None, + description='Per-period budget limit. The Broker tracks spend against this\n for the budget_scope. Transactions that would exceed are denied.', + ) + preferred_exchanges: list[str] | None = Field( + None, + description='Exchanges the agent has existing relationships with (subscriptions,\n contracts). The Broker SHOULD prefer these when resource is\n available — subscription resource has zero marginal cost.', + ) + reporting_capable: bool | None = Field( + None, description='Whether the agent supports post-usage reporting.' + ) + + +class RequesterType(Enum): + REQUESTER_TYPE_AGENT = 'REQUESTER_TYPE_AGENT' + REQUESTER_TYPE_HUMAN_TOOL = 'REQUESTER_TYPE_HUMAN_TOOL' + REQUESTER_TYPE_SERVICE = 'REQUESTER_TYPE_SERVICE' + REQUESTER_TYPE_DELEGATED = 'REQUESTER_TYPE_DELEGATED' + REQUESTER_TYPE_RESEARCH = 'REQUESTER_TYPE_RESEARCH' + + +class ResolutionType(Enum): + RESOLUTION_TYPE_CREDIT = 'RESOLUTION_TYPE_CREDIT' + RESOLUTION_TYPE_REDELIVERY = 'RESOLUTION_TYPE_REDELIVERY' + RESOLUTION_TYPE_REJECTED = 'RESOLUTION_TYPE_REJECTED' + RESOLUTION_TYPE_INVESTIGATION = 'RESOLUTION_TYPE_INVESTIGATION' + + +class ResourceAttestation(WireModel): + attested_at: AwareDatetime | None = Field( + None, + description='When this attestation was created. Agents use this to assess freshness\n (e.g., "I accept attestations up to N hours old for breaking news").', + ) + claims: dict[str, Any] | None = Field( + None, + description='Signed claims about the resource (max 4KB). A JSON object containing\n whatever properties the attesting party can determine about the resource.\n Recommended claim names for interoperability:\n estimated_quantity (integer): estimated consumption quantity (e.g., token count for text)\n word_count (integer): word count (estimated_quantity ~ word_count * 1.32 for text)\n language (string): ISO 639-1 language code\n iab_categories (string[]): IAB Content Taxonomy 3.1 codes\n content_hash (string): hash of content in "method:hexdigest" format\n hash_method (string): algorithm used for content_hash\n Vendors MAY add vendor-specific claims (e.g., brand_safety, sentiment).\n The protocol does NOT define "quality score" — it is inherently subjective.\n If a vendor provides a proprietary score, the vendor defines what it means\n via their WellKnownManifest ext["ramp.attestation.claims_schema"].', + ) + keyid: str | None = Field( + '', + description="RFC 7638 JWK Thumbprint (the RFC 9421 keyid) of the verifier's\n attestation-signing key, resolved against the verifier's WBA directory\n (WBAFile.keys). Identifies which Ed25519 key signed this attestation.\n Enables key rotation: new keys are published with overlapping validity,\n new attestations use the new key's thumbprint, old attestations remain\n verifiable while the old key is still published.", + ) + signature: str | None = Field( + '', + description='Ed25519 signature over JCS-canonicalized (RFC 8785) representation of\n {verifier, keyid, attested_at, uri, claims}. JCS (JSON Canonicalization\n Scheme) produces deterministic UTF-8 bytes: lexicographic key sorting,\n ECMAScript number serialization, strict string escaping, no whitespace.\n Each attestation is self-contained — new claim fields do not invalidate\n old attestations because the signature covers the specific claims instance.', + ) + uri: str | None = Field( + '', + description='The resource URI this attestation covers. Must match the URI in the\n Offer or ResourceEntry this attestation is attached to.', + ) + verifier: str | None = Field( + '', + description='Canonical domain of the attesting party (e.g., "nytimes.com" for\n self-attestation, "doubleverify.com" for third-party attestation).\n Used to look up the verifier\'s attestation-signing keys in its WBA\n directory (WBAFile.keys) at\n https://{verifier}/.well-known/http-message-signatures-directory', + ) + + +class ResourceMutability(Enum): + RESOURCE_MUTABILITY_STATIC = 'RESOURCE_MUTABILITY_STATIC' + RESOURCE_MUTABILITY_DYNAMIC = 'RESOURCE_MUTABILITY_DYNAMIC' + RESOURCE_MUTABILITY_LIVE = 'RESOURCE_MUTABILITY_LIVE' + + +class RestrictionKind(Enum): + RESTRICTION_KIND_FUNCTION = 'RESTRICTION_KIND_FUNCTION' + RESTRICTION_KIND_GEOGRAPHY = 'RESTRICTION_KIND_GEOGRAPHY' + RESTRICTION_KIND_USER_TYPE = 'RESTRICTION_KIND_USER_TYPE' + RESTRICTION_KIND_OTHER = 'RESTRICTION_KIND_OTHER' + + +class RetrievalAuthFailureReason(Enum): + RETRIEVAL_AUTH_FAILURE_REASON_URL_EXPIRED = ( + 'RETRIEVAL_AUTH_FAILURE_REASON_URL_EXPIRED' + ) + RETRIEVAL_AUTH_FAILURE_REASON_URL_SIGNATURE_MISSING = ( + 'RETRIEVAL_AUTH_FAILURE_REASON_URL_SIGNATURE_MISSING' + ) + RETRIEVAL_AUTH_FAILURE_REASON_URL_EXPIRY_MISSING = ( + 'RETRIEVAL_AUTH_FAILURE_REASON_URL_EXPIRY_MISSING' + ) + RETRIEVAL_AUTH_FAILURE_REASON_URL_SIGNATURE_MISMATCH = ( + 'RETRIEVAL_AUTH_FAILURE_REASON_URL_SIGNATURE_MISMATCH' + ) + RETRIEVAL_AUTH_FAILURE_REASON_AGENT_KEY_MISSING = ( + 'RETRIEVAL_AUTH_FAILURE_REASON_AGENT_KEY_MISSING' + ) + RETRIEVAL_AUTH_FAILURE_REASON_PROOF_SIGNATURE_MISSING = ( + 'RETRIEVAL_AUTH_FAILURE_REASON_PROOF_SIGNATURE_MISSING' + ) + RETRIEVAL_AUTH_FAILURE_REASON_KEYID_MISMATCH = ( + 'RETRIEVAL_AUTH_FAILURE_REASON_KEYID_MISMATCH' + ) + RETRIEVAL_AUTH_FAILURE_REASON_THUMBPRINT_MISMATCH = ( + 'RETRIEVAL_AUTH_FAILURE_REASON_THUMBPRINT_MISMATCH' + ) + RETRIEVAL_AUTH_FAILURE_REASON_PROOF_CREATED_MISSING = ( + 'RETRIEVAL_AUTH_FAILURE_REASON_PROOF_CREATED_MISSING' + ) + RETRIEVAL_AUTH_FAILURE_REASON_PROOF_EXPIRY_MISSING = ( + 'RETRIEVAL_AUTH_FAILURE_REASON_PROOF_EXPIRY_MISSING' + ) + RETRIEVAL_AUTH_FAILURE_REASON_PROOF_EXPIRED = ( + 'RETRIEVAL_AUTH_FAILURE_REASON_PROOF_EXPIRED' + ) + RETRIEVAL_AUTH_FAILURE_REASON_PROOF_SIGNATURE_INVALID = ( + 'RETRIEVAL_AUTH_FAILURE_REASON_PROOF_SIGNATURE_INVALID' + ) + + +class Role(Enum): + ROLE_AGENT = 'ROLE_AGENT' + ROLE_EXCHANGE = 'ROLE_EXCHANGE' + ROLE_BROKER = 'ROLE_BROKER' + ROLE_PUBLISHER = 'ROLE_PUBLISHER' + + +class SubscriptionQuotaInfo(WireModel): + quota_limit: conint(ge=-2147483648, le=2147483647) | None = Field( + None, description='Total allowed in the current period.' + ) + quota_remaining: conint(ge=-2147483648, le=2147483647) | None = Field( + None, description='Remaining in the current period.' + ) + quota_used: conint(ge=-2147483648, le=2147483647) | None = Field( + None, description='Used so far in the current period.' + ) + resets_at: AwareDatetime | None = Field( + None, description='When the quota counter resets (UTC).' + ) + subscription_id: str | None = Field( + '', description='Subscription this quota applies to.' + ) + unit: str | None = Field( + None, + description='What is being metered. Distinguishes access count quotas from\n spend quotas from burst limits.\n Standard values: "accesses", "tokens", "spend_cents", "burst"', + ) + + +class TermSemantics(Enum): + TERM_SEMANTICS_ENUMERATED = 'TERM_SEMANTICS_ENUMERATED' + TERM_SEMANTICS_REFERENCE_ONLY = 'TERM_SEMANTICS_REFERENCE_ONLY' + + +class TransactionDenial(WireModel): + offer_id: str | None = Field( + None, description='Batch mode: the offer this denial pertains to.' + ) + reason: DenialReason = Field( + ..., description='The denial reason (defined-only, non-zero)' + ) + restriction_mismatches: list[RestrictionKind] | None = Field( + None, + description='When reason = RESTRICTION_NOT_SATISFIED, the failed axes (same\n RestrictionKind vocabulary the terms use).', + ) + + +class TransactionItem(WireModel): + offer_id: str | None = Field( + '', description='The offer_id from the selected Offer.' + ) + offer_signature: str | None = Field( + '', + description="The selected Offer's `signature` (informally, the exchange signature).", + ) + + +class TransactionResultItem(WireModel): + billing_id: str | None = Field('', description='Billing reference.') + cost: Cost | None = Field(None, description='Cost for this item.') + delivery_method: ( + constr(pattern=r'^DELIVERY_METHOD_UNSPECIFIED$') + | DeliveryMethod + | conint(ge=-2147483648, le=2147483647) + | None + ) = Field(0, description='How resource is delivered for this item.') + denial_reason: DenialReason | None = Field( + None, description='Set if this specific item was denied (others may succeed).' + ) + expires_at: AwareDatetime | None = Field( + None, description='When retrieval_endpoint expires.' + ) + offer_id: str | None = Field('', description='The offer_id this result is for.') + reporting_obligation: ReportingObligation | None = Field( + None, description='Reporting requirements for this item.' + ) + resource_title: str | None = Field( + None, description='Resource title echoed from the Offer.' + ) + restriction_mismatches: list[RestrictionKind] | None = Field( + None, + description='When denial_reason = RESTRICTION_NOT_SATISFIED, the restriction axes the\n request failed, in the same RestrictionKind vocabulary the terms use.', + ) + retrieval_endpoint: str | None = Field( + None, + description="Signed retrieval URL for this item. Bound to the requesting agent's identity\n via the parent TransactionResponse.agent_identity_hash (shared across all\n batch items); expires at expires_at. Absent if this item was denied or its\n delivery_method is not signed-URL-based.", + ) + subscription_id: str | None = Field( + None, description='If under subscription, no per-request charge.' + ) + subscription_unit_value: Cost | None = Field( + None, + description='Computed per-unit cost for financial attribution on subscription transactions.\n Even when cost.amount="0" (subscription), this field carries the value\n of the access for accounting purposes (e.g., ASC 606 prepaid drawdown).', + ) + transaction_id: str | None = Field( + '', description='Exchange-assigned transaction identifier.' + ) + + +class UsageAsset(WireModel): + package_id: str | None = Field(None, description='Package identifier') + uri: str | None = Field('', description='Asset URI') + + +class UsageReportRejectionReason(Enum): + USAGE_REPORT_REJECTION_REASON_TRANSACTION_NOT_FOUND = ( + 'USAGE_REPORT_REJECTION_REASON_TRANSACTION_NOT_FOUND' + ) + USAGE_REPORT_REJECTION_REASON_DUPLICATE = 'USAGE_REPORT_REJECTION_REASON_DUPLICATE' + USAGE_REPORT_REJECTION_REASON_WINDOW_EXPIRED = ( + 'USAGE_REPORT_REJECTION_REASON_WINDOW_EXPIRED' + ) + USAGE_REPORT_REJECTION_REASON_MISSING_REQUIRED_FIELDS = ( + 'USAGE_REPORT_REJECTION_REASON_MISSING_REQUIRED_FIELDS' + ) + USAGE_REPORT_REJECTION_REASON_MALFORMED = 'USAGE_REPORT_REJECTION_REASON_MALFORMED' + + +class UsageReportResponse(WireModel): + ext: dict[str, Any] | None = Field(None, description='Extension point') + ext_critical: list[str] | None = Field( + None, + description='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.', + ) + report_id: str | None = Field( + '', + description='Exchange-assigned report identifier. Required for the dispute chain —\n the agent must reference this report_id in DisputeRequest to prove that\n a usage report was filed before disputing. The complete evidence chain:\n Offer → Transaction (transaction_id, billing_id)\n → UsageReport → UsageReportResponse (report_id)\n → DisputeRequest (transaction_id + report_id)', + ) + ver: str | None = Field('', description='Protocol version') + + +class WBAFile(WireModel): + keys: list[JsonWebKey] | None = Field( + None, + description='RFC 7517 JWK Set "keys" member. RAMP v1: Ed25519 (OKP) keys, each with\n not_before/not_after RAMP extension members.', + ) + revocation_url: str | None = Field( + None, + description='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.', + ) + + +class AcceptableRestriction(WireModel): + axis: ( + constr(pattern=r'^RESTRICTION_KIND_UNSPECIFIED$') + | RestrictionKind + | conint(ge=-2147483648, le=2147483647) + | None + ) = Field( + 0, + description='Which axis (same enum as Restriction.kind): FUNCTION / GEOGRAPHY /\n USER_TYPE / OTHER.', + ) + values: ( + list[constr(pattern=r'^[A-Za-z0-9._:*-]+$', min_length=1, max_length=64)] | None + ) = Field( + None, + description='The values the query operates within on this axis — same token vocabulary\n as the terms (e.g. FUNCTION ["ai-train"], GEOGRAPHY ["US", "EU"]).', + max_length=64, + ) + + +class AttributionDetail(WireModel): + displayed_url: str | None = Field( + None, description='URL displayed to the user as the attribution link.' + ) + format: CitationFormat | None = Field( + None, description='How the citation was presented.' + ) + visible_to_user: bool | None = Field( + None, description='Whether the attribution was visible to the end user.' + ) + + +class AuthorizedExchange(WireModel): + domain: str | None = Field('', description='Canonical domain of the Exchange.') + endpoint: str | None = Field('', description='RAMP ExchangeService endpoint URL.') + ext: dict[str, Any] | None = Field(None, description='Extension point') + ext_critical: list[str] | None = Field( + None, + description='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.', + ) + relationship: ProviderRelationship = Field( + ..., description='Relationship type (mirrors ads.txt DIRECT/RESELLER).' + ) + + +class CatalogRejection(WireModel): + reason: CatalogRejectionReason = Field( + ..., description='The rejection reason (defined-only, non-zero)' + ) + rejected_paths: list[str] | None = Field( + None, + description='For partial-batch failures: the entry paths that were rejected.', + ) + + +class DisputeFailure(WireModel): + reason: DisputeFailureReason = Field( + ..., description='The failure reason (defined-only, non-zero)' + ) + + +class DisputeResponse(WireModel): + dispute_id: str | None = Field( + None, description='Exchange-assigned dispute case identifier.' + ) + estimated_resolution: str | None = Field( + None, description='Expected resolution timeline.' + ) + ext: dict[str, Any] | None = Field(None, description='Extension point') + ext_critical: list[str] | None = Field( + None, + description='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.', + ) + resolution: ResolutionType | None = Field( + None, + description='Resolution outcome, populated when the dispute reaches a terminal\n state (RESOLVED, SETTLED, or FINAL). Absent while dispute is in\n progress (FILED, UNDER_REVIEW, ESCALATED, etc.).', + ) + status: ( + constr(pattern=r'^DISPUTE_STATUS_UNSPECIFIED$') + | DisputeStatus + | conint(ge=-2147483648, le=2147483647) + | None + ) = Field( + 0, + description='Current lifecycle status of the dispute. Tracks progression through\n the three-tier resolution process:\n Tier 1 (automated, <1s): FILED → AUTO_RESOLVED or EVIDENCE_NEEDED\n Tier 2 (rule-based, <24h): UNDER_REVIEW → RESOLVED\n Tier 3 (pattern investigation, async): ESCALATED → SETTLED → FINAL\n Losing party may appeal: RESOLVED → APPEALED → back to UNDER_REVIEW.', + ) + ver: str | None = Field('', description='Protocol version') + + +class DomainVerificationFailure(WireModel): + reason: DomainVerificationFailureReason = Field( + ..., description='The failure reason (defined-only, non-zero)' + ) + + +class Obligation(WireModel): + detail: str | None = Field( + None, + description='Free-form detail: attribution string, notice file URI, etc.\n OBLIGATION_KIND_OTHER without it → lint warning.', + ) + kind: ObligationKind = Field(..., description='What the agent must do.') + scope_license: License | None = Field( + None, + description="The license that derivatives must be released under. REQUIRED for\n SHARE_ALIKE (rejected if absent), where it MUST identify a license — set\n `id` (SPDX short-id, the common copyleft case, often the term's own\n License.id) and/or `uri`. Because it is a License, a referenced `uri`\n inherits the uri_digest swap-protection rule: a uri without a digest is\n rejected, exactly as for any other license reference.", + ) + trigger: ObligationTrigger = Field( + ..., description='When the obligation activates.' + ) + + +class Pricing(WireModel): + currency: str | None = Field( + '', description='ISO 4217 currency code (e.g. "USD", "EUR").' + ) + estimated_quantity: conint(ge=-2147483648, le=2147483647) | None = Field( + None, + description='Estimated quantity in the metering unit.\n For text: token count. For video: duration in seconds.\n For documents: page count. For data: record count.', + ) + license_duration_months: conint(ge=-2147483648, le=2147483647) | None = Field( + None, + description='License duration in months. How long the granted access remains valid.', + ) + metering: PricingMetering | None = Field( + None, + description='How usage is tracked for billing reconciliation.\n Absent = PRICING_METERING_ONLINE (default real-time tracking).\n NONE = one-time perpetual sale; no ReportUsage required after ExecuteTransaction.\n OFFLINE_SELF_REPORTED = agent self-reports physical-world consumption.', + ) + model: PricingModel = Field(..., description="Provider's pricing model.") + rate: constr(pattern=r'^([0-9]+([.][0-9]+)?)?$', max_length=32) | None = Field( + '', + description='Price in the provider\'s model, as an exact decimal string — e.g. "0.05" =\n $0.05 per article. NOT a float: money is decimal to avoid binary rounding and\n to allow arbitrary sub-cent precision (e.g. "0.0001234"). Denominated in `currency`.', + ) + unit: ( + constr( + pattern=r'^([a-z0-9-]+|[A-Za-z0-9._-]+:[A-Za-z0-9._-]+)?$', max_length=64 + ) + | None + ) = Field( + None, + description='The (ramp.v1.vocab) entries below are the SOLE authored source of the\n registered bare tokens. A buf plugin reads them structurally and emits the\n pricingunits constants + IsRegistered; ingest enforces membership from\n those. The CEL is STRUCTURE ONLY (empty / bare-form / vendor:namespaced) —\n it never lists the tokens, so it cannot drift from the registry.', + ) + unit_cost: constr(pattern=r'^([0-9]+([.][0-9]+)?)?$', max_length=32) | None = Field( + None, + description="Normalized cost per unit — the universal comparison metric, exact decimal string.\n For text: cost per token. For video: cost per second.\n For data: cost per record. For APIs: cost per call.\n Denominated in the Exchange's base_currency (from its WellKnownManifest).", + ) + + +class Quota(WireModel): + limit: conint(ge=1) = Field( + ..., + description='Maximum allowed value in the given window. A quota of 0 grants\n nothing — express "no access" by omitting the term, not a zero quota.', + ) + metric: constr( + pattern=r'^([a-z0-9-]+|[A-Za-z0-9._-]+:[A-Za-z0-9._-]+)$', max_length=64 + ) = Field( + ..., + description='The (ramp.v1.vocab) entries below are the SOLE authored source of the\n registered bare metric tokens. A buf plugin reads them structurally and\n emits the quotametrics constants + IsRegistered; ingest enforces membership\n from those. The CEL is STRUCTURE ONLY (non-empty bare token or\n vendor:namespaced) — it never lists the tokens, so it cannot drift.\n\n Token meanings:\n display-words Words of content text rendered to an end user.\n impressions Times the content is displayed to an end user.\n tokens LLM output tokens generated using this content.\n input-tokens LLM input tokens consumed from this content.\n units-manufactured Physical units manufactured from this design/pattern.\n accesses Distinct content access / retrieval events.\n copies Digital or physical copies produced.\n seats Distinct named users licensed to access the content.', + ) + window: QuotaWindow = Field( + ..., description='Time window over which the limit accumulates.' + ) + + +class RegistrationFailure(WireModel): + reason: RegistrationFailureReason = Field( + ..., description='The failure reason (defined-only, non-zero)' + ) + + +class Requester(WireModel): + billing_ref: str | None = Field( + None, + description="Opaque billing reference linking this requester to the Exchange's (and,\n through the Exchange, the publisher's) billing/accounting systems — e.g. a\n billing account, PO number, or cost center. NOT an entitlement or\n subscription credential: access is governed by scopes and delegation, and\n identity by the request signature. The Exchange uses it only for invoicing\n and cost attribution.", + ) + delegation: Delegation | None = Field( + None, + description='Optional delegation — present when the requester acts on behalf of\n another entity (user, organization, upstream agent).', + ) + domain: str | None = Field( + '', + description='Domain the requester belongs to — used for public key lookup.\n Keys published at {domain}/.well-known/ramp.json (WellKnownManifest, role=ROLE_AGENT).', + ) + ext: dict[str, Any] | None = Field(None, description='Extension point') + ext_critical: list[str] | None = Field( + None, + description='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.', + ) + id: str | None = Field( + '', description='Unique requester identifier (e.g., "agent-research-bot-001").' + ) + name: str | None = Field( + None, description='Human-readable name (e.g., "Acme Research Assistant").' + ) + scopes: list[str] | None = Field( + None, + description='The Exchange filters its catalog to resources matching these scopes.\n Resources outside the scopes are not returned — the requester never\n learns they exist. This is the enforcement mechanism for both enterprise\n RBAC and open-market subscription entitlements.\n\n Scope format: colon-separated segments, "{domain}:{permission}" or\n "{profile}:{permission}", optionally multi-segment ("dist:US:CA");\n matching is segment-wise per the rule below (no implicit hierarchy).\n Examples:\n "credit:read" — can access credit reports\n "subscription:marketdata-2026" — has active MarketData subscription\n "academic:*" — full access to academic resources\n "internal:reports" — can access internal reports\n "*" — unrestricted (public Exchange default)\n\n Matching is SEGMENT-WISE (":" separated). A granted scope G covers a\n required scope R iff, segment by segment, each G segment equals the\n corresponding R segment or is "*"; a terminal "*" matches all remaining\n segments. There is NO implicit prefix match, and a grant NARROWER than\n the requirement does not cover it (G must be equal-to-or-broader than R).\n Examples: "dist:*" covers "dist:US" and "dist:US:CA"; "dist:US:*" covers\n "dist:US:CA" but not "dist:EU"; bare "dist" covers only "dist"; granted\n "dist:US:CA" does NOT cover required "dist:US"; "*" covers everything.\n This same rule governs LicenseTerm.scopes — one algorithm protocol-wide.\n\n When empty, Exchange applies its default access policy (typically\n returns all publicly available resources).', + max_length=64, + ) + type: RequesterType = Field( + ..., description='What kind of entity is making this request.' + ) + + +class ResourceIdentity(WireModel): + c2pa_manifest: str | None = Field( + None, + description='Formats:\n Sidecar: HTTPS URI to a .c2pa manifest file\n Embedded: same URI as canonical_url (manifest is inside the asset)\n Content Credentials Cloud: https://contentcredentials.org/verify?uri=...', + ) + c2pa_status: C2PAStatus | None = Field( + None, + description='The full C2PA validation details (signer identity, trust list,\n action history, training/mining status) are carried in a\n ResourceAttestation with c2pa.* claims — see ramp-c2pa-v1 profile.', + ) + canonical_url: str | None = Field( + None, + description='Provider\'s authoritative URL for this resource (rel="canonical").\n Always available. Different per provider for syndicated content.', + ) + content_hash: str | None = Field( + None, + description='Level 1 (SimHash): computed by Exchange from extracted text.\n Agent verifies that fetched content is "substantially similar."\n Tolerates dynamic page elements.\n\n Level 2 (SHA-256): computed by provider from deterministic payload.\n Agent verifies exact match. Requires provider to serve consistent\n content (e.g., API endpoint, static HTML, structured JSON).\n Mismatch = dispute. Commands premium pricing.', + ) + doi: str | None = Field( + None, description='Digital Object Identifier — persistent, never changes.' + ) + ext: dict[str, Any] | None = Field(None, description='Extension point') + ext_critical: list[str] | None = Field( + None, + description='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.', + ) + hash_method: str | None = Field( + None, + description='Hash algorithm and verification level.\n Examples: "simhash-v1", "minhash-v1", "sha256", "sha384"', + ) + iptc_guid: str | None = Field( + None, + description='IPTC NewsML-G2 globally unique identifier.\n Present when resource flows through news wire syndication (AP, Reuters).', + ) + isni: str | None = Field( + None, description='International Standard Name Identifier for the creator.' + ) + resource_mutability: ResourceMutability = Field( + ..., + description='Drives hash verification behavior:\n STATIC: content_hash is stable. Agent SHOULD verify delivered content matches.\n DYNAMIC: content changes between offer and fetch (credit reports, drug databases).\n content_hash reflects state at offer generation time. Hash mismatch is\n expected and MUST NOT trigger automatic dispute.\n LIVE: content does not exist at offer time (streaming feeds, live broadcasts).\n content_hash is not applicable. The "resource" is the stream endpoint.\n\n Validated across 18 use cases: static content (articles, patents, legislation),\n dynamic data (credit reports, drug interactions, stock snapshots), and live\n streams (MarketData quotes, NPR broadcast, news monitoring feeds).', + ) + soft_binding: str | None = Field( + None, + description='Algorithm specified in soft_binding_method. Values are algorithm-specific\n (e.g., perceptual hash hex string, watermark identifier).', + ) + soft_binding_method: str | None = Field( + None, + description='Algorithm used for soft_binding.\n Examples: "phash-v1" (perceptual hash), "c2pa-watermark" (C2PA invisible\n watermark), "chromaprint" (audio fingerprint).', + ) + + +class ResourceQuery(WireModel): + acceptable_restrictions: list[AcceptableRestriction] | None = Field( + None, + description='The limits this query operates within, per restriction axis (function,\n geography, user-type, …) — see AcceptableRestriction. Advisory selection\n inputs the Exchange/Broker MAY pre-select offers against (convenience, not\n enforcement); the agent self-selects and bears compliance.', + ) + deadline: str | None = Field( + None, + description='Maximum time the caller will wait for a response.\n Exchange SHOULD prioritize speed over completeness when tight.\n Absent = 500ms default.', + ) + ext: dict[str, Any] | None = Field(None, description='Extension point') + ext_critical: list[str] | None = Field( + None, + description='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.', + ) + requester: Requester | None = Field( + None, + description='Requester identity — who is making this request, what scopes they have,\n and optional delegation chain.', + ) + supported_profiles: list[str] | None = Field( + None, + description='Declares which ext field vocabularies the caller can parse and act on.\n The Exchange SHOULD include profile-specific ext fields in Offers\n when the caller declares support. The Exchange MAY skip expensive\n metadata computation (e.g., retraction checking, consolidation\n verification) when the caller does not declare the relevant profile.\n\n Absence means "send all available metadata" — Exchange MUST NOT\n withhold ext fields solely because the caller omitted this field.\n\n Values match the Exchange\'s WellKnownManifest.supported_profiles entries.\n Examples: ["ramp-news-v1", "ramp-academic-v1", "ramp-legal-v1"]', + ) + uris: list[str] | None = Field( + None, description='Resource URIs being queried.', max_length=256 + ) + ver: str | None = Field('', description='RAMP protocol version.') + + +class Restriction(WireModel): + advisory: bool | None = Field( + False, + description='Fail-closed by default. When false (the default), this restriction is\n BINDING: an agent that cannot evaluate every token in it — including an\n unknown vendor token — MUST decline the term. Set advisory = true to\n downgrade an unverifiable restriction to non-blocking. This deliberately\n inverts the COSE-`crit` opt-in default: a license restriction a consumer\n does not understand should stop it, not be silently ignored.', + ) + kind: RestrictionKind = Field( + ..., description='Which dimension this restriction applies to.' + ) + permitted: ( + list[constr(pattern=r'^[A-Za-z0-9._:*-]+$', min_length=1, max_length=64)] | None + ) = Field( + None, + description='Tokens allowed on this axis. Empty = all permitted.\n For FUNCTION: "ai-input", "ai-train", "search", "editorial", "commercial", …\n For GEOGRAPHY: "US", "DE", "EU", "EEA", "*", …\n For USER_TYPE: "individual", "academic", "commercial_entity", …', + max_length=64, + ) + prohibited: ( + list[constr(pattern=r'^[A-Za-z0-9._:*-]+$', min_length=1, max_length=64)] | None + ) = Field( + None, + description='Tokens blocked on this axis. Takes precedence over permitted[].', + max_length=64, + ) + + +class RetrievalAuthFailure(WireModel): + reason: RetrievalAuthFailureReason = Field( + ..., description='The failure reason (defined-only, non-zero)' + ) + + +class TransactionRequest(WireModel): + ext: dict[str, Any] | None = Field(None, description='Extension point') + ext_critical: list[str] | None = Field( + None, + description='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.', + ) + idempotency_key: constr(min_length=1, max_length=255) = Field( + ..., + description="Idempotency key (REQUIRED). The server MUST dedupe on this: a replay returns\n the original result rather than re-executing. The transaction's durable\n identity is the Exchange-assigned transaction_id in the response.\n Uniqueness is scoped to the verified RFC 9421 signer: the server dedupes per\n (authenticated caller, key), never globally, so a key chosen by one caller\n cannot collide with another's cached result.", + ) + items: list[TransactionItem] | None = Field( + None, + description='Batch mode: commit to multiple offers in one request.\n When populated, `offer_id` and `offer_signature` SHOULD be empty.', + ) + offer_id: str | None = Field( + None, + description='Single-offer mode. Use `items` for batch mode; `offer_id` +\n `offer_signature` for single.', + ) + offer_signature: str | None = Field(None, description='Single-offer signature.') + requester: Requester | None = Field( + None, description='Requester identity — forwarded for authorization and audit.' + ) + ver: str | None = Field('', description='Protocol version') + + +class TransactionResponse(WireModel): + agent_identity_hash: str | None = Field( + '', + description='Identity that retrieval_endpoint is bound to: the RFC 7638 JWK Thumbprint of\n the agent\'s Ed25519 request-signing key (see "Retrieval-URL identity binding"\n above). Empty string when absent; non-empty iff a signed retrieval_endpoint\n is present. Delivery-endpoint enforcement of the binding is OPTIONAL.', + ) + billing_id: str | None = Field(None, description='Billing reference') + cost: Cost | None = Field(None, description='Transaction cost') + delivery_method: ( + constr(pattern=r'^DELIVERY_METHOD_UNSPECIFIED$') + | DeliveryMethod + | conint(ge=-2147483648, le=2147483647) + | None + ) = Field(0, description='How resource is delivered in this transaction.') + expires_at: AwareDatetime | None = Field( + None, description='When retrieval_endpoint expires.' + ) + ext: dict[str, Any] | None = Field(None, description='Extension point') + ext_critical: list[str] | None = Field( + None, + description='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.', + ) + items: list[TransactionResultItem] | None = Field( + None, description='Batch mode: per-offer results.' + ) + reporting_obligation: ReportingObligation | None = Field( + None, description='Reporting requirements attached to this delivery.' + ) + resource_title: str | None = Field( + None, description='Resource title echoed from the Offer (for logging/display).' + ) + retrieval_endpoint: str | None = Field( + None, + description='Signed retrieval URL the agent uses to fetch the purchased resource.\n Bound to agent_identity_hash; expires at expires_at. Absent on denial\n and on transactions whose delivery_method is not signed-URL-based.', + ) + subscription_id: str | None = Field( + None, + description='If set, this transaction was fulfilled under a subscription/deal.\n No per-request charge — usage tracked against subscription quota.', + ) + subscription_quota: list[SubscriptionQuotaInfo] | None = Field( + None, + description='Post-transaction quota state. Tells the agent how much quota remains\n after this transaction. Enables proactive throttling ("1 access left").\n Multiple entries for multi-dimensional quotas.', + ) + subscription_unit_value: Cost | None = Field( + None, + description='Computed per-unit cost for financial attribution on subscription transactions.\n Even when cost.amount="0" (subscription), this field carries the value\n of the access for accounting purposes (e.g., ASC 606 prepaid drawdown).', + ) + total_cost: Cost | None = Field( + None, description='Batch mode: aggregate cost across all items.' + ) + transaction_id: str | None = Field( + None, + description='Single-offer result.\n For batch mode, these may be empty — check `items` instead.', + ) + ver: str | None = Field('', description='Protocol version') + + +class Usage(WireModel): + attribution: list[AttributionDetail] | None = Field( + None, description='Structured attribution details for each citation provided.' + ) + citation_included: bool | None = Field( + None, + description='Whether citation was included as required by the offer terms.', + ) + consumed_quantity: conint(ge=-2147483648, le=2147483647) | None = Field( + None, + description="REQUIRED. Actual quantity consumed, in the metering unit from the Offer's Pricing.\n For text: tokens consumed. For video: seconds watched. For data: records accessed.\n Exchange cross-references against Offer.pricing.estimated_quantity.", + ) + consumed_unit: ( + constr( + pattern=r'^([a-z0-9-]+|[A-Za-z0-9._-]+:[A-Za-z0-9._-]+)?$', max_length=64 + ) + | None + ) = Field( + None, + description='Metering unit for consumed_quantity. Must match the Offer\'s Pricing.unit.\n If omitted, defaults to "tokens". Same token format as Pricing.unit:\n a bare registered token or a vendor:namespaced token.', + ) + displayed_to_user: bool | None = Field( + None, description='Whether resource/output was displayed to a human.' + ) + function: list[str] | None = Field( + None, + description='How the resource was used. Standard values: "ai-train", "ai-input",\n "ai-index", "search", "display". Multiple allowed.\n CoMP-specific values available via ramp-comp-v1 extension profile.', + ) + subfn: list[str] | None = Field( + None, + description='Sub-function detail. Standard values: "training", "rag", "grounding",\n "agent_view", "agent_actions".', + ) + + +class UsageReport(WireModel): + assets: list[UsageAsset] | None = Field( + None, description='Assets that were delivered and used.' + ) + billing_id: str | None = Field( + '', description='Billing reference from the delivery.' + ) + exchange: str | None = Field(None, description='Exchange this report is for.') + ext: dict[str, Any] | None = Field(None, description='Extension point') + ext_critical: list[str] | None = Field( + None, + description='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.', + ) + idempotency_key: constr(min_length=1, max_length=255) = Field( + ..., + description="Idempotency key (REQUIRED). The server MUST dedupe on this so a replayed\n report does not double-count usage. The report's durable identity is the\n Exchange-assigned report_id in UsageReportResponse.\n Uniqueness is scoped to the verified RFC 9421 signer: the server dedupes per\n (authenticated caller, key), never globally, so a key chosen by one caller\n cannot collide with another's cached result.", + ) + timestamp: AwareDatetime | None = Field( + None, description='When the resource was used (ISO 8601).' + ) + transaction_id: str | None = Field( + '', description='Transaction ID from the delivery.' + ) + usage: Usage | None = Field(None, description='How the resource was actually used.') + ver: str | None = Field('', description='Protocol version') + + +class UsageReportRejection(WireModel): + reason: UsageReportRejectionReason = Field( + ..., description='The rejection reason (defined-only, non-zero)' + ) + + +class WellKnownManifest(WireModel): + accepted_verifiers: list[str] | None = Field( + None, + description='Exchange-only. Trusted attestation verification vendors (domains).', + ) + base_currency: str | None = Field( + None, + description='Exchange-only. Base currency for pricing (ISO 4217). All unit_cost\n values from this Exchange are denominated in this currency.', + ) + catalog_contributors: list[CatalogContributor] | None = Field( + None, + description='Publisher-only. Authorized third-party catalog contributors.\n MUST be empty for non-publisher roles.', + ) + catalog_endpoint: str | None = Field( + None, description='Exchange-only. CatalogService endpoint URL (if exposed).' + ) + contact: str | None = Field( + None, description='Contact email (licensing, integration, security).' + ) + delivery_methods_supported: list[DeliveryMethod] | None = Field( + None, description='Exchange-only. Supported delivery methods.' + ) + domain: str | None = Field( + '', description='Canonical domain serving this manifest.' + ) + endpoint: str | None = Field( + None, description='Exchange-only. ExchangeService endpoint URL.' + ) + exchanges: list[AuthorizedExchange] | None = Field( + None, + description="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.", + ) + ext: dict[str, Any] | None = Field(None, description='Extension point') + ext_critical: list[str] | None = Field( + None, + description='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.', + ) + gnap_grant_endpoint: str | None = Field( + None, description='Exchange-only. GNAP grant endpoint when GNAP is supported.' + ) + hash_methods_supported: list[str] | None = Field( + None, + description='Exchange-only. Accepted resource hash methods for attestation\n verification.', + ) + health_endpoint: str | None = Field( + None, description='Exchange-only. Health check endpoint URL.' + ) + max_intermediary_hops: conint(ge=-2147483648, le=2147483647) | None = Field( + None, + description='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).', + ) + name: str | None = Field( + None, description='Exchange-only. Human-readable Exchange name.' + ) + oidc_issuer: str | None = Field( + None, + description='Exchange-only. OIDC Discovery URL when OAuth methods are supported.', + ) + operator: str | None = Field( + None, description='Exchange-only. Organization operating this Exchange.' + ) + operator_domain: str | None = Field( + None, + description="Exchange-only. Operator's corporate domain (may differ from domain).", + ) + pricing_models_supported: list[PricingModel] | None = Field( + None, description='Exchange-only. Supported pricing models.' + ) + privacy_uri: str | None = Field( + None, description='Exchange-only. Privacy policy URL.' + ) + protocol_versions_supported: list[str] | None = Field( + None, + description='Exchange-only. Supported RAMP protocol versions (e.g. ["1.0"]).', + ) + role: Role = Field(..., description='Role this manifest describes.') + supported_auth_methods: list[AuthMethod] | None = Field( + None, + description='Exchange-only. Authorization methods this Exchange supports\n (ordered by preference).', + ) + supported_profiles: list[str] | None = Field( + None, + description='Exchange-only. Domain extension profiles this Exchange conforms to.\n See standards-layering docs.', + ) + terms_uri: str | None = Field( + None, description='Exchange-only. Terms of service URL.' + ) + ver: str | None = Field( + '', + description='RAMP protocol version. MUST equal "1.0"; consumers REJECT\n unrecognised major versions.', + ) + + +class DiscoveryRequest(WireModel): + acceptable_restrictions: list[AcceptableRestriction] | None = Field( + None, + description='The limits the agent will operate within, per restriction axis — see\n AcceptableRestriction. The Broker forwards these to Exchanges in\n ResourceQuery.acceptable_restrictions. Advisory selection inputs, not\n enforcement.', + ) + constraints: RequestConstraints | None = Field( + None, description='Constraints for exchange filtering and offer selection.' + ) + ext: dict[str, Any] | None = Field(None, description='Extension point') + ext_critical: list[str] | None = Field( + None, + description='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.', + ) + query: str | None = Field( + None, + description="Search query for Broker-side resource discovery.\n Used when the agent doesn't know specific URIs but wants the Broker\n to find matching resources across Exchanges.\n When present, the Broker interprets the query and discovers resources\n across Exchanges on the agent's behalf. Results returned as Offers\n in DiscoveryResponse, same as for specific URI requests.\n Can be used alongside uris (specific URIs + search in one request).", + ) + requester: Requester | None = Field( + None, + description='Requester identity — who is making this request, what scopes they have.\n The Broker forwards this to Exchanges in ResourceQuery.requester.', + ) + search_filters: dict[str, Any] | None = Field( + None, + description='Structured search filters (optional, alongside or instead of query).\n Keys are profile-specific: "academic.topic", "news.category",\n "legal.jurisdiction", etc. The Broker maps these to Exchange-specific\n query parameters.', + ) + supported_profiles: list[str] | None = Field( + None, + description='The Broker uses this to:\n 1. Route queries to Exchanges that support these profiles\n 2. Forward the profiles in ResourceQuery.supported_profiles\n 3. Include profile-specific ext fields when returning results\n\n Examples: ["ramp-academic-v1"] — agent working on literature review', + ) + uris: list[str] | None = Field( + None, + description='Resource URIs the agent wants. The Broker forwards these to Exchanges in\n ResourceQuery.uris. Optional when `query` / `search_filters` drive\n Broker-side discovery instead.', + max_length=256, + ) + ver: str | None = Field('', description='RAMP protocol version') + + +class ErrorDetail(WireModel): + catalog_rejection: CatalogRejection | None = Field( + None, description='`reason` oneof — CatalogService rejection' + ) + dispute_failure: DisputeFailure | None = Field( + None, description='`reason` oneof — DisputeTransaction filing refused' + ) + domain: str | None = Field( + '', + description='Stable grouping for the failing surface, e.g. "ramp.v1.ExchangeService".\n Mirrors google.rpc.ErrorInfo.domain so generic tooling can group errors.', + ) + domain_verification_failure: DomainVerificationFailure | None = Field( + None, description='`reason` oneof — domain verification failed' + ) + message: str | None = Field( + '', + description='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`).', + ) + metadata: dict[str, str] | None = Field( + None, + description='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.', + ) + registration_failure: RegistrationFailure | None = Field( + None, description='`reason` oneof — agent/provider registration refused' + ) + retrieval_auth_failure: RetrievalAuthFailure | None = Field( + None, + description='`reason` oneof — signed-URL / proof-of-possession check failed', + ) + transaction_denial: TransactionDenial | None = Field( + None, description='`reason` oneof — ExecuteTransaction denial' + ) + usage_report_rejection: UsageReportRejection | None = Field( + None, description='`reason` oneof — ReportUsage filing rejected' + ) + + +class LicenseTerm(WireModel): + license: License | None = Field( + None, + description='Governing license document. Authoritative for REFERENCE_ONLY terms, which\n MUST carry a License with a non-empty uri — a REFERENCE_ONLY term that\n references nothing is rejected at ingest.', + ) + obligations: list[Obligation] | None = Field( + None, description='Post-use behavioral requirements.' + ) + part_label: str | None = Field( + None, + description='Informational human-readable name for this sub-part (sub-part terms).', + ) + pricing: Pricing = Field( + ..., + description='Pricing for this term. REQUIRED for every term regardless of semantics —\n an agent cannot act on a priceless term, so absent Pricing is a validation\n error at ingest. model = FREE must be stated explicitly (absent Pricing is\n not free). A REFERENCE_ONLY term states its price here too; its License\n governs the human-readable terms but does not replace the machine-readable\n price.', + ) + quotas: list[Quota] | None = Field( + None, description='Usage caps. The agent must not exceed any individual Quota.' + ) + restrictions: list[Restriction] | None = Field( + None, + description='Usage restrictions (function, geography, user-type).\n Multiple restrictions are AND-combined — the agent must satisfy all of them.', + ) + scopes: list[str] | None = Field( + None, + description='Coverage uses the SAME matching rule as Requester/delegation scopes:\n segment-wise (":" separated), each granted segment must equal the\n corresponding required segment or be "*", a terminal "*" matches all\n remaining segments, and there is NO implicit prefix match (a grant\n narrower than the requirement does not cover it). "dist:*" covers\n "dist:US" and "dist:US:CA"; "dist" covers only "dist". There is exactly\n one scope-matching algorithm across the protocol.', + max_length=64, + ) + semantics: TermSemantics = Field( + ..., description='How to interpret the machine fields.' + ) + + +class Offer(WireModel): + attestations: list[ResourceAttestation] | None = Field( + None, + description='Three verification levels determine what is independently verifiable:\n Level 0 (no attestations): Resource may carry identifiers (DOI, IPTC GUID)\n for identification, but nothing is cryptographically verifiable.\n Only CDN delivery failure is auto-disputable.\n Level 1 (self-attested): Provider signs own claims with Ed25519 key.\n Agent can independently verify content hash and token count.\n CDN delivery failure + content hash mismatch are auto-disputable.\n Level 2 (third-party attested): Independent verification vendor crawled\n the resource and attested to its properties. Agent trusts the attestation\n (does not re-verify hash). Token count discrepancy is auto-disputable\n when corroborated by CDN response size.\n\n Multiple attestations may be present (e.g., provider self-attestation\n plus a third-party verification). Agents choose which to trust.', + ) + data_as_of: AwareDatetime | None = Field( + None, + description="Not set for STATIC resources (content doesn't change) or LIVE\n resources (content doesn't exist yet).\n\n The Broker compares this against RequestConstraints.max_data_age\n to filter stale offers. Example: agent requests max_data_age = 7 days,\n Broker drops offers where now() - data_as_of > 7 days.", + ) + delivery_method: ( + constr(pattern=r'^DELIVERY_METHOD_UNSPECIFIED$') + | DeliveryMethod + | conint(ge=-2147483648, le=2147483647) + | None + ) = Field(0, description='How resource will be delivered.') + exchange: str | None = Field( + '', + description='Canonical domain of the Exchange that issued this offer (e.g.\n "exchange.example.com"). This is the execute-routing target: the agent (or\n a relaying Broker) sends the ExecuteTransaction call for this offer to this\n Exchange. Because it is an ordinary Offer field it falls inside the signed\n bytes (see `signature` below — the signature covers every field except\n `signature` / `signature_algorithm`), so an intermediary cannot redirect\n the execute call to a different Exchange without invalidating the offer.', + ) + expires_at: AwareDatetime | None = Field( + None, description='When this offer expires (ISO 8601).' + ) + ext: dict[str, Any] | None = Field(None, description='Extension point') + ext_critical: list[str] | None = Field( + None, + description='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.', + ) + iab_categories: list[str] | None = Field( + None, + description='IAB Content Taxonomy category codes.\n Enables agents to filter offers by topic (e.g., "only finance resources").\n Uses IAB Content Taxonomy 3.1 codes.', + ) + identity: ResourceIdentity | None = Field( + None, + description='Resource identity for cross-exchange deduplication.\n Enables Brokers to recognize the same resource offered by\n different Exchanges and compare pricing.', + ) + offer_id: str | None = Field( + '', description='Unique identifier for this offer, assigned by the Exchange.' + ) + previews: list[Preview] | None = Field( + None, + description='Per content type:\n Image: watermarked thumbnail (150–450px JPEG)\n Video: short clip (10–30s MP4, watermarked)\n Audio: short clip (15–30s MP3, low-bitrate or watermarked)\n Text: snippet or abstract (first 200 words as text/plain)\n Data: sample records (1–3 rows as application/json)\n Stream: optional frame capture or none (streams are priced by time)\n\n Modeled after Shutterstock (multi-size thumbnail URLs),\n Spotify (preview_url to 30s clip), IIIF (parameterized image URLs),\n and OpenRTB native (img.url + dimensions).', + ) + pricing: Pricing | None = Field( + None, + description='Pricing for this offer. An offer represents a single licensing\n arrangement: each projected LicenseTerm yields its own offer, so this is\n that term\'s pricing (the authoritative copy lives in `terms[].pricing`).\n Used for cross-exchange comparison and Broker ranking. A resource with\n multiple alternative terms (e.g. dual-licensed) produces multiple separate\n offers, one per term — never one offer with a "headline" picked among them.', + ) + reporting: ReportingObligation | None = Field( + None, description='Post-usage reporting requirements for this offer.' + ) + signature: str | None = Field( + '', + description="Because the signature covers `terms`, `pricing`, `expires_at`, and\n `exchange`, an intermediary (Broker) cannot tamper with price, restrictions,\n quotas, obligations, the expiry, the execute-routing target, or any\n licensing term without invalidating it.\n Agent SHOULD verify the signature (RFC 2119) against the Exchange's public\n key, and MUST reject an offer whose `expires_at` is in the past.", + ) + signature_algorithm: str | None = Field( + '', + description="JWS algorithm. Always 'EdDSA' for Ed25519 via JWS Compact Serialization.", + ) + subscription_id: str | None = Field( + None, + description='If set, this offer is available under an existing subscription/deal.\n No per-request billing — usage tracked against subscription quota.\n Pricing.rate = "0" for subscription offers (zero marginal cost).\n The Broker SHOULD prefer subscription offers when available.', + ) + subscription_quota: list[SubscriptionQuotaInfo] | None = Field( + None, + description='Subscription quota state, when this offer is under a subscription.\n Enables the agent to see remaining quota before committing.\n Multiple entries when the subscription has independent quotas\n (e.g., access count + spend cap).', + ) + terms: list[LicenseTerm] | None = Field( + None, + description="Licensing terms for this offer, sourced from the publisher's ResourceEntry.\n Multiple terms when the resource has different arrangements by use case.\n See: Universal Licensing Core section.", + ) + + +class OfferGroup(WireModel): + absence_reason: OfferAbsenceReason | None = Field( + None, + description='Why no offers are available for this URI.\n Present when `offers` is empty. Enables agents/Brokers to distinguish\n "resource not in catalog" from "resource blocked for your use case" without\n trial-and-error transactions. Analogous to OpenRTB nbr codes and\n Shutterstock per-item error metadata in batch responses.', + ) + discovery_method: DiscoveryMethod | None = Field( + None, + description="How this URI was discovered by the Broker (v2 extension point).\n v1: always DISCOVERY_METHOD_EXCHANGE (Broker queried an Exchange).\n v2: may include DISCOVERY_METHOD_SEARCH (URI found via search engine like Exa),\n DISCOVERY_METHOD_RECOMMENDATION, etc. The Broker discovers URIs\n through any source, then routes through Exchange for pricing/transaction.\n The discovery method does not affect the transaction flow — it's metadata\n for the agent to understand how the resource was found.", + ) + offers: list[Offer] | None = Field( + None, + description='Zero or more offers for this URI. Empty = resource not available.', + ) + restriction_filters: list[RestrictionKind] | None = Field( + None, + description="When absence_reason = RESTRICTION_FILTERED, the restriction axes that drove\n the convenience pre-filter, in the same RestrictionKind vocabulary the terms\n use (e.g. [GEOGRAPHY] when the requester's stated geography matched no term).\n Advisory diagnostics, not an enforcement verdict.", + ) + uri: str | None = Field( + '', + description='The URI this group of offers is for (echoed from ResourceQuery.uris).', + ) + + +class ResourceEntry(WireModel): + attestations: list[ResourceAttestation] | None = Field( + None, + description="Signed attestations about this resource entry.\n Same semantics as Offer.attestations — see ResourceAttestation message\n for verification levels and claim vocabulary. Attestations pushed via\n CatalogService are verified at push time: the Exchange checks that\n the attestation verifier is authorized to push for this provider\n (via catalog_contributors in the provider's WellKnownManifest) and validates the\n attestation signature against the verifier's public key from their\n /.well-known/ramp.json endpoint (WellKnownManifest, role determined\n by the verifier's operator).", + ) + content_hash: str | None = Field(None, description='Content hash') + content_id: str | None = Field(None, description='Content identifier') + domain: str | None = Field('', description='Provider domain') + estimated_quantity: conint(ge=-2147483648, le=2147483647) | None = Field( + None, description='Estimated quantity in the metering unit' + ) + ext: dict[str, Any] | None = Field(None, description='Extension point') + ext_critical: list[str] | None = Field( + None, + description='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.', + ) + hash_method: str | None = Field(None, description='Hash algorithm') + path: str | None = Field('', description='Content path') + provenance_source: str | None = Field( + None, + description='Who provided this resource metadata. Creates audit trail for\n "where did this catalog entry come from?"', + ) + provenance_timestamp: AwareDatetime | None = Field( + None, description='When this metadata was collected/generated.' + ) + source: IngestionSource | None = Field( + None, description='How the entry was discovered' + ) + terms: list[LicenseTerm] | None = Field( + None, + description='Publisher-declared licensing terms for this resource.\n See LicenseTerm for the full model. For ENUMERATED terms, Pricing MUST\n be present. For REFERENCE_ONLY terms, License.uri is authoritative.\n The Exchange validates ENUMERATED terms at push time and surfaces them\n in Offer.terms on discovery.', + ) + word_count: conint(ge=-2147483648, le=2147483647) | None = Field( + None, description='Word count' + ) + + +class ResourceResponse(WireModel): + exchange: str | None = Field( + '', description='Canonical domain of the responding Exchange.' + ) + ext: dict[str, Any] | None = Field(None, description='Extension point') + ext_critical: list[str] | None = Field( + None, + description='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.', + ) + offer_groups: list[OfferGroup] | None = Field( + None, + description='Offers grouped by requested URI (for multi-URI batch queries).\n When populated, `offers` SHOULD be empty to avoid ambiguity.', + ) + offers: list[Offer] | None = Field( + None, description='Flat list of offers (for single-URI queries).' + ) + rate_limit: RateLimitInfo | None = Field( + None, + description='Rate limit status for this caller.\n Present when the Exchange enforces per-caller rate limits on discovery.\n Enables agents/Brokers to throttle proactively rather than hitting\n hard limits. Particularly important when a Broker fans out the\n same batch query to multiple Exchanges — mid-batch rate limiting\n can cause partial results if not signaled early.', + ) + ver: str | None = Field('', description='Protocol version') + + +class DiscoveryResponse(WireModel): + absence_reason: OfferAbsenceReason | None = Field( + None, + description='Existence-oracle note: an authorization-flavored reason (SCOPE_INSUFFICIENT,\n NOT_AUTHORIZED, NOT_IN_CATALOG, CONTENT_BLOCKED) confirms a resource exists\n and why access was refused. Resolve surfaces the same oracle at the broker\n that OfferGroup.absence_reason does at the Exchange, so the same mitigation\n applies: where existence itself must stay hidden, the Broker MAY omit the\n reason (leave this unset) rather than reveal it. See the threat model.', + ) + ext: dict[str, Any] | None = Field(None, description='Extension point') + ext_critical: list[str] | None = Field( + None, + description='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.', + ) + offer_groups: list[OfferGroup] | None = Field( + None, + description='Offers grouped by requested URI — the sole offer representation in this\n response. One OfferGroup per URI the agent asked for (echoed in\n OfferGroup.uri); a group with no offers carries OfferGroup.absence_reason\n explaining why. Each contained Offer is the full signed Offer the Exchange\n issued (including Offer.exchange, the execute-routing target), forwarded by\n the Broker unchanged so the agent can verify the signature end to end.', + ) + ver: str | None = '' + + +class PushResourcesRequest(WireModel): + caller_id: str | None = Field( + '', + description='Identity of the caller (who is pushing this data).\n The Exchange verifies this matches a registered CatalogService client.', + ) + entries: list[ResourceEntry] | None = Field( + None, description='Content entries to push' + ) + ext: dict[str, Any] | None = Field(None, description='Extension point') + ext_critical: list[str] | None = Field( + None, + description='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.', + ) + tenant_id: str | None = Field('', description='Tenant identifier') + ver: str | None = Field('', description='Protocol version') diff --git a/gen/ts/README.md b/gen/ts/README.md new file mode 100644 index 00000000..01410a24 --- /dev/null +++ b/gen/ts/README.md @@ -0,0 +1,26 @@ +# RAMP TypeScript types export + +A **types export**, not a full SDK: Zod schemas for every RAMP message + registered +vocabulary constants. Generated from `proto/` via JSON Schema by +`scripts/gen-sdk-types.sh` — do not edit by hand. + +Contents: +- `wire/schemas.ts` — a Zod schema per message (`OfferSchema`, `PricingSchema`, …), + carrying **shape + per-field validation** (string patterns, length/item bounds, + closed `not_in:[0]` enum discriminators). Cross-field rules are **not** here — they + are enforced server-side by the Exchange/Broker. +- `wire/base.ts` — **`wire()`**, the single seam that sets the unknown-field policy for + every schema (default: strip unknown keys, so a field from a newer protocol version + is accepted and dropped). **Hand-written, not generated.** +- `vocab/*.ts` — registered vocabulary constants per axis (`pricingunits`, …) with + `isRegistered()`. + +```typescript +import { OfferSchema } from "@ramp-protocol/sdk/wire/schemas"; +import { pricingunits } from "@ramp-protocol/sdk/vocab/pricingunits"; + +const result = OfferSchema.safeParse(incomingJson); // shape + per-field validation +``` + +Money is a decimal **string** on the wire (e.g. `"19.99"`), never a float. Requires +`zod` as a peer dependency. Licensed under Apache-2.0. diff --git a/gen/ts/comp/v1/comp_pb.ts b/gen/ts/comp/v1/comp_pb.ts deleted file mode 100644 index 813c886b..00000000 --- a/gen/ts/comp/v1/comp_pb.ts +++ /dev/null @@ -1,1344 +0,0 @@ -// CoMP V1 — IAB Tech Lab Content Monetization Protocols (finalized 2026-04-28). -// -// 1:1 mapping from the canonical CoMP V1 specification. -// Source (immutable pin): https://github.com/IABTechLab/CoMP/blob/880238e0100b3d0d67d5afd7357a18fc21a97be5/CoMP-1.0.md -// Spec-file content blob SHA (content-addressed): aa8c796be7a9bcfa1189a1cf6c1ec50aa67a0f5f -// NOTE: the 1.0-202604 release tag does NOT contain the spec; main carries it. -// -// Enum alignment: proto3 enum values map directly to CoMP integer values -// (first real CoMP value = 0). No UNSPECIFIED sentinels — proto3 default -// zero is a valid CoMP value, not a sentinel. -// -// This file preserves CoMP field names, enum values, and semantics exactly. -// Canonical V1 has no separate License object: all commercial/licensing terms -// live on Scope (ause, pricetype, pricetier, unitprice, cur, country, -// licensedur). RAMP extensions live in ramp/v1/ramp.proto, never here. - -// @generated by protoc-gen-es v2.12.1 with parameter "target=ts" -// @generated from file comp/v1/comp.proto (package comp.v1, syntax proto3) -/* eslint-disable */ - -import type { GenEnum, GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2"; -import { enumDesc, fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2"; -import { file_google_protobuf_struct } from "@bufbuild/protobuf/wkt"; -import type { JsonObject, Message } from "@bufbuild/protobuf"; - -/** - * Describes the file comp/v1/comp.proto. - */ -export const file_comp_v1_comp: GenFile = /*@__PURE__*/ - fileDesc("ChJjb21wL3YxL2NvbXAucHJvdG8SB2NvbXAudjEilgEKCEFJU3lzdGVtEgwKBG5hbWUYASABKAkSDwoCdWEYAiABKAlIAIgBARIPCgJpZBgDIAEoCUgBiAEBEiYKCGFpc3lzdXNlGAQgASgLMhQuY29tcC52MS5BSVN5c3RlbVVzZRIkCgNleHQYDyABKAsyFy5nb29nbGUucHJvdG9idWYuU3RydWN0QgUKA191YUIFCgNfaWQijgIKC0FJU3lzdGVtVXNlEgsKA2xpZBgBIAEoCRIjCgZhaWF1dGgYAiABKA4yEy5jb21wLnYxLkF1dGhNZXRob2QSCwoDdXJpGAMgAygJEiYKBXNjb3BlGAQgASgOMhIuY29tcC52MS5TY29wZVR5cGVIAIgBARIjCghmdW5jdGlvbhgFIAMoDjIRLmNvbXAudjEuRnVuY3Rpb24SIwoFc3ViZm4YBiADKA4yFC5jb21wLnYxLlN1YkZ1bmN0aW9uEhMKBnJlc2RpcxgHIAEoBUgBiAEBEiQKA2V4dBgPIAEoCzIXLmdvb2dsZS5wcm90b2J1Zi5TdHJ1Y3RCCAoGX3Njb3BlQgkKB19yZXNkaXMi9wIKB1BhY2thZ2USCgoCaWQYASABKAkSEgoFdGl0bGUYAiABKAlIAIgBARITCgZzZWxsZXIYAyABKAlIAYgBARIVCghwYWNrYWdlchgEIAEoCUgCiAEBEhcKCmxpY2Vuc2V1cmwYBSABKAlIA4gBARIVCghjaXRhdGlvbhgGIAEoBUgEiAEBEhYKCXJlcG9ydHVybBgHIAEoCUgFiAEBEiIKBXNjb3BlGAggASgLMg4uY29tcC52MS5TY29wZUgGiAEBEioKCXJldHJpZXZhbBgJIAEoCzISLmNvbXAudjEuUmV0cmlldmFsSAeIAQESJAoDZXh0GA8gASgLMhcuZ29vZ2xlLnByb3RvYnVmLlN0cnVjdEIICgZfdGl0bGVCCQoHX3NlbGxlckILCglfcGFja2FnZXJCDQoLX2xpY2Vuc2V1cmxCCwoJX2NpdGF0aW9uQgwKCl9yZXBvcnR1cmxCCAoGX3Njb3BlQgwKCl9yZXRyaWV2YWwiogQKBVNjb3BlEiYKBXNjb3BlGAEgASgOMhIuY29tcC52MS5TY29wZVR5cGVIAIgBARImCgRhdXNlGAIgASgOMhMuY29tcC52MS5BbGxvd2VkVXNlSAGIAQESKgoJcHJpY2V0eXBlGAMgASgOMhIuY29tcC52MS5QcmljZVR5cGVIAogBARIWCglwcmljZXRpZXIYBCABKAVIA4gBARIWCgl1bml0cHJpY2UYBSABKAFIBIgBARIQCgNjdXIYBiABKAlIBYgBARIPCgdjb3VudHJ5GAcgAygFEhcKCmxpY2Vuc2VkdXIYCCABKAVIBogBARIQCgNtYXgYCSABKAVIB4gBARIjCgVjdHlwZRgKIAMoDjIULmNvbXAudjEuQ29udGVudFR5cGUSGwoEdGV4dBgLIAMoCzINLmNvbXAudjEuVGV4dBIdCgV2aWRlbxgMIAMoCzIOLmNvbXAudjEuVmlkZW8SHQoFaW1hZ2UYDSADKAsyDi5jb21wLnYxLkltYWdlEh0KBWF1ZGlvGA4gAygLMg4uY29tcC52MS5BdWRpbxIkCgNleHQYDyABKAsyFy5nb29nbGUucHJvdG9idWYuU3RydWN0QggKBl9zY29wZUIHCgVfYXVzZUIMCgpfcHJpY2V0eXBlQgwKCl9wcmljZXRpZXJCDAoKX3VuaXRwcmljZUIGCgRfY3VyQg0KC19saWNlbnNlZHVyQgYKBF9tYXgihgMKBFRleHQSEgoFdGl0bGUYASABKAlIAIgBARIRCgl3b3JkY291bnQYAiADKAUSFAoHcHViZGF0ZRgDIAEoCUgBiAEBEhYKCXB1Ymxpc2hlZBgEIAEoBUgCiAEBEhMKBnVwZGF0ZRgFIAEoCUgDiAEBEg4KBmF1dGhvchgGIAMoCRIXCgpzb3VyY2V0eXBlGAcgASgFSASIAQESFwoKcHJvdmVuYW5jZRgIIAEoBUgFiAEBEhQKB3Byb3ZlbnQYCSABKAlIBogBARITCgZjYXR0YXgYCiABKAVIB4gBARILCgNjYXQYCyADKAUSEAoIbGFuZ3VhZ2UYDCADKAUSJAoDZXh0GA8gASgLMhcuZ29vZ2xlLnByb3RvYnVmLlN0cnVjdEIICgZfdGl0bGVCCgoIX3B1YmRhdGVCDAoKX3B1Ymxpc2hlZEIJCgdfdXBkYXRlQg0KC19zb3VyY2V0eXBlQg0KC19wcm92ZW5hbmNlQgoKCF9wcm92ZW50QgkKB19jYXR0YXgiyQMKBVZpZGVvEg0KBXRpdGxlGAEgAygJEgsKA2R1chgCIAMoBRIRCgRjbGlwGAMgASgFSACIAQESEQoJd29yZGNvdW50GAQgAygFEhcKCnRyYW5zY3JpcHQYBSABKAVIAYgBARIUCgdwdWJkYXRlGAYgASgJSAKIAQESFgoJcHVibGlzaGVkGAcgASgFSAOIAQESEwoGdXBkYXRlGAggASgJSASIAQESDgoGYXV0aG9yGAkgAygJEhcKCnNvdXJjZXR5cGUYCiABKAVIBYgBARIXCgpwcm92ZW5hbmNlGAsgASgFSAaIAQESFAoHcHJvdmVudBgMIAEoCUgHiAEBEhMKBmNhdHRheBgNIAEoBUgIiAEBEgsKA2NhdBgOIAMoBRIQCghsYW5ndWFnZRgQIAMoBRIkCgNleHQYDyABKAsyFy5nb29nbGUucHJvdG9idWYuU3RydWN0QgcKBV9jbGlwQg0KC190cmFuc2NyaXB0QgoKCF9wdWJkYXRlQgwKCl9wdWJsaXNoZWRCCQoHX3VwZGF0ZUINCgtfc291cmNldHlwZUINCgtfcHJvdmVuYW5jZUIKCghfcHJvdmVudEIJCgdfY2F0dGF4IuUCCgVJbWFnZRINCgV0aXRsZRgBIAMoCRIUCgdwdWJkYXRlGAIgASgJSACIAQESFgoJcHVibGlzaGVkGAMgASgFSAGIAQESEwoGdXBkYXRlGAQgASgJSAKIAQESDgoGYXV0aG9yGAUgAygJEhcKCnNvdXJjZXR5cGUYBiABKAVIA4gBARIXCgpwcm92ZW5hbmNlGAcgASgFSASIAQESFAoHcHJvdmVudBgIIAEoCUgFiAEBEhMKBmNhdHRheBgJIAEoBUgGiAEBEgsKA2NhdBgKIAMoBRIQCghsYW5ndWFnZRgLIAMoBRIkCgNleHQYDyABKAsyFy5nb29nbGUucHJvdG9idWYuU3RydWN0QgoKCF9wdWJkYXRlQgwKCl9wdWJsaXNoZWRCCQoHX3VwZGF0ZUINCgtfc291cmNldHlwZUINCgtfcHJvdmVuYW5jZUIKCghfcHJvdmVudEIJCgdfY2F0dGF4Iq0DCgVBdWRpbxINCgV0aXRsZRgBIAMoCRILCgNkdXIYAiADKAUSEQoJd29yZGNvdW50GAMgAygFEhcKCnRyYW5zY3JpcHQYBCABKAVIAIgBARIUCgdwdWJkYXRlGAUgASgJSAGIAQESFgoJcHVibGlzaGVkGAYgASgFSAKIAQESEwoGdXBkYXRlGAcgASgJSAOIAQESDgoGYXV0aG9yGAggAygJEhcKCnNvdXJjZXR5cGUYCSABKAVIBIgBARIXCgpwcm92ZW5hbmNlGAogASgFSAWIAQESFAoHcHJvdmVudBgLIAEoCUgGiAEBEhMKBmNhdHRheBgMIAEoBUgHiAEBEgsKA2NhdBgNIAMoBRIQCghsYW5ndWFnZRgOIAMoBRIkCgNleHQYDyABKAsyFy5nb29nbGUucHJvdG9idWYuU3RydWN0Qg0KC190cmFuc2NyaXB0QgoKCF9wdWJkYXRlQgwKCl9wdWJsaXNoZWRCCQoHX3VwZGF0ZUINCgtfc291cmNldHlwZUINCgtfcHJvdmVuYW5jZUIKCghfcHJvdmVudEIJCgdfY2F0dGF4Iq8BCglSZXRyaWV2YWwSKQoEYXV0aBgBIAEoDjIWLmNvbXAudjEuUmV0cmlldmFsQXV0aEgAiAEBEhUKCGVuZHBvaW50GAIgASgJSAGIAQESJAoEdHlwZRgDIAMoDjIWLmNvbXAudjEuUmV0cmlldmFsVHlwZRIkCgNleHQYDyABKAsyFy5nb29nbGUucHJvdG9idWYuU3RydWN0QgcKBV9hdXRoQgsKCV9lbmRwb2ludCqcAQoKQXV0aE1ldGhvZBIaChZBVVRIX01FVEhPRF9VU0VSX0FHRU5UEAASEgoOQVVUSF9NRVRIT0RfSVAQARIVChFBVVRIX01FVEhPRF9UT0tFThACEhYKEkFVVEhfTUVUSE9EX1dFQkJPVBADEhgKFEFVVEhfTUVUSE9EX0FHRU5UX0lEEAQSFQoRQVVUSF9NRVRIT0RfT1RIRVIQBSq0AQoJU2NvcGVUeXBlEhoKFlNDT1BFX1RZUEVfRlVMTF9DT1JQVVMQABIWChJTQ09QRV9UWVBFX1NFQ1RJT04QARIZChVTQ09QRV9UWVBFX0RBVEVfUkFOR0UQAhIUChBTQ09QRV9UWVBFX0dFTlJFEAMSFAoQU0NPUEVfVFlQRV9UT1BJQxAEEhYKElNDT1BFX1RZUEVfQ1VSQVRFRBAFEhQKEFNDT1BFX1RZUEVfT1RIRVIQBiqLAQoIRnVuY3Rpb24SEAoMRlVOQ1RJT05fQUxMEAASEwoPRlVOQ1RJT05fQUlfQUxMEAESFQoRRlVOQ1RJT05fQUlfVFJBSU4QAhIVChFGVU5DVElPTl9BSV9JTlBVVBADEhUKEUZVTkNUSU9OX0FJX0lOREVYEAQSEwoPRlVOQ1RJT05fU0VBUkNIEAUqrwEKC1N1YkZ1bmN0aW9uEhkKFVNVQl9GVU5DVElPTl9UUkFJTklORxAAEhQKEFNVQl9GVU5DVElPTl9SQUcQARIaChZTVUJfRlVOQ1RJT05fR1JPVU5ESU5HEAISGwoXU1VCX0ZVTkNUSU9OX0FHRU5UX1ZJRVcQAxIeChpTVUJfRlVOQ1RJT05fQUdFTlRfQUNUSU9OUxAEEhYKElNVQl9GVU5DVElPTl9PVEhFUhAFKs8BCgpBbGxvd2VkVXNlEhoKFkFMTE9XRURfVVNFX0NPTU1FUkNJQUwQABIeChpBTExPV0VEX1VTRV9OT05fQ09NTUVSQ0lBTBABEhsKF0FMTE9XRURfVVNFX0VEVUNBVElPTkFMEAISGgoWQUxMT1dFRF9VU0VfR09WRVJOTUVOVBADEhgKFEFMTE9XRURfVVNFX1BFUlNPTkFMEAQSGwoXQUxMT1dFRF9VU0VfQllPX0xJQ0VOU0UQBRIVChFBTExPV0VEX1VTRV9PVEhFUhAGKpkBCglQcmljZVR5cGUSFgoSUFJJQ0VfVFlQRV9QRVJfVVNFEAASGAoUUFJJQ0VfVFlQRV9QRVJfUVVFUlkQARIYChRQUklDRV9UWVBFX1BFUl9UT0tFThACEhMKD1BSSUNFX1RZUEVfRkxBVBADEhUKEVBSSUNFX1RZUEVfVElFUkVEEAQSFAoQUFJJQ0VfVFlQRV9PVEhFUhAFKpoBCgtDb250ZW50VHlwZRIVChFDT05URU5UX1RZUEVfVEVYVBAAEhYKEkNPTlRFTlRfVFlQRV9WSURFTxABEhYKEkNPTlRFTlRfVFlQRV9JTUFHRRACEhYKEkNPTlRFTlRfVFlQRV9BVURJTxADEhQKEENPTlRFTlRfVFlQRV9BTEwQBBIWChJDT05URU5UX1RZUEVfT1RIRVIQBSqRAQoNUmV0cmlldmFsQXV0aBIXChNSRVRSSUVWQUxfQVVUSF9OT05FEAASGgoWUkVUUklFVkFMX0FVVEhfQVBJX0tFWRABEhkKFVJFVFJJRVZBTF9BVVRIX09BVVRIMhACEhYKElJFVFJJRVZBTF9BVVRIX1NTTBADEhgKFFJFVFJJRVZBTF9BVVRIX09USEVSEAQq1wEKDVJldHJpZXZhbFR5cGUSFwoTUkVUUklFVkFMX1RZUEVfSFRNTBAAEhYKElJFVFJJRVZBTF9UWVBFX1JTUxABEhYKElJFVFJJRVZBTF9UWVBFX0FQSRACEhYKElJFVFJJRVZBTF9UWVBFX01DUBADEhgKFFJFVFJJRVZBTF9UWVBFX05MV0VCEAQSFgoSUkVUUklFVkFMX1RZUEVfWE1MEAUSGQoVUkVUUklFVkFMX1RZUEVfTkVXU01MEAYSGAoUUkVUUklFVkFMX1RZUEVfT1RIRVIQB0KOAQoLY29tLmNvbXAudjFCCUNvbXBQcm90b1ABWjdnaXRodWIuY29tL1JBTVAtUHJvdG9jb2wvcHJvdG9jb2wvZ2VuL2dvL2NvbXAvdjE7Y29tcHYxogIDQ1hYqgIHQ29tcC5WMcoCB0NvbXBcVjHiAhNDb21wXFYxXEdQQk1ldGFkYXRh6gIIQ29tcDo6VjFiBnByb3RvMw", [file_google_protobuf_struct]); - -/** - * AISystem — Information about the AI System making the request. - * CoMP §aisystem.json - * - * @generated from message comp.v1.AISystem - */ -export type AISystem = Message<"comp.v1.AISystem"> & { - /** - * Canonical domain of the AI System requesting access. - * - * @generated from field: string name = 1; - */ - name: string; - - /** - * User agent of the AI System. - * - * @generated from field: optional string ua = 2; - */ - ua?: string | undefined; - - /** - * ID of the AI system, as registered by Tech Lab Agent Registry. - * - * @generated from field: optional string id = 3; - */ - id?: string | undefined; - - /** - * How the AI system will use the content. - * - * @generated from field: comp.v1.AISystemUse aisysuse = 4; - */ - aisysuse?: AISystemUse | undefined; - - /** - * Implementer-specific extensions. - * - * @generated from field: google.protobuf.Struct ext = 15; - */ - ext?: JsonObject | undefined; -}; - -/** - * Describes the message comp.v1.AISystem. - * Use `create(AISystemSchema)` to create a new message. - */ -export const AISystemSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_comp_v1_comp, 0); - -/** - * AISystemUse — Intended use of the requested content. - * CoMP §aisystemuse.json - * - * @generated from message comp.v1.AISystemUse - */ -export type AISystemUse = Message<"comp.v1.AISystemUse"> & { - /** - * License ID — public agent identifier in CoMP. NOT a credential. - * In RAMP this maps to Requester.billing_ref (a billing handle, not an - * entitlement). Request authentication is an RFC 9421 HTTP Message Signature; - * the agent's key is resolved by its domain, never by this id. - * - * @generated from field: string lid = 1; - */ - lid: string; - - /** - * How the AI system authenticates itself. - * - * @generated from field: comp.v1.AuthMethod aiauth = 2; - */ - aiauth: AuthMethod; - - /** - * URI(s) that the AI System is requesting to crawl. - * - * @generated from field: repeated string uri = 3; - */ - uri: string[]; - - /** - * General information about the content scope requested. - * - * @generated from field: optional comp.v1.ScopeType scope = 4; - */ - scope?: ScopeType | undefined; - - /** - * Function(s) the AI System will use the content for. - * - * @generated from field: repeated comp.v1.Function function = 5; - */ - function: Function[]; - - /** - * Sub-function(s) for content use. - * - * @generated from field: repeated comp.v1.SubFunction subfn = 6; - */ - subfn: SubFunction[]; - - /** - * Whether results will be displayed to a human user. 0=no, 1=yes. - * - * @generated from field: optional int32 resdis = 7; - */ - resdis?: number | undefined; - - /** - * @generated from field: google.protobuf.Struct ext = 15; - */ - ext?: JsonObject | undefined; -}; - -/** - * Describes the message comp.v1.AISystemUse. - * Use `create(AISystemUseSchema)` to create a new message. - */ -export const AISystemUseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_comp_v1_comp, 1); - -/** - * Package — A package of content inventory. - * CoMP §package.json - * - * @generated from message comp.v1.Package - */ -export type Package = Message<"comp.v1.Package"> & { - /** - * Unique package identifier defined by the Content Owner or Marketplace. - * - * @generated from field: string id = 1; - */ - id: string; - - /** - * Title of the package. - * - * @generated from field: optional string title = 2; - */ - title?: string | undefined; - - /** - * Canonical domain of the business entity offering the content. - * - * @generated from field: optional string seller = 3; - */ - seller?: string | undefined; - - /** - * Canonical domain of the packager, if different than the seller. - * - * @generated from field: optional string packager = 4; - */ - packager?: string | undefined; - - /** - * URL for AI system to find the License(s) required to access content. - * SSRF/fetch sink: a consumer that dereferences this URL must apply the - * countermeasures specified for core License.uri (threat model T-LIC-1). - * - * @generated from field: optional string licenseurl = 5; - */ - licenseurl?: string | undefined; - - /** - * Whether citation of the Content Owner is required. 0=no, 1=yes. - * - * @generated from field: optional int32 citation = 6; - */ - citation?: number | undefined; - - /** - * URL for AI system to send usage reporting to the Content Owner or - * Marketplace. POST/exfil sink: apply the SSRF countermeasures specified - * for core License.uri (threat model T-LIC-1) before dereferencing. - * - * @generated from field: optional string reporturl = 7; - */ - reporturl?: string | undefined; - - /** - * Scope of content in this package. - * - * @generated from field: optional comp.v1.Scope scope = 8; - */ - scope?: Scope | undefined; - - /** - * How to retrieve the content. - * - * @generated from field: optional comp.v1.Retrieval retrieval = 9; - */ - retrieval?: Retrieval | undefined; - - /** - * @generated from field: google.protobuf.Struct ext = 15; - */ - ext?: JsonObject | undefined; -}; - -/** - * Describes the message comp.v1.Package. - * Use `create(PackageSchema)` to create a new message. - */ -export const PackageSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_comp_v1_comp, 2); - -/** - * Scope — What content is included in the package, plus its commercial terms. - * CoMP §scope.json — canonical V1 folds licensing/pricing into Scope. - * - * @generated from message comp.v1.Scope - */ -export type Scope = Message<"comp.v1.Scope"> & { - /** - * How much of the Content Owner's corpus is available in this package. - * - * @generated from field: optional comp.v1.ScopeType scope = 1; - */ - scope?: ScopeType | undefined; - - /** - * Kind of use that is allowed for this package. - * - * @generated from field: optional comp.v1.AllowedUse ause = 2; - */ - ause?: AllowedUse | undefined; - - /** - * How the package is priced. - * - * @generated from field: optional comp.v1.PriceType pricetype = 3; - */ - pricetype?: PriceType | undefined; - - /** - * Tier identifier if pricing is tiered (pricetype = 4). - * - * @generated from field: optional int32 pricetier = 4; - */ - pricetier?: number | undefined; - - /** - * Content Owner set unit price at the given basis. Typed `double` to mirror - * the CoMP spec; downstream billing must convert to a decimal / minor-units - * representation — do not settle money directly off this float. - * - * @generated from field: optional double unitprice = 5; - */ - unitprice?: number | undefined; - - /** - * Bid currency using ISO-4217 alpha codes. Default "USD". - * - * @generated from field: optional string cur = 6; - */ - cur?: string | undefined; - - /** - * Country code(s) where this package may be used, expressed by - * ISO-3166-1 numeric codes. - * - * @generated from field: repeated int32 country = 7; - */ - country: number[]; - - /** - * Number of days the license is active, once the content package has been - * delivered to the AI System. - * - * @generated from field: optional int32 licensedur = 8; - */ - licensedur?: number | undefined; - - /** - * Upper limit on crawl. 0=unlimited, 1=has maximum. - * - * @generated from field: optional int32 max = 9; - */ - max?: number | undefined; - - /** - * Content type(s) included. - * - * @generated from field: repeated comp.v1.ContentType ctype = 10; - */ - ctype: ContentType[]; - - /** - * Text assets. - * - * @generated from field: repeated comp.v1.Text text = 11; - */ - text: Text[]; - - /** - * Video assets. - * - * @generated from field: repeated comp.v1.Video video = 12; - */ - video: Video[]; - - /** - * Image assets. - * - * @generated from field: repeated comp.v1.Image image = 13; - */ - image: Image[]; - - /** - * Audio assets. - * - * @generated from field: repeated comp.v1.Audio audio = 14; - */ - audio: Audio[]; - - /** - * @generated from field: google.protobuf.Struct ext = 15; - */ - ext?: JsonObject | undefined; -}; - -/** - * Describes the message comp.v1.Scope. - * Use `create(ScopeSchema)` to create a new message. - */ -export const ScopeSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_comp_v1_comp, 3); - -/** - * Text — A text-based asset. - * CoMP §text.json - * - * @generated from message comp.v1.Text - */ -export type Text = Message<"comp.v1.Text"> & { - /** - * @generated from field: optional string title = 1; - */ - title?: string | undefined; - - /** - * Count of whitespace-delimited words in main body. - * - * @generated from field: repeated int32 wordcount = 2; - */ - wordcount: number[]; - - /** - * Original publication date (ISO 8601). - * - * @generated from field: optional string pubdate = 3; - */ - pubdate?: string | undefined; - - /** - * Denotes if the content is publicly available. Default 1 for Text. - * - * @generated from field: optional int32 published = 4; - */ - published?: number | undefined; - - /** - * Most recent substantive update (ISO 8601). - * - * @generated from field: optional string update = 5; - */ - update?: string | undefined; - - /** - * Author name(s). - * - * @generated from field: repeated string author = 6; - */ - author: string[]; - - /** - * Source of creation. 0=human, 1=ai, 2=hybrid. - * - * @generated from field: optional int32 sourcetype = 7; - */ - sourcetype?: number | undefined; - - /** - * Provenance available. 0=no, 1=yes. - * - * @generated from field: optional int32 provenance = 8; - */ - provenance?: number | undefined; - - /** - * Canonical domain of the provenance entity (e.g. c2pa.org). - * Required if provenance = 1. - * - * @generated from field: optional string provent = 9; - */ - provent?: string | undefined; - - /** - * IAB category taxonomy in use. Default 9 (IAB Content Category Taxonomy 3.1). - * - * @generated from field: optional int32 cattax = 10; - */ - cattax?: number | undefined; - - /** - * IAB Tech Lab content category codes (per cattax). - * - * @generated from field: repeated int32 cat = 11; - */ - cat: number[]; - - /** - * Content language. CoMP declares this `int` (array) though its prose says - * ISO-639-1 (alpha-2) — an upstream inconsistency; the proto follows the - * declared `int` type. - * - * @generated from field: repeated int32 language = 12; - */ - language: number[]; - - /** - * @generated from field: google.protobuf.Struct ext = 15; - */ - ext?: JsonObject | undefined; -}; - -/** - * Describes the message comp.v1.Text. - * Use `create(TextSchema)` to create a new message. - */ -export const TextSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_comp_v1_comp, 4); - -/** - * Video — A video asset. - * CoMP §video.json - * - * @generated from message comp.v1.Video - */ -export type Video = Message<"comp.v1.Video"> & { - /** - * @generated from field: repeated string title = 1; - */ - title: string[]; - - /** - * Duration in seconds. - * - * @generated from field: repeated int32 dur = 2; - */ - dur: number[]; - - /** - * 0=full, 1=clip. - * - * @generated from field: optional int32 clip = 3; - */ - clip?: number | undefined; - - /** - * @generated from field: repeated int32 wordcount = 4; - */ - wordcount: number[]; - - /** - * 0=no, 1=yes. - * - * @generated from field: optional int32 transcript = 5; - */ - transcript?: number | undefined; - - /** - * @generated from field: optional string pubdate = 6; - */ - pubdate?: string | undefined; - - /** - * Denotes if the content is publicly available. Default 0 for Video. - * - * @generated from field: optional int32 published = 7; - */ - published?: number | undefined; - - /** - * @generated from field: optional string update = 8; - */ - update?: string | undefined; - - /** - * @generated from field: repeated string author = 9; - */ - author: string[]; - - /** - * @generated from field: optional int32 sourcetype = 10; - */ - sourcetype?: number | undefined; - - /** - * @generated from field: optional int32 provenance = 11; - */ - provenance?: number | undefined; - - /** - * @generated from field: optional string provent = 12; - */ - provent?: string | undefined; - - /** - * IAB category taxonomy in use. Default 9 (IAB Content Category Taxonomy 3.1). - * - * @generated from field: optional int32 cattax = 13; - */ - cattax?: number | undefined; - - /** - * IAB Tech Lab content category codes (per cattax). - * - * @generated from field: repeated int32 cat = 14; - */ - cat: number[]; - - /** - * Content language. CoMP declares this `int` (array) though its prose says - * ISO-639-1 (alpha-2) — an upstream inconsistency; the proto follows the - * declared `int` type. - * Tag 16 sits above ext (15) only here: cattax/cat consumed 13/14, and ext is - * kept at 15 for cross-message uniformity rather than the usual terminal slot. - * - * @generated from field: repeated int32 language = 16; - */ - language: number[]; - - /** - * @generated from field: google.protobuf.Struct ext = 15; - */ - ext?: JsonObject | undefined; -}; - -/** - * Describes the message comp.v1.Video. - * Use `create(VideoSchema)` to create a new message. - */ -export const VideoSchema: GenMessage